Multi instance #26
|
@ -30,6 +30,7 @@ open Microsoft.Extensions.Configuration
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
open Microsoft.IdentityModel.Tokens
|
open Microsoft.IdentityModel.Tokens
|
||||||
open System.Text
|
open System.Text
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
/// Configure dependency injection
|
/// Configure dependency injection
|
||||||
let configureServices (svc : IServiceCollection) =
|
let configureServices (svc : IServiceCollection) =
|
||||||
|
@ -57,10 +58,11 @@ let configureServices (svc : IServiceCollection) =
|
||||||
ValidAudience = "https://noagendacareers.com",
|
ValidAudience = "https://noagendacareers.com",
|
||||||
ValidIssuer = "https://noagendacareers.com",
|
ValidIssuer = "https://noagendacareers.com",
|
||||||
IssuerSigningKey = SymmetricSecurityKey (
|
IssuerSigningKey = SymmetricSecurityKey (
|
||||||
Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"]))))
|
Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"])))
|
||||||
|> ignore
|
|> ignore
|
||||||
svc.AddAuthorization () |> ignore
|
svc.AddAuthorization () |> ignore
|
||||||
|
svc.Configure<AuthOptions> (cfg.GetSection "Auth") |> ignore
|
||||||
|
|
||||||
let dbCfg = cfg.GetSection "Rethink"
|
let dbCfg = cfg.GetSection "Rethink"
|
||||||
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
||||||
let conn = Data.Startup.createConnection dbCfg log
|
let conn = Data.Startup.createConnection dbCfg log
|
||||||
|
|
|
@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth
|
||||||
|
|
||||||
open System.Text.Json.Serialization
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
/// The variables we need from the account information we get from No Agenda Social
|
/// The variables we need from the account information we get from Mastodon
|
||||||
[<NoComparison; NoEquality; AllowNullLiteral>]
|
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||||
type MastodonAccount () =
|
type MastodonAccount () =
|
||||||
/// The user name (what we store as naUser)
|
/// The user name (what we store as naUser)
|
||||||
[<JsonPropertyName "username">]
|
[<JsonPropertyName "username">]
|
||||||
member val Username = "" with get, set
|
member val Username = "" with get, set
|
||||||
/// The account name; will be the same as username for local (non-federated) accounts
|
/// The account name; will generally be the same as username for local accounts, which is all we can verify
|
||||||
[<JsonPropertyName "acct">]
|
[<JsonPropertyName "acct">]
|
||||||
member val AccountName = "" with get, set
|
member val AccountName = "" with get, set
|
||||||
/// The user's display name as it currently shows on No Agenda Social
|
/// The user's display name as it currently shows on Mastodon
|
||||||
[<JsonPropertyName "display_name">]
|
[<JsonPropertyName "display_name">]
|
||||||
member val DisplayName = "" with get, set
|
member val DisplayName = "" with get, set
|
||||||
/// The user's profile URL
|
/// The user's profile URL
|
||||||
|
@ -21,25 +21,29 @@ type MastodonAccount () =
|
||||||
|
|
||||||
|
|
||||||
open FSharp.Control.Tasks
|
open FSharp.Control.Tasks
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
open System
|
open System
|
||||||
open System.Net.Http
|
open System.Net.Http
|
||||||
open System.Net.Http.Headers
|
open System.Net.Http.Headers
|
||||||
open System.Net.Http.Json
|
open System.Net.Http.Json
|
||||||
open System.Text.Json
|
open System.Text.Json
|
||||||
|
open JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
|
/// HTTP client to use to communication with Mastodon
|
||||||
|
let private http = new HttpClient()
|
||||||
|
|
||||||
/// Verify the authorization code with Mastodon and get the user's profile
|
/// Verify the authorization code with Mastodon and get the user's profile
|
||||||
let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
|
let verifyWithMastodon (authCode : string) (inst : MastodonInstance) rtnHost (log : ILogger) = task {
|
||||||
|
|
||||||
use http = new HttpClient()
|
// Function to create a URL for the given instance
|
||||||
|
let apiUrl = sprintf "%s/api/v1/%s" inst.Url
|
||||||
|
|
||||||
// Use authorization code to get an access token from NAS
|
// Use authorization code to get an access token from Mastodon
|
||||||
use! codeResult =
|
use! codeResult =
|
||||||
http.PostAsJsonAsync("https://noagendasocial.com/oauth/token",
|
http.PostAsJsonAsync($"{inst.Url}/oauth/token",
|
||||||
{| client_id = cfg.["ClientId"]
|
{| client_id = inst.ClientId
|
||||||
client_secret = cfg.["Secret"]
|
client_secret = inst.Secret
|
||||||
redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"]
|
redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized"
|
||||||
grant_type = "authorization_code"
|
grant_type = "authorization_code"
|
||||||
code = authCode
|
code = authCode
|
||||||
scope = "read"
|
scope = "read"
|
||||||
|
@ -49,11 +53,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
|
||||||
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
|
||||||
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
use tokenResponse = JsonSerializer.Deserialize<JsonDocument> (ReadOnlySpan<byte> responseBytes)
|
||||||
match tokenResponse with
|
match tokenResponse with
|
||||||
| null ->
|
| null -> return Error "Could not parse authorization code result"
|
||||||
return Error "Could not parse authorization code result"
|
|
||||||
| _ ->
|
| _ ->
|
||||||
// Use access token to get profile from NAS
|
// Use access token to get profile from NAS
|
||||||
use req = new HttpRequestMessage (HttpMethod.Get, sprintf "%saccounts/verify_credentials" cfg.["ApiUrl"])
|
use req = new HttpRequestMessage (HttpMethod.Get, apiUrl "accounts/verify_credentials")
|
||||||
req.Headers.Authorization <- AuthenticationHeaderValue
|
req.Headers.Authorization <- AuthenticationHeaderValue
|
||||||
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
|
||||||
use! profileResult = http.SendAsync req
|
use! profileResult = http.SendAsync req
|
||||||
|
@ -62,19 +65,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log :
|
||||||
| true ->
|
| true ->
|
||||||
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
let! profileBytes = profileResult.Content.ReadAsByteArrayAsync ()
|
||||||
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
match JsonSerializer.Deserialize<MastodonAccount>(ReadOnlySpan<byte> profileBytes) with
|
||||||
| null ->
|
| null -> return Error "Could not parse profile result"
|
||||||
return Error "Could not parse profile result"
|
| profile -> return Ok profile
|
||||||
| x when x.Username <> x.AccountName ->
|
| false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
||||||
return Error $"Profiles must be from noagendasocial.com; yours is {x.AccountName}"
|
|
||||||
| profile ->
|
|
||||||
return Ok profile
|
|
||||||
| false ->
|
|
||||||
return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})"
|
|
||||||
| false ->
|
| false ->
|
||||||
let! err = codeResult.Content.ReadAsStringAsync ()
|
let! err = codeResult.Content.ReadAsStringAsync ()
|
||||||
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
log.LogError $"Could not get token result from Mastodon:\n {err}"
|
||||||
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -86,7 +83,7 @@ open System.Security.Claims
|
||||||
open System.Text
|
open System.Text
|
||||||
|
|
||||||
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
/// Create a JSON Web Token for this citizen to use for further requests to this API
|
||||||
let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
let createJwt (citizen : Citizen) (cfg : AuthOptions) =
|
||||||
|
|
||||||
let tokenHandler = JwtSecurityTokenHandler ()
|
let tokenHandler = JwtSecurityTokenHandler ()
|
||||||
let token =
|
let token =
|
||||||
|
@ -100,8 +97,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) =
|
||||||
Issuer = "https://noagendacareers.com",
|
Issuer = "https://noagendacareers.com",
|
||||||
Audience = "https://noagendacareers.com",
|
Audience = "https://noagendacareers.com",
|
||||||
SigningCredentials = SigningCredentials (
|
SigningCredentials = SigningCredentials (
|
||||||
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]),
|
SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature)
|
||||||
SecurityAlgorithms.HmacSha256Signature)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tokenHandler.WriteToken token
|
tokenHandler.WriteToken token
|
||||||
|
|
|
@ -23,23 +23,23 @@ module Error =
|
||||||
|
|
||||||
/// URL prefixes for the Vue app
|
/// URL prefixes for the Vue app
|
||||||
let vueUrls = [
|
let vueUrls = [
|
||||||
"/"; "/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
"/how-it-works"; "/privacy-policy"; "/terms-of-service"; "/citizen"; "/help-wanted"; "/listing"; "/profile"
|
||||||
"/so-long"; "/success-story"
|
"/so-long"; "/success-story"
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Handler that will return a status code 404 and the text "Not Found"
|
/// Handler that will return a status code 404 and the text "Not Found"
|
||||||
let notFound : HttpHandler =
|
let notFound : HttpHandler =
|
||||||
fun next ctx -> task {
|
fun next ctx -> task {
|
||||||
let fac = ctx.GetService<ILoggerFactory>()
|
let fac = ctx.GetService<ILoggerFactory> ()
|
||||||
let log = fac.CreateLogger("Handler")
|
let log = fac.CreateLogger "Handler"
|
||||||
|
let path = string ctx.Request.Path
|
||||||
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
|
||||||
| true when vueUrls |> List.exists (fun url -> ctx.Request.Path.ToString().StartsWith url) ->
|
| true when path = "/" || vueUrls |> List.exists path.StartsWith ->
|
||||||
log.LogInformation "Returning Vue app"
|
log.LogInformation "Returning Vue app"
|
||||||
return! Vue.app next ctx
|
return! Vue.app next ctx
|
||||||
| _ ->
|
| _ ->
|
||||||
log.LogInformation "Returning 404"
|
log.LogInformation "Returning 404"
|
||||||
return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next
|
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
||||||
ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler that returns a 403 NOT AUTHORIZED response
|
/// Handler that returns a 403 NOT AUTHORIZED response
|
||||||
|
@ -58,6 +58,7 @@ module Helpers =
|
||||||
|
|
||||||
open NodaTime
|
open NodaTime
|
||||||
open Microsoft.Extensions.Configuration
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Options
|
||||||
open RethinkDb.Driver.Net
|
open RethinkDb.Driver.Net
|
||||||
open System.Security.Claims
|
open System.Security.Claims
|
||||||
|
|
||||||
|
@ -67,6 +68,9 @@ module Helpers =
|
||||||
/// Get the application configuration from the request context
|
/// Get the application configuration from the request context
|
||||||
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
let config (ctx : HttpContext) = ctx.GetService<IConfiguration> ()
|
||||||
|
|
||||||
|
/// Get the authorization configuration from the request context
|
||||||
|
let authConfig (ctx : HttpContext) = (ctx.GetService<IOptions<AuthOptions>> ()).Value
|
||||||
|
|
||||||
/// Get the logger factory from the request context
|
/// Get the logger factory from the request context
|
||||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||||
|
|
||||||
|
@ -104,46 +108,49 @@ module Helpers =
|
||||||
module Citizen =
|
module Citizen =
|
||||||
|
|
||||||
// GET: /api/citizen/log-on/[code]
|
// GET: /api/citizen/log-on/[code]
|
||||||
let logOn authCode : HttpHandler =
|
let logOn (abbr, authCode) : HttpHandler =
|
||||||
fun next ctx -> task {
|
fun next ctx -> task {
|
||||||
// Step 1 - Verify with Mastodon
|
// Step 1 - Verify with Mastodon
|
||||||
let cfg = (config ctx).GetSection "Auth"
|
let cfg = authConfig ctx
|
||||||
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
|
||||||
|
|
||||||
match! Auth.verifyWithMastodon authCode cfg log with
|
match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
|
||||||
| Ok account ->
|
| Some instance ->
|
||||||
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth)
|
||||||
let now = (clock ctx).GetCurrentInstant ()
|
|
||||||
let dbConn = conn ctx
|
|
||||||
let! citizen = task {
|
|
||||||
match! Data.Citizen.findByNaUser account.Username dbConn with
|
|
||||||
| None ->
|
|
||||||
let it : Citizen =
|
|
||||||
{ id = CitizenId.create ()
|
|
||||||
naUser = account.Username
|
|
||||||
displayName = noneIfEmpty account.DisplayName
|
|
||||||
realName = None
|
|
||||||
profileUrl = account.Url
|
|
||||||
joinedOn = now
|
|
||||||
lastSeenOn = now
|
|
||||||
}
|
|
||||||
do! Data.Citizen.add it dbConn
|
|
||||||
return it
|
|
||||||
| Some citizen ->
|
|
||||||
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
|
||||||
do! Data.Citizen.logOnUpdate it dbConn
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3 - Generate JWT
|
match! Auth.verifyWithMastodon authCode instance cfg.ReturnUrl log with
|
||||||
return!
|
| Ok account ->
|
||||||
json
|
// Step 2 - Find / establish Jobs, Jobs, Jobs account
|
||||||
{ jwt = Auth.createJwt citizen cfg
|
let now = (clock ctx).GetCurrentInstant ()
|
||||||
citizenId = CitizenId.toString citizen.id
|
let dbConn = conn ctx
|
||||||
name = Citizen.name citizen
|
let! citizen = task {
|
||||||
} next ctx
|
match! Data.Citizen.findByNaUser account.Username dbConn with
|
||||||
| Error err ->
|
| None ->
|
||||||
return! RequestErrors.BAD_REQUEST err next ctx
|
let it : Citizen =
|
||||||
|
{ id = CitizenId.create ()
|
||||||
|
naUser = account.Username
|
||||||
|
displayName = noneIfEmpty account.DisplayName
|
||||||
|
realName = None
|
||||||
|
profileUrl = account.Url
|
||||||
|
joinedOn = now
|
||||||
|
lastSeenOn = now
|
||||||
|
}
|
||||||
|
do! Data.Citizen.add it dbConn
|
||||||
|
return it
|
||||||
|
| Some citizen ->
|
||||||
|
let it = { citizen with displayName = noneIfEmpty account.DisplayName; lastSeenOn = now }
|
||||||
|
do! Data.Citizen.logOnUpdate it dbConn
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3 - Generate JWT
|
||||||
|
return!
|
||||||
|
json
|
||||||
|
{ jwt = Auth.createJwt citizen cfg
|
||||||
|
citizenId = CitizenId.toString citizen.id
|
||||||
|
name = Citizen.name citizen
|
||||||
|
} next ctx
|
||||||
|
| Error err -> return! RequestErrors.BAD_REQUEST err next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET: /api/citizen/[id]
|
// GET: /api/citizen/[id]
|
||||||
|
@ -176,6 +183,33 @@ module Continent =
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /api/instances routes
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Instances =
|
||||||
|
|
||||||
|
/// Convert a Masotodon instance to the one we use in the API
|
||||||
|
let private toInstance (inst : MastodonInstance) =
|
||||||
|
{ name = inst.Name
|
||||||
|
url = inst.Url
|
||||||
|
abbr = inst.Abbr
|
||||||
|
clientId = inst.ClientId
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/instances
|
||||||
|
let all : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
return! json ((authConfig ctx).Instances |> Array.map toInstance) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: /api/instance/[abbr]
|
||||||
|
let byAbbr abbr : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
match (authConfig ctx).Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with
|
||||||
|
| Some inst -> return! json (toInstance inst) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Handlers for /api/listing[s] routes
|
/// Handlers for /api/listing[s] routes
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Listing =
|
module Listing =
|
||||||
|
@ -489,12 +523,18 @@ let allEndpoints = [
|
||||||
subRoute "/api" [
|
subRoute "/api" [
|
||||||
subRoute "/citizen" [
|
subRoute "/citizen" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/log-on/%s" Citizen.logOn
|
routef "/log-on/%s/%s" Citizen.logOn
|
||||||
routef "/%O" Citizen.get
|
routef "/%O" Citizen.get
|
||||||
]
|
]
|
||||||
DELETE [ route "" Citizen.delete ]
|
DELETE [ route "" Citizen.delete ]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/continents" Continent.all ]
|
GET_HEAD [ route "/continents" Continent.all ]
|
||||||
|
subRoute "/instance" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "s" Instances.all
|
||||||
|
routef "/%s" Instances.byAbbr
|
||||||
|
]
|
||||||
|
]
|
||||||
subRoute "/listing" [
|
subRoute "/listing" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
routef "/%O" Listing.get
|
routef "/%O" Listing.get
|
||||||
|
|
|
@ -1,6 +1,24 @@
|
||||||
{
|
{
|
||||||
"Rethink": {
|
"Rethink": {
|
||||||
"Hostname": "localhost",
|
},
|
||||||
"Db": "jobsjobsjobs"
|
"Auth": {
|
||||||
|
"ReturnHost": "http://localhost:5000",
|
||||||
|
"Instances": {
|
||||||
|
"0": {
|
||||||
|
"Name": "No Agenda Social",
|
||||||
|
"Url": "https://noagendasocial.com",
|
||||||
|
"Abbr": "nas"
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"Name": "ITM Slaves!",
|
||||||
|
"Url": "https://itmslaves.com",
|
||||||
|
"Abbr": "itm"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"Name": "Liberty Woof",
|
||||||
|
"Url": "https://libertywoof.com",
|
||||||
|
"Abbr": "lw"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@ import {
|
||||||
Citizen,
|
Citizen,
|
||||||
Continent,
|
Continent,
|
||||||
Count,
|
Count,
|
||||||
|
Instance,
|
||||||
Listing,
|
Listing,
|
||||||
ListingExpireForm,
|
ListingExpireForm,
|
||||||
ListingForm,
|
ListingForm,
|
||||||
|
@ -100,11 +101,12 @@ export default {
|
||||||
/**
|
/**
|
||||||
* Log a citizen on
|
* Log a citizen on
|
||||||
*
|
*
|
||||||
* @param code The authorization code from No Agenda Social
|
* @param abbr The abbreviation of the Mastodon instance that issued the code
|
||||||
|
* @param code The authorization code from Mastodon
|
||||||
* @returns The user result, or an error
|
* @returns The user result, or an error
|
||||||
*/
|
*/
|
||||||
logOn: async (code : string) : Promise<LogOnSuccess | string> => {
|
logOn: async (abbr : string, code : string) : Promise<LogOnSuccess | string> => {
|
||||||
const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" })
|
const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" })
|
||||||
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
if (resp.status === 200) return await resp.json() as LogOnSuccess
|
||||||
return `Error logging on - ${await resp.text()}`
|
return `Error logging on - ${await resp.text()}`
|
||||||
},
|
},
|
||||||
|
@ -141,6 +143,27 @@ export default {
|
||||||
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
apiResult<Continent[]>(await fetch(apiUrl("continents"), { method: "GET" }), "retrieving continents")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** API functions for instances */
|
||||||
|
instances: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Mastodon instances we support
|
||||||
|
*
|
||||||
|
* @returns All instances, or an error
|
||||||
|
*/
|
||||||
|
all: async () : Promise<Instance[] | string | undefined> =>
|
||||||
|
apiResult<Instance[]>(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a Mastodon instance by its abbreviation
|
||||||
|
*
|
||||||
|
* @param abbr The abbreviation of the Mastodon instance to retrieve
|
||||||
|
* @returns The Mastodon instance (if found), undefined (if not found), or an error string
|
||||||
|
*/
|
||||||
|
byAbbr: async (abbr : string) : Promise<Instance | string | undefined> =>
|
||||||
|
apiResult<Instance>(await fetch(apiUrl(`instance/${abbr}`), { method: "GET" }), "retrieving Mastodon instance")
|
||||||
|
},
|
||||||
|
|
||||||
/** API functions for job listings */
|
/** API functions for job listings */
|
||||||
listings: {
|
listings: {
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,18 @@ export interface Count {
|
||||||
count : number
|
count : number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Mastodon instance data provided via the Jobs, Jobs, Jobs API */
|
||||||
|
export interface Instance {
|
||||||
|
/** The name of the instance */
|
||||||
|
name : string
|
||||||
|
/** The URL for this instance */
|
||||||
|
url : string
|
||||||
|
/** The abbreviation used in the URL to distinguish this instance's return codes */
|
||||||
|
abbr : string
|
||||||
|
/** The client ID (assigned by the Mastodon server) */
|
||||||
|
clientId : string
|
||||||
|
}
|
||||||
|
|
||||||
/** A job listing */
|
/** A job listing */
|
||||||
export interface Listing {
|
export interface Listing {
|
||||||
/** The ID of the job listing */
|
/** The ID of the job listing */
|
||||||
|
|
|
@ -53,7 +53,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||||
component: LogOn
|
component: LogOn
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/citizen/authorized",
|
path: "/citizen/:abbr/authorized",
|
||||||
name: "CitizenAuthorized",
|
name: "CitizenAuthorized",
|
||||||
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
|
component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue")
|
||||||
},
|
},
|
||||||
|
|
|
@ -43,8 +43,8 @@ export default createStore({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async logOn ({ commit }, code: string) {
|
async logOn ({ commit }, { abbr, code }) {
|
||||||
const logOnResult = await api.citizen.logOn(code)
|
const logOnResult = await api.citizen.logOn(abbr, code)
|
||||||
if (typeof logOnResult === "string") {
|
if (typeof logOnResult === "string") {
|
||||||
commit("setLogOnState", logOnResult)
|
commit("setLogOnState", logOnResult)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -7,30 +7,44 @@ article
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from "vue"
|
import { computed, onMounted } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import api from "@/api"
|
||||||
import { useStore } from "@/store"
|
import { useStore } from "@/store"
|
||||||
import { AFTER_LOG_ON_URL } from "@/router"
|
import { AFTER_LOG_ON_URL } from "@/router"
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** The abbreviation of the instance from which we received the code */
|
||||||
|
const abbr = route.params.abbr as string
|
||||||
|
|
||||||
|
/** Set the message for this component */
|
||||||
|
const setMessage = (msg : string) => store.commit("setLogOnState", msg)
|
||||||
|
|
||||||
/** Pass the code to the API and exchange it for a user and a JWT */
|
/** Pass the code to the API and exchange it for a user and a JWT */
|
||||||
const logOn = async () => {
|
const logOn = async () => {
|
||||||
const code = router.currentRoute.value.query.code
|
const instance = await api.instances.byAbbr(abbr)
|
||||||
if (code) {
|
if (typeof instance === "string") {
|
||||||
await store.dispatch("logOn", code)
|
setMessage(instance)
|
||||||
if (store.state.user !== undefined) {
|
} else if (typeof instance === "undefined") {
|
||||||
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
setMessage(`Mastodon instance ${abbr} not found`)
|
||||||
if (afterLogOnUrl) {
|
|
||||||
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
|
||||||
router.push(afterLogOnUrl)
|
|
||||||
} else {
|
|
||||||
router.push("/citizen/dashboard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
store.commit("setLogOnState",
|
const code = route.query.code
|
||||||
"Did not receive a token from No Agenda Social (perhaps you clicked “Cancel”?)")
|
if (code) {
|
||||||
|
await store.dispatch("logOn", { abbr, code })
|
||||||
|
if (store.state.user !== undefined) {
|
||||||
|
const afterLogOnUrl = window.localStorage.getItem(AFTER_LOG_ON_URL)
|
||||||
|
if (afterLogOnUrl) {
|
||||||
|
window.localStorage.removeItem(AFTER_LOG_ON_URL)
|
||||||
|
router.push(afterLogOnUrl)
|
||||||
|
} else {
|
||||||
|
router.push("/citizen/dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMessage(`Did not receive a token from ${instance.name} (perhaps you clicked “Cancel”?)`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,58 @@
|
||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
article
|
article
|
||||||
p
|
p
|
||||||
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second…
|
load-data(:load="retrieveInstances")
|
||||||
|
p.fst-italic(v-if="selected") Sending you over to {{selected.name}} to log on; see you back in just a second…
|
||||||
|
template(v-else)
|
||||||
|
p.text-center Please select your No Agenda-affiliated Mastodon instance
|
||||||
|
p.text-center(v-for="it in instances" :key="it.abbr")
|
||||||
|
button.btn.btn-primary(@click.prevent="select(it.abbr)") {{it.name}}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
import { computed, Ref, ref } from "vue"
|
||||||
* This component simply redirects the user to the No Agenda Social authorization page; it is separate here so that it
|
import api, { Instance } from "@/api"
|
||||||
* can be called from two different places, and allow the app to support direct links to authorized content.
|
|
||||||
*/
|
import LoadData from "@/components/LoadData.vue"
|
||||||
|
|
||||||
|
/** The instances configured for Jobs, Jobs, Jobs */
|
||||||
|
const instances : Ref<Instance[]> = ref([])
|
||||||
|
|
||||||
|
/** Whether authorization is in progress */
|
||||||
|
const selected : Ref<Instance | undefined> = ref(undefined)
|
||||||
|
|
||||||
/** The authorization URL to which the user should be directed */
|
/** The authorization URL to which the user should be directed */
|
||||||
const authUrl = (() => {
|
const authUrl = computed(() => {
|
||||||
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
|
if (selected.value) {
|
||||||
const id = "k_06zlMy0N451meL4AqlwMQzs5PYr6g3d2Q_dCT-OjU"
|
/** The client ID for Jobs, Jobs, Jobs at No Agenda Social */
|
||||||
const client = `client_id=${id}`
|
const client = `client_id=${selected.value.clientId}`
|
||||||
const scope = "scope=read:accounts"
|
const scope = "scope=read:accounts"
|
||||||
const redirect = `redirect_uri=${document.location.origin}/citizen/authorized`
|
const redirect = `redirect_uri=${document.location.origin}/citizen/${selected.value.abbr}/authorized`
|
||||||
const respType = "response_type=code"
|
const respType = "response_type=code"
|
||||||
return `https://noagendasocial.com/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
return `${selected.value.url}/oauth/authorize?${client}&${scope}&${redirect}&${respType}`
|
||||||
})()
|
}
|
||||||
document.location.assign(authUrl)
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a given Mastadon instance
|
||||||
|
*
|
||||||
|
* @param abbr The abbreviation of the instance being selected
|
||||||
|
*/
|
||||||
|
const select = (abbr : string) => {
|
||||||
|
selected.value = instances.value.find(it => it.abbr === abbr)
|
||||||
|
document.location.assign(authUrl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the instances we have configured */
|
||||||
|
const retrieveInstances = async (errors : string[]) => {
|
||||||
|
const instancesResp = await api.instances.all()
|
||||||
|
if (typeof instancesResp === "string") {
|
||||||
|
errors.push(instancesResp)
|
||||||
|
} else if (typeof instancesResp === "undefined") {
|
||||||
|
errors.push("No instances found (this should not happen)")
|
||||||
|
} else {
|
||||||
|
instances.value = instancesResp
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.25.0" />
|
<PackageReference Include="Markdig" Version="0.25.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="5.0.0" />
|
||||||
<PackageReference Include="NodaTime" Version="3.0.5" />
|
<PackageReference Include="NodaTime" Version="3.0.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
module JobsJobsJobs.Domain.SharedTypes
|
module JobsJobsJobs.Domain.SharedTypes
|
||||||
|
|
||||||
open JobsJobsJobs.Domain.Types
|
open JobsJobsJobs.Domain.Types
|
||||||
|
open Microsoft.Extensions.Options
|
||||||
open NodaTime
|
open NodaTime
|
||||||
|
|
||||||
// fsharplint:disable FieldNames
|
// fsharplint:disable FieldNames
|
||||||
|
@ -75,6 +76,45 @@ type Count = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// An instance of a Mastodon server which is configured to work with Jobs, Jobs, Jobs
|
||||||
|
type MastodonInstance () =
|
||||||
|
/// The name of the instance
|
||||||
|
member val Name = "" with get, set
|
||||||
|
/// The URL for this instance
|
||||||
|
member val Url = "" with get, set
|
||||||
|
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||||
|
member val Abbr = "" with get, set
|
||||||
|
/// The client ID (assigned by the Mastodon server)
|
||||||
|
member val ClientId = "" with get, set
|
||||||
|
/// The cryptographic secret (provided by the Mastodon server)
|
||||||
|
member val Secret = "" with get, set
|
||||||
|
|
||||||
|
|
||||||
|
/// The authorization options for Jobs, Jobs, Jobs
|
||||||
|
type AuthOptions () =
|
||||||
|
/// The return URL for Mastodoon verification
|
||||||
|
member val ReturnUrl = "" with get, set
|
||||||
|
/// The secret with which the server signs the JWTs for auth once we've verified with Mastodon
|
||||||
|
member val ServerSecret = "" with get, set
|
||||||
|
/// The instances configured for use
|
||||||
|
member val Instances = Array.empty<MastodonInstance> with get, set
|
||||||
|
interface IOptions<AuthOptions> with
|
||||||
|
override this.Value = this
|
||||||
|
|
||||||
|
|
||||||
|
/// The Mastodon instance data provided via the Jobs, Jobs, Jobs API
|
||||||
|
type Instance = {
|
||||||
|
/// The name of the instance
|
||||||
|
name : string
|
||||||
|
/// The URL for this instance
|
||||||
|
url : string
|
||||||
|
/// The abbreviation used in the URL to distinguish this instance's return codes
|
||||||
|
abbr : string
|
||||||
|
/// The client ID (assigned by the Mastodon server)
|
||||||
|
clientId : string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The fields required for a skill
|
/// The fields required for a skill
|
||||||
type SkillForm = {
|
type SkillForm = {
|
||||||
/// The ID of this skill
|
/// The ID of this skill
|
||||||
|
|
Loading…
Reference in New Issue
Block a user