From a1d1b53ff4fb4ccd45cf03e9eb687ff4b57a8434 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 6 Sep 2021 21:20:51 -0400 Subject: [PATCH] Support multiple Mastodon instances (#26) The application handles multiple instances, and gets that information from configuration, making it much easier to bring in additional NA-affiliated instances in the future Fixes #22 --- src/JobsJobsJobs/Api/App.fs | 6 +- src/JobsJobsJobs/Api/Auth.fs | 50 ++++---- src/JobsJobsJobs/Api/Data.fs | 34 +++-- src/JobsJobsJobs/Api/Handlers.fs | 116 +++++++++++------- src/JobsJobsJobs/Api/appsettings.json | 22 +++- src/JobsJobsJobs/App/package.json | 5 +- src/JobsJobsJobs/App/src/App.vue | 4 +- src/JobsJobsJobs/App/src/api/index.ts | 22 +++- src/JobsJobsJobs/App/src/api/types.ts | 16 ++- src/JobsJobsJobs/App/src/router/index.ts | 6 +- src/JobsJobsJobs/App/src/store/actions.ts | 8 ++ src/JobsJobsJobs/App/src/store/index.ts | 54 ++++---- src/JobsJobsJobs/App/src/store/mutations.ts | 14 +++ src/JobsJobsJobs/App/src/views/HowItWorks.vue | 30 ++--- .../App/src/views/PrivacyPolicy.vue | 24 ++-- .../App/src/views/TermsOfService.vue | 40 ++++-- .../App/src/views/citizen/Authorized.vue | 47 ++++--- .../App/src/views/citizen/Dashboard.vue | 3 +- .../App/src/views/citizen/EditProfile.vue | 4 +- .../App/src/views/citizen/LogOff.vue | 4 +- .../App/src/views/citizen/LogOn.vue | 56 ++++++--- .../App/src/views/listing/ListingView.vue | 4 +- .../App/src/views/so-long/DeletionOptions.vue | 46 ++++--- .../App/src/views/so-long/DeletionSuccess.vue | 20 ++- .../App/src/views/success-story/StoryView.vue | 2 +- src/JobsJobsJobs/Domain/Domain.fsproj | 1 + src/JobsJobsJobs/Domain/Modules.fs | 2 +- src/JobsJobsJobs/Domain/SharedTypes.fs | 40 ++++++ src/JobsJobsJobs/Domain/Types.fs | 16 +-- 29 files changed, 483 insertions(+), 213 deletions(-) create mode 100644 src/JobsJobsJobs/App/src/store/actions.ts create mode 100644 src/JobsJobsJobs/App/src/store/mutations.ts diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs index ccf4b63..af8bd6e 100644 --- a/src/JobsJobsJobs/Api/App.fs +++ b/src/JobsJobsJobs/Api/App.fs @@ -30,6 +30,7 @@ open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging open Microsoft.IdentityModel.Tokens open System.Text +open JobsJobsJobs.Domain.SharedTypes /// Configure dependency injection let configureServices (svc : IServiceCollection) = @@ -57,10 +58,11 @@ let configureServices (svc : IServiceCollection) = ValidAudience = "https://noagendacareers.com", ValidIssuer = "https://noagendacareers.com", IssuerSigningKey = SymmetricSecurityKey ( - Encoding.UTF8.GetBytes (cfg.GetSection("Auth").["ServerSecret"])))) + Encoding.UTF8.GetBytes (cfg.GetSection "Auth").["ServerSecret"]))) |> ignore svc.AddAuthorization () |> ignore - + svc.Configure (cfg.GetSection "Auth") |> ignore + let dbCfg = cfg.GetSection "Rethink" let log = svcs.GetRequiredService().CreateLogger (nameof Data.Startup) let conn = Data.Startup.createConnection dbCfg log diff --git a/src/JobsJobsJobs/Api/Auth.fs b/src/JobsJobsJobs/Api/Auth.fs index 376d3bc..772e041 100644 --- a/src/JobsJobsJobs/Api/Auth.fs +++ b/src/JobsJobsJobs/Api/Auth.fs @@ -3,16 +3,16 @@ module JobsJobsJobs.Api.Auth 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 [] type MastodonAccount () = - /// The user name (what we store as naUser) + /// The user name (what we store as mastodonUser) [] 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 [] 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 [] member val DisplayName = "" with get, set /// The user's profile URL @@ -21,25 +21,29 @@ type MastodonAccount () = open FSharp.Control.Tasks -open Microsoft.Extensions.Configuration open Microsoft.Extensions.Logging open System open System.Net.Http open System.Net.Http.Headers open System.Net.Http.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 -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 = - http.PostAsJsonAsync("https://noagendasocial.com/oauth/token", - {| client_id = cfg.["ClientId"] - client_secret = cfg.["Secret"] - redirect_uri = sprintf "%s/citizen/authorized" cfg.["ReturnHost"] + http.PostAsJsonAsync($"{inst.Url}/oauth/token", + {| client_id = inst.ClientId + client_secret = inst.Secret + redirect_uri = $"{rtnHost}/citizen/{inst.Abbr}/authorized" grant_type = "authorization_code" code = authCode scope = "read" @@ -49,11 +53,10 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : let! responseBytes = codeResult.Content.ReadAsByteArrayAsync () use tokenResponse = JsonSerializer.Deserialize (ReadOnlySpan responseBytes) match tokenResponse with - | null -> - return Error "Could not parse authorization code result" + | null -> return Error "Could not parse authorization code result" | _ -> // 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 ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ()) use! profileResult = http.SendAsync req @@ -62,19 +65,13 @@ let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : | true -> let! profileBytes = profileResult.Content.ReadAsByteArrayAsync () match JsonSerializer.Deserialize(ReadOnlySpan profileBytes) with - | null -> - return Error "Could not parse profile result" - | x when x.Username <> x.AccountName -> - 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})" + | null -> return Error "Could not parse profile result" + | profile -> return Ok profile + | false -> return Error $"Could not get profile ({profileResult.StatusCode:D}: {profileResult.ReasonPhrase})" | false -> let! err = codeResult.Content.ReadAsStringAsync () log.LogError $"Could not get token result from Mastodon:\n {err}" return Error $"Could not get token ({codeResult.StatusCode:D}: {codeResult.ReasonPhrase})" - } @@ -86,7 +83,7 @@ open System.Security.Claims open System.Text /// 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 token = @@ -100,8 +97,7 @@ let createJwt (citizen : Citizen) (cfg : IConfigurationSection) = Issuer = "https://noagendacareers.com", Audience = "https://noagendacareers.com", SigningCredentials = SigningCredentials ( - SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.["ServerSecret"]), - SecurityAlgorithms.HmacSha256Signature) + SymmetricSecurityKey (Encoding.UTF8.GetBytes cfg.ServerSecret), SecurityAlgorithms.HmacSha256Signature) ) ) tokenHandler.WriteToken token diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs index 5d5f2c9..2d32f0b 100644 --- a/src/JobsJobsJobs/Api/Data.fs +++ b/src/JobsJobsJobs/Api/Data.fs @@ -6,6 +6,7 @@ open JobsJobsJobs.Domain.Types open Polly open RethinkDb.Driver open RethinkDb.Driver.Net +open RethinkDb.Driver.Ast /// Shorthand for the RethinkDB R variable (how every command starts) let private r = RethinkDB.R @@ -166,10 +167,20 @@ module Startup = log.LogInformation $"Creating \"{idx}\" index on {table}" r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore) } - do! ensureIndexes Table.Citizen [ "naUser" ] do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] do! ensureIndexes Table.Profile [ "continentId" ] do! ensureIndexes Table.Success [ "citizenId" ] + // The instance/user is a compound index + let! userIdx = r.Table(Table.Citizen).IndexList().RunResultAsync conn + match userIdx |> List.contains "instanceUser" with + | true -> () + | false -> + let! _ = + r.Table(Table.Citizen) + .IndexCreate("instanceUser", + ReqlFunction1 (fun row -> upcast r.Array (row.G "instance", row.G "mastodonUser"))) + .RunWriteAsync conn + () } @@ -215,7 +226,6 @@ let regexContains = System.Text.RegularExpressions.Regex.Escape >> sprintf "(?i) open JobsJobsJobs.Domain open JobsJobsJobs.Domain.SharedTypes -open RethinkDb.Driver.Ast /// Profile data access functions [] @@ -287,7 +297,7 @@ module Profile = .HashMap("displayName", r.Branch (it.G("realName" ).Default_("").Ne "", it.G "realName", it.G("displayName").Default_("").Ne "", it.G "displayName", - it.G "naUser")) + it.G "mastodonUser")) .With ("citizenId", it.G "id"))) .Pluck("citizenId", "displayName", "seekingEmployment", "remoteWork", "fullTime", "lastUpdatedOn") .OrderBy(ReqlFunction1 (fun it -> upcast it.G("displayName").Downcase ())) @@ -348,12 +358,16 @@ module Citizen = .RunResultAsync |> withReconnOption conn - /// Find a citizen by their No Agenda Social username - let findByNaUser (naUser : string) conn = - r.Table(Table.Citizen) - .GetAll(naUser).OptArg("index", "naUser").Nth(0) - .RunResultAsync - |> withReconnOption conn + /// Find a citizen by their Mastodon username + let findByMastodonUser (instance : string) (mastodonUser : string) conn = + fun c -> task { + let! u = + r.Table(Table.Citizen) + .GetAll(r.Array (instance, mastodonUser)).OptArg("index", "instanceUser").Limit(1) + .RunResultAsync c + return u |> List.tryHead + } + |> withReconn conn /// Add a citizen let add (citizen : Citizen) conn = @@ -546,7 +560,7 @@ module Success = .HashMap("citizenName", r.Branch(it.G("realName" ).Default_("").Ne "", it.G "realName", it.G("displayName").Default_("").Ne "", it.G "displayName", - it.G "naUser")) + it.G "mastodonUser")) .With ("hasStory", it.G("story").Default_("").Gt ""))) .Pluck("id", "citizenId", "citizenName", "recordedOn", "fromHere", "hasStory") .OrderBy(r.Desc "recordedOn") diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs index bc24dd4..6e877dd 100644 --- a/src/JobsJobsJobs/Api/Handlers.fs +++ b/src/JobsJobsJobs/Api/Handlers.fs @@ -23,23 +23,23 @@ module Error = /// URL prefixes for the Vue app 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" ] /// Handler that will return a status code 404 and the text "Not Found" let notFound : HttpHandler = fun next ctx -> task { - let fac = ctx.GetService() - let log = fac.CreateLogger("Handler") + let fac = ctx.GetService () + let log = fac.CreateLogger "Handler" + let path = string ctx.Request.Path 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" return! Vue.app next ctx | _ -> log.LogInformation "Returning 404" - return! RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next - ctx + return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx } /// Handler that returns a 403 NOT AUTHORIZED response @@ -58,6 +58,7 @@ module Helpers = open NodaTime open Microsoft.Extensions.Configuration + open Microsoft.Extensions.Options open RethinkDb.Driver.Net open System.Security.Claims @@ -67,6 +68,9 @@ module Helpers = /// Get the application configuration from the request context let config (ctx : HttpContext) = ctx.GetService () + /// Get the authorization configuration from the request context + let authConfig (ctx : HttpContext) = (ctx.GetService> ()).Value + /// Get the logger factory from the request context let logger (ctx : HttpContext) = ctx.GetService () @@ -104,46 +108,50 @@ module Helpers = module Citizen = // GET: /api/citizen/log-on/[code] - let logOn authCode : HttpHandler = + let logOn (abbr, authCode) : HttpHandler = fun next ctx -> task { // Step 1 - Verify with Mastodon - let cfg = (config ctx).GetSection "Auth" - let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) + let cfg = authConfig ctx - match! Auth.verifyWithMastodon authCode cfg log with - | Ok account -> - // Step 2 - Find / establish Jobs, Jobs, Jobs account - 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 - } + match cfg.Instances |> Array.tryFind (fun it -> it.Abbr = abbr) with + | Some instance -> + let log = (logger ctx).CreateLogger (nameof JobsJobsJobs.Api.Auth) - // 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 + match! Auth.verifyWithMastodon authCode instance cfg.ReturnHost log with + | Ok account -> + // Step 2 - Find / establish Jobs, Jobs, Jobs account + let now = (clock ctx).GetCurrentInstant () + let dbConn = conn ctx + let! citizen = task { + match! Data.Citizen.findByMastodonUser instance.Abbr account.Username dbConn with + | None -> + let it : Citizen = + { id = CitizenId.create () + instance = instance.Abbr + mastodonUser = 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] @@ -176,6 +184,25 @@ module Continent = } +/// Handlers for /api/instances routes +[] +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 + } + + /// Handlers for /api/listing[s] routes [] module Listing = @@ -489,12 +516,13 @@ let allEndpoints = [ subRoute "/api" [ subRoute "/citizen" [ GET_HEAD [ - routef "/log-on/%s" Citizen.logOn - routef "/%O" Citizen.get + routef "/log-on/%s/%s" Citizen.logOn + routef "/%O" Citizen.get ] DELETE [ route "" Citizen.delete ] ] GET_HEAD [ route "/continents" Continent.all ] + GET_HEAD [ route "/instances" Instances.all ] subRoute "/listing" [ GET_HEAD [ routef "/%O" Listing.get diff --git a/src/JobsJobsJobs/Api/appsettings.json b/src/JobsJobsJobs/Api/appsettings.json index cbdb783..209fc02 100644 --- a/src/JobsJobsJobs/Api/appsettings.json +++ b/src/JobsJobsJobs/Api/appsettings.json @@ -1,6 +1,22 @@ { - "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" + } + } } } \ No newline at end of file diff --git a/src/JobsJobsJobs/App/package.json b/src/JobsJobsJobs/App/package.json index ff9210a..4901cfd 100644 --- a/src/JobsJobsJobs/App/package.json +++ b/src/JobsJobsJobs/App/package.json @@ -1,12 +1,13 @@ { "name": "jobs-jobs-jobs", - "version": "2.0.0", + "version": "2.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "lint": "vue-cli-service lint", - "apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug" + "apiserve": "vue-cli-service build && cd ../Api && dotnet run -c Debug", + "publish": "vue-cli-service build --modern && cd ../Api && dotnet publish -c Release -r linux-x64 --self-contained false" }, "dependencies": { "@mdi/js": "^5.9.55", diff --git a/src/JobsJobsJobs/App/src/App.vue b/src/JobsJobsJobs/App/src/App.vue index b4cac8a..90b0321 100644 --- a/src/JobsJobsJobs/App/src/App.vue +++ b/src/JobsJobsJobs/App/src/App.vue @@ -40,13 +40,13 @@ export function yesOrNo (cond : boolean) : string { } /** - * Get the display name for a citizen (the first available among real, display, or NAS handle) + * Get the display name for a citizen (the first available among real, display, or Mastodon handle) * * @param cit The citizen * @returns The citizen's display name */ export function citizenName (cit : Citizen) : string { - return cit.realName ?? cit.displayName ?? cit.naUser + return cit.realName ?? cit.displayName ?? cit.mastodonUser } diff --git a/src/JobsJobsJobs/App/src/api/index.ts b/src/JobsJobsJobs/App/src/api/index.ts index 5b2c297..48ba69d 100644 --- a/src/JobsJobsJobs/App/src/api/index.ts +++ b/src/JobsJobsJobs/App/src/api/index.ts @@ -2,6 +2,7 @@ import { Citizen, Continent, Count, + Instance, Listing, ListingExpireForm, ListingForm, @@ -25,7 +26,7 @@ import { * @param url The partial URL for the API * @returns A full URL for the API */ -const apiUrl = (url : string) : string => `http://localhost:5000/api/${url}` +const apiUrl = (url : string) : string => `/api/${url}` /** * Create request init parameters @@ -100,11 +101,12 @@ export default { /** * 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 */ - logOn: async (code : string) : Promise => { - const resp = await fetch(apiUrl(`citizen/log-on/${code}`), { method: "GET", mode: "cors" }) + logOn: async (abbr : string, code : string) : Promise => { + const resp = await fetch(apiUrl(`citizen/log-on/${abbr}/${code}`), { method: "GET", mode: "cors" }) if (resp.status === 200) return await resp.json() as LogOnSuccess return `Error logging on - ${await resp.text()}` }, @@ -141,6 +143,18 @@ export default { apiResult(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 => + apiResult(await fetch(apiUrl("instances"), { method: "GET" }), "retrieving Mastodon instances") + }, + /** API functions for job listings */ listings: { diff --git a/src/JobsJobsJobs/App/src/api/types.ts b/src/JobsJobsJobs/App/src/api/types.ts index b9cbd87..2c98c89 100644 --- a/src/JobsJobsJobs/App/src/api/types.ts +++ b/src/JobsJobsJobs/App/src/api/types.ts @@ -3,8 +3,10 @@ export interface Citizen { /** The ID of the user */ id : string + /** The abbreviation of the instance where this citizen is based */ + instance : string /** The handle by which the user is known on Mastodon */ - naUser : string + mastodonUser : string /** The user's display name from Mastodon (updated every login) */ displayName : string | undefined /** The user's real name */ @@ -31,6 +33,18 @@ export interface Count { 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 */ export interface Listing { /** The ID of the job listing */ diff --git a/src/JobsJobsJobs/App/src/router/index.ts b/src/JobsJobsJobs/App/src/router/index.ts index 77f39c2..1814410 100644 --- a/src/JobsJobsJobs/App/src/router/index.ts +++ b/src/JobsJobsJobs/App/src/router/index.ts @@ -10,7 +10,7 @@ import store from "@/store" import Home from "@/views/Home.vue" import LogOn from "@/views/citizen/LogOn.vue" -/** The URL to which the user should be pointed once they have authorized with NAS */ +/** The URL to which the user should be pointed once they have authorized with Mastodon */ export const AFTER_LOG_ON_URL = "jjj-after-log-on-url" /** @@ -53,7 +53,7 @@ const routes: Array = [ component: LogOn }, { - path: "/citizen/authorized", + path: "/citizen/:abbr/authorized", name: "CitizenAuthorized", component: () => import(/* webpackChunkName: "dashboard" */ "../views/citizen/Authorized.vue") }, @@ -121,7 +121,7 @@ const routes: Array = [ component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionOptions.vue") }, { - path: "/so-long/success", + path: "/so-long/success/:abbr", name: "DeletionSuccess", component: () => import(/* webpackChunkName: "so-long" */ "../views/so-long/DeletionSuccess.vue") }, diff --git a/src/JobsJobsJobs/App/src/store/actions.ts b/src/JobsJobsJobs/App/src/store/actions.ts new file mode 100644 index 0000000..bd5e1f3 --- /dev/null +++ b/src/JobsJobsJobs/App/src/store/actions.ts @@ -0,0 +1,8 @@ +/** Logs a user on to Jobs, Jobs, Jobs */ +export const LogOn = "logOn" + +/** Ensures that the continent list in the state has been populated */ +export const EnsureContinents = "ensureContinents" + +/** Ensures that the Mastodon instance list in the state has been populated */ +export const EnsureInstances = "ensureInstances" diff --git a/src/JobsJobsJobs/App/src/store/index.ts b/src/JobsJobsJobs/App/src/store/index.ts index 7e1cda6..3750b91 100644 --- a/src/JobsJobsJobs/App/src/store/index.ts +++ b/src/JobsJobsJobs/App/src/store/index.ts @@ -1,6 +1,8 @@ import { InjectionKey } from "vue" import { createStore, Store, useStore as baseUseStore } from "vuex" -import api, { Continent, LogOnSuccess } from "../api" +import api, { Continent, Instance, LogOnSuccess } from "../api" +import * as Actions from "./actions" +import * as Mutations from "./mutations" /** The state tracked by the application */ export interface State { @@ -10,6 +12,8 @@ export interface State { logOnState: string /** All continents (use `ensureContinents` action) */ continents: Continent[] + /** All instances (use `ensureInstances` action) */ + instances: Instance[] } /** An injection key to identify this state with Vue */ @@ -24,43 +28,51 @@ export default createStore({ state: () : State => { return { user: undefined, - logOnState: "Welcome back! Verifying your No Agenda Social account…", - continents: [] + logOnState: "Welcome back!", + continents: [], + instances: [] } }, mutations: { - setUser (state, user : LogOnSuccess) { - state.user = user - }, - clearUser (state) { - state.user = undefined - }, - setLogOnState (state, message : string) { - state.logOnState = message - }, - setContinents (state, continents : Continent[]) { - state.continents = continents - } + [Mutations.SetUser]: (state, user : LogOnSuccess) => { state.user = user }, + [Mutations.ClearUser]: (state) => { state.user = undefined }, + [Mutations.SetLogOnState]: (state, message : string) => { state.logOnState = message }, + [Mutations.SetContinents]: (state, continents : Continent[]) => { state.continents = continents }, + [Mutations.SetInstances]: (state, instances : Instance[]) => { state.instances = instances } }, actions: { - async logOn ({ commit }, code: string) { - const logOnResult = await api.citizen.logOn(code) + [Actions.LogOn]: async ({ commit }, { abbr, code }) => { + const logOnResult = await api.citizen.logOn(abbr, code) if (typeof logOnResult === "string") { - commit("setLogOnState", logOnResult) + commit(Mutations.SetLogOnState, logOnResult) } else { - commit("setUser", logOnResult) + commit(Mutations.SetUser, logOnResult) } }, - async ensureContinents ({ state, commit }) { + [Actions.EnsureContinents]: async ({ state, commit }) => { if (state.continents.length > 0) return const theSeven = await api.continent.all() if (typeof theSeven === "string") { console.error(theSeven) } else { - commit("setContinents", theSeven) + commit(Mutations.SetContinents, theSeven) + } + }, + [Actions.EnsureInstances]: async ({ state, commit }) => { + if (state.instances.length > 0) return + const instResp = await api.instances.all() + if (typeof instResp === "string") { + console.error(instResp) + } else if (typeof instResp === "undefined") { + console.error("No instances were found; this should not happen") + } else { + commit(Mutations.SetInstances, instResp) } } }, modules: { } }) + +export * as Actions from "./actions" +export * as Mutations from "./mutations" diff --git a/src/JobsJobsJobs/App/src/store/mutations.ts b/src/JobsJobsJobs/App/src/store/mutations.ts new file mode 100644 index 0000000..fc58676 --- /dev/null +++ b/src/JobsJobsJobs/App/src/store/mutations.ts @@ -0,0 +1,14 @@ +/** Set the logged-on user */ +export const SetUser = "setUser" + +/** Clear the logged-on user */ +export const ClearUser = "clearUser" + +/** Set the status of the current log on action */ +export const SetLogOnState = "setLogOnState" + +/** Set the list of continents */ +export const SetContinents = "setContinents" + +/** Set the list of Mastodon instances */ +export const SetInstances = "setInstances" diff --git a/src/JobsJobsJobs/App/src/views/HowItWorks.vue b/src/JobsJobsJobs/App/src/views/HowItWorks.vue index f134a2f..4d3810f 100644 --- a/src/JobsJobsJobs/App/src/views/HowItWorks.vue +++ b/src/JobsJobsJobs/App/src/views/HowItWorks.vue @@ -21,8 +21,8 @@ article p. Clicking the #[span.link View] link on a listing brings up the full view page for a listing. This page displays all of the information from the search results, along with the citizen who posted it, and the full details of the job. - The citizen’s name is a link to their profile page at No Agenda Social; you can use that to get their handle, - and use NAS’s communication facilites to inquire about the position. + The citizen’s name is a link to their profile page at their Mastodon instance; you can use that to get their + handle, and use Mastodon’s communication facilites to inquire about the position. p: em.text-muted. (If you know of a way to construct a link to Mastodon that would start a direct message, please reach out; I’ve searched and searched, and asked NAS, but have not yet determined how to do that.) @@ -43,9 +43,9 @@ article The #[span.link My Job Listings] page will show you all of your active job listings just below the #[span.button Add a Job Listing] button. Within this table, you can edit the listing, view it, or expire it (more on that below). The #[span.link View] link will show you the job listing just as other users will see it. You can share - the link from your browser over on No Agenda Social, and those who click on it will be able to view it. (Existing - users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s access, but then they - will get there as well.) + the link from your browser on any No Agenda-affiliated Mastodon instance, and those who click on it will be able to + view it. (Existing users of Jobs, Jobs, Jobs will go right to it; others will need to authorize this site’s + access, but then they will get there as well.) h5 Expire a Job Listing p. @@ -68,7 +68,7 @@ article The #[span.link Employment Profiles] link at the side allows you to search for profiles by continent, the citizen’s desire for remote work, a skill, or any text in their professional biography and experience. If you find someone with whom you’d like to discuss potential opportunities, the name at the top of the profile links - to their No Agenda Social account, where you can use its features to get in touch. + to their Mastodon profile, where you can use its features to get in touch. hr @@ -76,8 +76,8 @@ article p. The employment profile is your résumé, visible to other citizens here. It also allows you to specify your real name, if you so desire; if that is filled in, that is how you will be identified in search results, - profile views, etc. If not, you will be identified as you are on No Agenda Social; this system updates your current - display name each time you log on. + profile views, etc. If not, you will be identified as you are on your Mastodon instance; this system updates your + current display name each time you log on. h5 Completing Your Profile p. @@ -99,19 +99,19 @@ article li. If you check the #[span.link Allow my profile to be searched publicly] checkbox #[strong and] you are seeking employment, your continent, region, and skills fields will be searchable and displayed to public users of the - site. They will not be tied to your No Agenda Social handle or real name; they are there to let people peek - behind the curtain a bit, and hopefully inspire them to join us. + site. They will not be tied to your Mastodon handle or real name; they are there to let people peek behind the + curtain a bit, and hopefully inspire them to join us. h5 Viewing and Sharing Your Profile p. Once your profile has been established, the #[span.link My Employment Profile] page will have a button at the bottom that will let you view your profile the way all other validated users will be able to see it. (There will also be a - link to this page from the #[span.link Dashboard].) The URL of this page can be shared on No Agenda Social, if you - would like to share it there. Just as with job listings, existing users will go straight there, while other No - Agenda Social users will get there once they authorize this application. + link to this page from the #[span.link Dashboard].) The URL of this page can be shared on any No Agenda-affiliated + Mastodon instance, if you would like to share it there. Just as with job listings, existing users will go straight + there, while others will get there once they authorize this application. p. - The name on employment profiles is a link to that user’s profile on No Agenda Social; from there, others can - communicate further with you using the tools Mastodon provides. + The name on employment profiles is a link to that user’s profile on their Mastodon instance; from there, + others can communicate further with you using the tools Mastodon provides. h5 “I Found a Job!” p. diff --git a/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue b/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue index f917169..e77ca25 100644 --- a/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue +++ b/src/JobsJobsJobs/App/src/views/PrivacyPolicy.vue @@ -2,7 +2,7 @@ article page-title(title="Privacy Policy") h3 Privacy Policy - p: em (as of February 6#[sup th], 2021) + p: em (as of September 6#[sup th], 2021) p. {{name}} (“we,” “our,” or “us”) is committed to protecting your privacy. This @@ -58,7 +58,7 @@ article li Name / Username li Coarse Geographic Location li Employment History - li No Agenda Social Account Name / Profile + li Mastodon Account Name / Profile h4 How Do We Use The Information We Collect? p Any of the information we collect from you may be used in one of the following ways: @@ -75,9 +75,9 @@ article p {{name}} will collect End User Data necessary to provide the {{name}} services to our customers. p. End users may voluntarily provide us with information they have made available on social media websites - (specifically No Agenda Social). If you provide us with any such information, we may collect publicly available - information from the social media websites you have indicated. You can control how much of your information social - media websites make public by visiting these websites and changing your privacy settings. + (specifically No Agenda-affiliated Mastodon instances). If you provide us with any such information, we may collect + publicly available information from the social media websites you have indicated. You can control how much of your + information social media websites make public by visiting these websites and changing your privacy settings. h4 When does {{name}} use customer information from third parties? p We do not utilize third party information apart from the end-user data described above. @@ -223,10 +223,10 @@ article h4 Tracking Technologies p. - {{name}} does not use any tracking technologies. When an authorization code is received from No Agenda Social, that - token is stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is - refreshed or the browser window/tab is closed, this token disappears, and a new one must be generated before the - application can be used again. + {{name}} does not use any tracking technologies. When an authorization code is received from Mastodon, that token is + stored in the browser’s memory, and the Service uses tokens on each request for data. If the page is refreshed + or the browser window/tab is closed, this token disappears, and a new one must be generated before the application + can be used again. h4 Information about General Data Protection Regulation (GDPR) p. @@ -335,6 +335,12 @@ article h4 Contact Us p Don’t hesitate to contact us if you have any questions. ul: li Via this Link: #[router-link(to="/how-it-works") https://noagendacareers.com/how-it-works] + + hr + + p: em. + Change on September 6#[sup th], 2021 – replaced “No Agenda Social” with generic terms for any + authorized Mastodon instance. diff --git a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue index e355163..448f61e 100644 --- a/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue +++ b/src/JobsJobsJobs/App/src/views/citizen/Authorized.vue @@ -7,30 +7,43 @@ article diff --git a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue b/src/JobsJobsJobs/App/src/views/listing/ListingView.vue index 94ea1c0..335fb6c 100644 --- a/src/JobsJobsJobs/App/src/views/listing/ListingView.vue +++ b/src/JobsJobsJobs/App/src/views/listing/ListingView.vue @@ -65,8 +65,8 @@ const title = computed(() => it.value ? `${it.value.listing.title} | Job Listing /** The HTML details of the job listing */ const details = computed(() => toHtml(it.value?.listing.text ?? "")) -/** The NAS profile URL for the citizen who posted this job listing */ -const profileUrl = computed(() => citizen.value ? `https://noagendasocial.com/@${citizen.value.naUser}` : "") +/** The Mastodon profile URL for the citizen who posted this job listing */ +const profileUrl = computed(() => citizen.value ? citizen.value.profileUrl : "") /** The needed by date, formatted in SHOUTING MODE */ const neededBy = (nb : string) => formatNeededBy(nb).toUpperCase() diff --git a/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue b/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue index 0b59fa7..ab1763a 100644 --- a/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue +++ b/src/JobsJobsJobs/App/src/views/so-long/DeletionOptions.vue @@ -13,28 +13,31 @@ article p. This option will make it like you never visited this site. It will delete your profile, skills, success stories, and account. This is what you want to use if you want to disappear from this application. Clicking the button below - #[strong will not] affect your No Agenda Social account in any way; its effects are limited to Jobs, Jobs, Jobs. + #[strong will not] affect your Mastodon account in any way; its effects are limited to Jobs, Jobs, Jobs. p: em. - (This will not revoke this application’s permissions on No Agenda Social; you will have to remove this - yourself. The confirmation message has a link where you can do this; once the page loads, find the + (This will not revoke this application’s permissions on Mastodon; you will have to remove this yourself. The + confirmation message has a link where you can do this; once the page loads, find the #[strong Jobs, Jobs, Jobs] entry, and click the #[strong × Revoke] link for that entry.) p.text-center: button.btn.btn-danger(@click.prevent="deleteAccount") Delete Your Entire Account - +import { useStore, Actions, Mutations } from "@/store" - diff --git a/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue b/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue index 83d9b38..123df3e 100644 --- a/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue +++ b/src/JobsJobsJobs/App/src/views/so-long/DeletionSuccess.vue @@ -4,8 +4,26 @@ article h3.pb-3 Account Deletion Success p. Your account has been successfully deleted. To revoke the permissions you have previously granted to this - application, find it in #[a(href="https://noagendasocial.com/oauth/authorized_applications") this list] and click + application, find it in #[a(:href="`${url}/oauth/authorized_applications`") this list] and click #[strong × Revoke]. Otherwise, clicking “Log On” in the left-hand menu will create a new, empty account without prompting you further. p Thank you for participating, and thank you for your courage. #GitmoNation + + diff --git a/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue b/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue index d215f84..5ce3119 100644 --- a/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue +++ b/src/JobsJobsJobs/App/src/views/success-story/StoryView.vue @@ -31,7 +31,7 @@ const user = store.state.user as LogOnSuccess /** The story to be displayed */ const story : Ref = ref(undefined) -/** The citizen's name (real, display, or NAS, whichever is found first) */ +/** The citizen's name (real, display, or Mastodon, whichever is found first) */ const citizenName = ref("") /** Retrieve the success story */ diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj index 48c3782..dbac946 100644 --- a/src/JobsJobsJobs/Domain/Domain.fsproj +++ b/src/JobsJobsJobs/Domain/Domain.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs index d65bade..f6f9df5 100644 --- a/src/JobsJobsJobs/Domain/Modules.fs +++ b/src/JobsJobsJobs/Domain/Modules.fs @@ -33,7 +33,7 @@ module CitizenId = module Citizen = /// Get the name of the citizen (the first of real name, display name, or handle that is filled in) let name x = - [ x.realName; x.displayName; Some x.naUser ] + [ x.realName; x.displayName; Some x.mastodonUser ] |> List.find Option.isSome |> Option.get diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs index 30acf2a..dc0dcb3 100644 --- a/src/JobsJobsJobs/Domain/SharedTypes.fs +++ b/src/JobsJobsJobs/Domain/SharedTypes.fs @@ -2,6 +2,7 @@ module JobsJobsJobs.Domain.SharedTypes open JobsJobsJobs.Domain.Types +open Microsoft.Extensions.Options open NodaTime // 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 host for the return URL for Mastodoon verification + member val ReturnHost = "" 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 with get, set + interface IOptions 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 type SkillForm = { /// The ID of this skill diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs index 9659e10..244286e 100644 --- a/src/JobsJobsJobs/Domain/Types.fs +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -13,19 +13,21 @@ type CitizenId = CitizenId of Guid [] type Citizen = { /// The ID of the user - id : CitizenId + id : CitizenId + /// The Mastodon instance abbreviation from which this citizen is authorized + instance : string /// The handle by which the user is known on Mastodon - naUser : string + mastodonUser : string /// The user's display name from Mastodon (updated every login) - displayName : string option + displayName : string option /// The user's real name - realName : string option + realName : string option /// The URL for the user's Mastodon profile - profileUrl : string + profileUrl : string /// When the user joined Jobs, Jobs, Jobs - joinedOn : Instant + joinedOn : Instant /// When the user last logged in - lastSeenOn : Instant + lastSeenOn : Instant }