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
This commit was merged in pull request #26.
This commit is contained in:
@@ -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<AuthOptions> (cfg.GetSection "Auth") |> ignore
|
||||
|
||||
let dbCfg = cfg.GetSection "Rethink"
|
||||
let log = svcs.GetRequiredService<ILoggerFactory>().CreateLogger (nameof Data.Startup)
|
||||
let conn = Data.Startup.createConnection dbCfg log
|
||||
|
||||
@@ -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
|
||||
[<NoComparison; NoEquality; AllowNullLiteral>]
|
||||
type MastodonAccount () =
|
||||
/// The user name (what we store as naUser)
|
||||
/// The user name (what we store as mastodonUser)
|
||||
[<JsonPropertyName "username">]
|
||||
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">]
|
||||
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">]
|
||||
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<JsonDocument> (ReadOnlySpan<byte> 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<MastodonAccount>(ReadOnlySpan<byte> 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
|
||||
|
||||
@@ -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<string list> 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
|
||||
[<RequireQualifiedAccess>]
|
||||
@@ -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<Citizen>
|
||||
|> 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<Citizen>
|
||||
|> 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<Citizen list> 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")
|
||||
|
||||
@@ -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<ILoggerFactory>()
|
||||
let log = fac.CreateLogger("Handler")
|
||||
let fac = ctx.GetService<ILoggerFactory> ()
|
||||
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<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
|
||||
let logger (ctx : HttpContext) = ctx.GetService<ILoggerFactory> ()
|
||||
|
||||
@@ -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
|
||||
[<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
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /api/listing[s] routes
|
||||
[<RequireQualifiedAccess>]
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user