Multi instance #26

Merged
danieljsummers merged 4 commits from multi-instance into main 2021-09-07 01:20:51 +00:00
12 changed files with 291 additions and 111 deletions
Showing only changes of commit 4e84bc251a - Show all commits

View File

@ -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,9 +58,10 @@ 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)

View File

@ -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

View File

@ -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

View File

@ -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"
}
}
} }
} }

View File

@ -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: {

View File

@ -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 */

View File

@ -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")
}, },

View File

@ -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 {

View File

@ -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 &ldquo;Cancel&rdquo;?)") 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 &ldquo;Cancel&rdquo;?)`)
}
} }
} }

View File

@ -1,24 +1,58 @@
<template lang="pug"> <template lang="pug">
article article
p &nbsp; p &nbsp;
p.fst-italic Sending you over to No Agenda Social to log on; see you back in just a second&hellip; 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&hellip;
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>

View File

@ -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>

View File

@ -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