diff --git a/src/JobsJobsJobs/Api/Api.fsproj b/src/JobsJobsJobs/Api/Api.fsproj
index 8cb0cc1..2070132 100644
--- a/src/JobsJobsJobs/Api/Api.fsproj
+++ b/src/JobsJobsJobs/Api/Api.fsproj
@@ -8,6 +8,8 @@
+
+
@@ -20,6 +22,7 @@
+
diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs
index bbcbc72..a8307d9 100644
--- a/src/JobsJobsJobs/Api/App.fs
+++ b/src/JobsJobsJobs/Api/App.fs
@@ -1,26 +1,30 @@
/// The main API application for Jobs, Jobs, Jobs
module JobsJobsJobs.Api.App
-//open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open Giraffe
+open Giraffe.EndpointRouting
/// All available routes for the application
-let webApp =
- choose [
- route "/ping" >=> text "pong"
- route "/" >=> htmlFile "/pages/index.html"
+let webApp = [
+ subRoute "/api" [
+ subRoute "/citizen" [
+ GET [ routef "/log-on/%s" Handlers.Citizen.logOn ]
+ ]
]
+ ]
/// Configure the ASP.NET Core pipeline to use Giraffe
let configureApp (app : IApplicationBuilder) =
- app.UseGiraffe webApp
+ app
+ .UseRouting()
+ .UseEndpoints(fun e -> e.MapGiraffeEndpoints webApp)
+ |> ignore
open NodaTime
-open RethinkDb.Driver.Net
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
@@ -31,7 +35,7 @@ let configureServices (svc : IServiceCollection) =
svc.AddLogging () |> ignore
let svcs = svc.BuildServiceProvider()
let cfg = svcs.GetRequiredService().GetSection "Rethink"
- let log = svcs.GetRequiredService().CreateLogger "Data.Startup"
+ let log = svcs.GetRequiredService().CreateLogger (nameof Data.Startup)
let conn = Data.Startup.createConnection cfg log
svc.AddSingleton conn |> ignore
Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore
diff --git a/src/JobsJobsJobs/Api/Auth.fs b/src/JobsJobsJobs/Api/Auth.fs
new file mode 100644
index 0000000..376d3bc
--- /dev/null
+++ b/src/JobsJobsJobs/Api/Auth.fs
@@ -0,0 +1,108 @@
+/// Authorization / authentication functions
+module JobsJobsJobs.Api.Auth
+
+open System.Text.Json.Serialization
+
+/// The variables we need from the account information we get from No Agenda Social
+[]
+type MastodonAccount () =
+ /// The user name (what we store as naUser)
+ []
+ member val Username = "" with get, set
+ /// The account name; will be the same as username for local (non-federated) accounts
+ []
+ member val AccountName = "" with get, set
+ /// The user's display name as it currently shows on No Agenda Social
+ []
+ member val DisplayName = "" with get, set
+ /// The user's profile URL
+ []
+ member val Url = "" with get, set
+
+
+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
+
+/// Verify the authorization code with Mastodon and get the user's profile
+let verifyWithMastodon (authCode : string) (cfg : IConfigurationSection) (log : ILogger) = task {
+
+ use http = new HttpClient()
+
+ // Use authorization code to get an access token from NAS
+ 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"]
+ grant_type = "authorization_code"
+ code = authCode
+ scope = "read"
+ |})
+ match codeResult.IsSuccessStatusCode with
+ | true ->
+ let! responseBytes = codeResult.Content.ReadAsByteArrayAsync ()
+ use tokenResponse = JsonSerializer.Deserialize (ReadOnlySpan responseBytes)
+ match tokenResponse with
+ | 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"])
+ req.Headers.Authorization <- AuthenticationHeaderValue
+ ("Bearer", tokenResponse.RootElement.GetProperty("access_token").GetString ())
+ use! profileResult = http.SendAsync req
+
+ match profileResult.IsSuccessStatusCode with
+ | 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})"
+ | 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})"
+
+ }
+
+
+open JobsJobsJobs.Domain
+open JobsJobsJobs.Domain.Types
+open Microsoft.IdentityModel.Tokens
+open System.IdentityModel.Tokens.Jwt
+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 tokenHandler = JwtSecurityTokenHandler ()
+ let token =
+ tokenHandler.CreateToken (
+ SecurityTokenDescriptor (
+ Subject = ClaimsIdentity [|
+ Claim (ClaimTypes.NameIdentifier, CitizenId.toString citizen.id)
+ Claim (ClaimTypes.Name, Citizen.name citizen)
+ |],
+ Expires = DateTime.UtcNow.AddHours 2.,
+ Issuer = "https://noagendacareers.com",
+ Audience = "https://noagendacareers.com",
+ SigningCredentials = SigningCredentials (
+ 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 6075914..de369a3 100644
--- a/src/JobsJobsJobs/Api/Data.fs
+++ b/src/JobsJobsJobs/Api/Data.fs
@@ -220,6 +220,17 @@ module Citizen =
()
}
+ /// Update the display name and last seen on date for a citizen
+ let logOnUpdate (citizen : Citizen) conn = task {
+ let! _ =
+ withReconn(conn).ExecuteAsync(fun () ->
+ r.Table(Table.Citizen)
+ .Get(citizen.id)
+ .Update(r.HashMap(nameof citizen.displayName, citizen.displayName)
+ .With(nameof citizen.lastSeenOn, citizen.lastSeenOn))
+ .RunWriteAsync conn)
+ ()
+ }
/// Profile data access functions
[]
diff --git a/src/JobsJobsJobs/Api/Handlers.fs b/src/JobsJobsJobs/Api/Handlers.fs
new file mode 100644
index 0000000..bc04b93
--- /dev/null
+++ b/src/JobsJobsJobs/Api/Handlers.fs
@@ -0,0 +1,101 @@
+/// Route handlers for Giraffe endpoints
+module JobsJobsJobs.Api.Handlers
+
+open FSharp.Control.Tasks
+open Giraffe
+open JobsJobsJobs.Domain
+open JobsJobsJobs.Domain.SharedTypes
+open JobsJobsJobs.Domain.Types
+
+/// Helper functions
+[]
+module Helpers =
+
+ open NodaTime
+ open Microsoft.AspNetCore.Http
+ open Microsoft.Extensions.Configuration
+ open Microsoft.Extensions.Logging
+ open RethinkDb.Driver.Net
+
+ /// Get the NodaTime clock from the request context
+ let clock (ctx : HttpContext) = ctx.GetService ()
+
+ /// Get the application configuration from the request context
+ let config (ctx : HttpContext) = ctx.GetService ()
+
+ /// Get the logger factory from the request context
+ let logger (ctx : HttpContext) = ctx.GetService ()
+
+ /// Get the RethinkDB connection from the request context
+ let conn (ctx : HttpContext) = ctx.GetService ()
+
+ /// Return None if the string is null, empty, or whitespace; otherwise, return Some and the trimmed string
+ let noneIfEmpty x =
+ match (defaultArg (Option.ofObj x) "").Trim () with | "" -> None | it -> Some it
+
+
+/// Handlers for error conditions
+module Error =
+
+ /// Handler that will return a status code 404 and the text "Not Found"
+ let notFound : HttpHandler =
+ fun next ctx ->
+ RequestErrors.NOT_FOUND $"The URL {string ctx.Request.Path} was not recognized as a valid URL" next ctx
+
+
+/// Handler to return the files required for the Vue client app
+module Vue =
+
+ /// Handler that returns index.html (the Vue client app)
+ let app : HttpHandler =
+ fun next ctx ->
+ match [ "GET"; "HEAD" ] |> List.contains ctx.Request.Method with
+ | true -> htmlFile "wwwroot/index.html" next ctx
+ | false -> Error.notFound next ctx
+
+
+/// Handler for /api/citizen routes
+module Citizen =
+
+ // GET: /api/citizen/log-on/[code]
+ let logOn 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)
+
+ 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
+ }
+
+ // 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
+ }
diff --git a/src/JobsJobsJobs/DataMigrate/Program.fs b/src/JobsJobsJobs/DataMigrate/Program.fs
index a11ea96..00e9273 100644
--- a/src/JobsJobsJobs/DataMigrate/Program.fs
+++ b/src/JobsJobsJobs/DataMigrate/Program.fs
@@ -33,17 +33,21 @@ let createHostBuilder argv =
Startup.establishEnvironment cfg log conn |> awaitIgnore
))
.Build()
-
+
[]
let main argv =
let host = createHostBuilder argv
let r = RethinkDb.Driver.RethinkDB.R
+ printfn "0) Connecting to databases..."
+
use db = host.Services.GetRequiredService ()
let conn = host.Services.GetRequiredService ()
task {
- // Migrate continents
+
+ printfn "1) Migrating continents..."
+
let mutable continentXref = Map.empty
let! continents = db.Continents.AsNoTracking().ToListAsync ()
let reContinents =
@@ -59,7 +63,8 @@ let main argv =
|> List.ofSeq
let! _ = r.Table(Table.Continent).Insert(reContinents).RunWriteAsync conn
- // Migrate citizens
+ printfn "2) Migrating citizens..."
+
let mutable citizenXref = Map.empty
let! citizens = db.Citizens.AsNoTracking().ToListAsync ()
let reCitizens =
@@ -79,7 +84,8 @@ let main argv =
it)
let! _ = r.Table(Table.Citizen).Insert(reCitizens).RunWriteAsync conn
- // Migrate profile information (includes skills)
+ printfn "3) Migrating profiles and skills..."
+
let! profiles = db.Profiles.AsNoTracking().ToListAsync ()
let reProfiles =
profiles
@@ -103,15 +109,16 @@ let main argv =
region = p.Region
remoteWork = p.RemoteWork
fullTime = p.FullTime
- biography = Types.Text (string p.Biography)
+ biography = Types.Text p.Biography.Text
lastUpdatedOn = p.LastUpdatedOn
- experience = match p.Experience with null -> None | x -> (string >> Types.Text >> Some) x
+ experience = match p.Experience with null -> None | x -> (Types.Text >> Some) x.Text
skills = reSkills
}
it)
let! _ = r.Table(Table.Profile).Insert(reProfiles).RunWriteAsync conn
- // Migrate success stories
+ printfn "4) Migrating success stories..."
+
let! successes = db.Successes.AsNoTracking().ToListAsync ()
let reSuccesses =
successes
@@ -122,7 +129,7 @@ let main argv =
recordedOn = s.RecordedOn
fromHere = s.FromHere
source = "profile"
- story = match s.Story with null -> None | x -> (string >> Types.Text >> Some) x
+ story = match s.Story with null -> None | x -> (Types.Text >> Some) x.Text
}
it)
let! _ = r.Table(Table.Success).Insert(reSuccesses).RunWriteAsync conn
@@ -130,4 +137,6 @@ let main argv =
}
|> awaitIgnore
- 0
\ No newline at end of file
+ printfn "Migration complete"
+
+ 0
diff --git a/src/JobsJobsJobs/Domain/.gitignore b/src/JobsJobsJobs/Domain/.gitignore
new file mode 100644
index 0000000..4c43fe6
--- /dev/null
+++ b/src/JobsJobsJobs/Domain/.gitignore
@@ -0,0 +1 @@
+*.js
\ No newline at end of file
diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj
index ad1f7fe..48c3782 100644
--- a/src/JobsJobsJobs/Domain/Domain.fsproj
+++ b/src/JobsJobsJobs/Domain/Domain.fsproj
@@ -9,6 +9,7 @@
+
diff --git a/src/JobsJobsJobs/Domain/SharedTypes.fs b/src/JobsJobsJobs/Domain/SharedTypes.fs
new file mode 100644
index 0000000..689aa45
--- /dev/null
+++ b/src/JobsJobsJobs/Domain/SharedTypes.fs
@@ -0,0 +1,14 @@
+/// Types intended to be shared between the API and the client application
+module JobsJobsJobs.Domain.SharedTypes
+
+// fsharplint:disable FieldNames
+
+/// A successful logon
+type LogOnSuccess = {
+ /// The JSON Web Token (JWT) to use for API access
+ jwt : string
+ /// The ID of the logged-in citizen (as a string)
+ citizenId : string
+ /// The name of the logged-in citizen
+ name : string
+ }
diff --git a/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs
index e63ef93..9921291 100644
--- a/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs
+++ b/src/JobsJobsJobs/Server/Areas/Api/Controllers/SuccessController.cs
@@ -53,7 +53,7 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
if (form.Id == "new")
{
var story = new Success(await SuccessId.Create(), CurrentCitizenId, _clock.GetCurrentInstant(),
- form.FromHere, "profile",
+ form.FromHere, // "profile",
string.IsNullOrWhiteSpace(form.Story) ? null : new MarkdownString(form.Story));
await _db.AddAsync(story);
}
diff --git a/src/JobsJobsJobs/Server/Data/JobsDbContext.cs b/src/JobsJobsJobs/Server/Data/JobsDbContext.cs
index 7a4e490..3d96e0b 100644
--- a/src/JobsJobsJobs/Server/Data/JobsDbContext.cs
+++ b/src/JobsJobsJobs/Server/Data/JobsDbContext.cs
@@ -143,7 +143,7 @@ namespace JobsJobsJobs.Server.Data
.HasConversion(Converters.CitizenIdConverter);
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
- m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
+ // m.Property(e => e.Source).HasColumnName("source").IsRequired().HasMaxLength(7);
m.Property(e => e.Story).HasColumnName("story")
.HasConversion(Converters.OptionalMarkdownStringConverter);
});
diff --git a/src/JobsJobsJobs/Shared/Domain/Success.cs b/src/JobsJobsJobs/Shared/Domain/Success.cs
index e049b10..e8eed9c 100644
--- a/src/JobsJobsJobs/Shared/Domain/Success.cs
+++ b/src/JobsJobsJobs/Shared/Domain/Success.cs
@@ -10,6 +10,6 @@ namespace JobsJobsJobs.Shared
CitizenId CitizenId,
Instant RecordedOn,
bool FromHere,
- string Source,
+ // string Source,
MarkdownString? Story);
}