WIP on server credential exchange

part of #1
This commit is contained in:
Daniel J. Summers 2020-09-10 22:23:17 -04:00
parent 10658b0d77
commit 2b7dd09630
9 changed files with 726 additions and 7 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
.ionide .ionide
.fake

View File

@ -0,0 +1,98 @@
module JobsJobsJobs.Api.Data
open JobsJobsJobs.Api.Domain
open Npgsql.FSharp
open System
/// The connection URI for the database
let connectUri = Uri config.dbUri
/// Connect to the database
let db () =
(Sql.fromUri >> Sql.connect) connectUri
/// Return None if the error is that a single row was expected, but no rows matched
let private noneIfNotFound (it : Async<Result<'T, exn>>) = async {
match! it with
| Ok x ->
return (Some >> Ok) x
| Error err ->
return match err.Message with msg when msg.Contains "at least one" -> Ok None | _ -> Error err
}
/// Get the item count from a single-row result
let private itemCount (read: RowReader) = read.int64 "item_count"
/// Functions for manipulating citizens
// (SHUT UP, SLAVE!)
module Citizens =
/// Create a Citizen from a row of data
let private fromReader (read: RowReader) =
match (read.string >> CitizenId.tryParse) "id" with
| Ok citizenId -> {
id = citizenId
naUser = read.string "na_user"
displayName = read.string "display_name"
profileUrl = read.string "profile_url"
joinedOn = (read.int64 >> Millis) "joined_on"
lastSeenOn = (read.int64 >> Millis) "last_seen_on"
}
| Error err -> failwith err
/// Determine if we already know about this user from No Agenda Social
let findIdByNaUser naUser =
db ()
|> Sql.query "SELECT id FROM citizen WHERE na_user = @na_user"
|> Sql.parameters [ "@na_user", Sql.string naUser ]
|> Sql.executeRowAsync (fun read ->
match (read.string >> CitizenId.tryParse) "id" with
| Ok citizenId -> citizenId
| Error err -> failwith err)
|> noneIfNotFound
/// Add a citizen
let add citizen =
db ()
|> Sql.query
"""INSERT INTO citizen (
na_user, display_name, profile_url, joined_on, last_seen_on, id
) VALUES (
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
)"""
|> Sql.parameters [
"@na_user", Sql.string citizen.naUser
"@display_name", Sql.string citizen.displayName
"@profile_url", Sql.string citizen.profileUrl
"@joined_on", (Millis.toLong >> Sql.int64) citizen.joinedOn
"@last_seen_on", (Millis.toLong >> Sql.int64) citizen.lastSeenOn
"@id", (CitizenId.toString >> Sql.string) citizen.id
]
|> Sql.executeNonQueryAsync
/// Update a citizen record when they log on
let update citizenId displayName =
db ()
|> Sql.query
"""UPDATE citizen
SET display_name = @display_name,
last_seen_on = @last_seen_on
WHERE id = @id"""
|> Sql.parameters [
"@display_name", Sql.string displayName
"@last_seen_on", (DateTime.Now.toMillis >> Millis.toLong >> Sql.int64) ()
"@id", (CitizenId.toString >> Sql.string) citizenId
]
|> Sql.executeNonQueryAsync
/// Try to find a citizen with the given ID
let tryFind citizenId =
db ()
|> Sql.query "SELECT * FROM citizen WHERE id = @id"
|> Sql.parameters [ "@id", (CitizenId.toString >> Sql.string) citizenId ]
|> Sql.executeRowAsync fromReader
|> noneIfNotFound

View File

@ -0,0 +1,272 @@
module JobsJobsJobs.Api.Domain
// fsharplint:disable RecordFieldNames MemberNames
/// A short ID (12 characters of a Nano ID)
type ShortId =
| ShortId of string
/// Functions to maniuplate short IDs
module ShortId =
open Nanoid
open System.Text.RegularExpressions
/// Regular expression to validate a string's format as a short ID
let validShortId = Regex ("^[a-z0-9_-]{12}", RegexOptions.Compiled ||| RegexOptions.IgnoreCase)
/// Convert a short ID to its string representation
let toString = function ShortId text -> text
/// Create a new short ID
let create () = async {
let! text = Nanoid.GenerateAsync (size = 12)
return ShortId text
}
/// Try to parse a string into a short ID
let tryParse (text : string) =
match text.Length with
| 12 when validShortId.IsMatch text -> (ShortId >> Ok) text
| 12 -> Error "ShortId must be 12 characters [a-z,0-9,-, or _]"
| x -> Error (sprintf "ShortId must be 12 characters; %d provided" x)
/// The ID for a citizen (user) record
type CitizenId =
| CitizenId of ShortId
/// Functions for manipulating citizen (user) IDs
module CitizenId =
/// Convert a citizen ID to its string representation
let toString = function CitizenId shortId -> ShortId.toString shortId
/// Create a new citizen ID
let create () = async {
let! shortId = ShortId.create ()
return CitizenId shortId
}
/// Try to parse a string into a CitizenId
let tryParse text =
match ShortId.tryParse text with
| Ok shortId -> (CitizenId >> Ok) shortId
| Error err -> Error err
/// The ID for a continent record
type ContinentId =
| ContinentId of ShortId
/// Functions for manipulating continent IDs
module ContinentId =
/// Convert a continent ID to its string representation
let toString = function ContinentId shortId -> ShortId.toString shortId
/// Create a new continent ID
let create () = async {
let! shortId = ShortId.create ()
return ContinentId shortId
}
/// Try to parse a string into a ContinentId
let tryParse text =
match ShortId.tryParse text with
| Ok shortId -> (ContinentId >> Ok) shortId
| Error err -> Error err
/// The ID for a skill record
type SkillId =
| SkillId of ShortId
/// Functions for manipulating skill IDs
module SkillId =
/// Convert a skill ID to its string representation
let toString = function SkillId shortId -> ShortId.toString shortId
/// Create a new skill ID
let create () = async {
let! shortId = ShortId.create ()
return SkillId shortId
}
/// Try to parse a string into a CitizenId
let tryParse text =
match ShortId.tryParse text with
| Ok shortId -> (SkillId >> Ok) shortId
| Error err -> Error err
/// The ID for a success report record
type SuccessId =
| SuccessId of ShortId
/// Functions for manipulating success report IDs
module SuccessId =
/// Convert a success report ID to its string representation
let toString = function SuccessId shortId -> ShortId.toString shortId
/// Create a new success report ID
let create () = async {
let! shortId = ShortId.create ()
return SuccessId shortId
}
/// Try to parse a string into a SuccessId
let tryParse text =
match ShortId.tryParse text with
| Ok shortId -> (SuccessId >> Ok) shortId
| Error err -> Error err
/// A number representing milliseconds since the epoch (AKA JavaScript time)
type Millis =
| Millis of int64
/// Functions to manipulate ticks
module Millis =
/// Convert a Ticks instance to its primitive value
let toLong = function Millis millis -> millis
/// A string that holds Markdown-formatted text
type MarkdownString =
| MarkdownString of string
/// Functions to manipulate Markdown-formatted text
module MarkdownString =
open Markdig
/// Markdown pipeline that supports all built-in Markdown extensions
let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build ()
/// Get the plain-text (non-rendered) representation of the text
let toText = function MarkdownString str -> str
/// Get the HTML (rendered) representation of the text
let toHtml = function MarkdownString str -> Markdown.ToHtml (str, pipeline)
/// A user
type Citizen = {
/// The ID of the user
id : CitizenId
/// The user's handle on No Agenda Social
naUser : string
/// The user's display name from No Agenda Social (as of their last login here)
displayName : string
/// The URL to the user's profile on No Agenda Social
profileUrl : string
/// When the user signed up here
joinedOn : Millis
/// When the user last logged on here
lastSeenOn : Millis
}
/// A continent
type Continent = {
/// The ID of the continent
id : ContinentId
/// The name of the continent
name : string
}
/// An employment / skills profile
type Profile = {
/// The ID of the user to whom the profile applies
citizenId : CitizenId
/// Whether this user is actively seeking employment
seekingEmployment : bool
/// Whether information from this profile should appear in the public anonymous list of available skills
isPublic : bool
/// The continent on which the user is seeking employment
continentId : Continent
/// The region within that continent where the user would prefer to work
region : string
/// Whether the user is looking for remote work
remoteWork : bool
/// Whether the user is looking for full-time work
fullTime : bool
/// The user's professional biography
biography : MarkdownString
/// When this profile was last updated
lastUpdatedOn : Millis
/// The user's experience
experience : MarkdownString option
}
/// A skill which a user possesses
type Skill = {
/// The ID of the skill
id : SkillId
/// The ID of the user who possesses this skill
citizenId : CitizenId
/// The skill
skill : string
/// Notes about the skill (proficiency, experience, etc.)
notes : string option
}
/// A success story
type Success = {
/// The ID of the success story
id : SuccessId
/// The ID of the user who experienced this success story
citizenId : CitizenId
/// When this story was recorded
recordedOn : Millis
/// Whether the success came from here; if Jobs, Jobs, Jobs led them to eventual employment
fromHere : bool
/// Their success story
story : MarkdownString option
}
/// Configuration required for authentication with No Agenda Social
type AuthConfig = {
/// The client ID
clientId : string
/// The cryptographic secret
secret : string
/// The base URL for Mastodon's API access
apiUrl : string
}
/// Application configuration format
type JobsJobsJobsConfig = {
/// Auth0 configuration
auth : AuthConfig
/// Database connection URI
dbUri : string
}
open Microsoft.Extensions.Configuration
open System.IO
/// Configuration instance
let config =
(lazy
(let root =
ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory ())
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json", true)
.AddJsonFile("appsettings.Production.json", true)
.AddEnvironmentVariables("JJJ_")
.Build()
let auth = root.GetSection "Auth"
{ dbUri = root.["dbUri"]
auth = {
clientId = auth.["ClientId"]
secret = auth.["Secret"]
apiUrl = auth.["ApiUrl"]
}
})).Force()

View File

@ -0,0 +1,31 @@
[<AutoOpen>]
module JobsJobsJobs.Api.Extensions
open System
// fsharplint:disable MemberNames
/// Extensions for the DateTime object
type DateTime with
/// Constant for the ticks at the Unix epoch
member __.UnixEpochTicks = (DateTime (1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Ticks
/// Convert this DateTime to a JavaScript milliseconds-past-the-epoch value
member this.toMillis () =
(this.ToUniversalTime().Ticks - this.UnixEpochTicks) / 10000L |> Domain.Millis
open FSharp.Json
open Suave
open System.Text
/// Extensions for Suave's context
type HttpContext with
/// Deserialize an object from a JSON request body
member this.fromJsonBody<'T> () =
try
Encoding.UTF8.GetString this.request.rawForm |> Json.deserialize<'T> |> Ok
with x ->
Error x

View File

@ -0,0 +1,147 @@
module JobsJobsJobs.Api.Handlers
open Data
open Domain
open FSharp.Json
open Suave
open Suave.Operators
open System
[<AutoOpen>]
module private Internal =
open Suave.Writers
/// Send a JSON response
let json x =
Successful.OK (Json.serialize x)
>=> setMimeType "application/json; charset=utf-8"
module Auth =
open System.Net.Http
open System.Net.Http.Headers
/// Verify a user's credentials with No Agenda Social
let verifyWithMastodon accessToken = async {
use client = new HttpClient ()
use req = new HttpRequestMessage (HttpMethod.Get, (sprintf "%saccounts/verify_credentials" config.auth.apiUrl))
req.Headers.Authorization <- AuthenticationHeaderValue <| sprintf "Bearer %s" accessToken
match! client.SendAsync req |> Async.AwaitTask with
| res when res.IsSuccessStatusCode ->
let! body = res.Content.ReadAsStringAsync ()
return
match Json.deserialize<ViewModels.Citizen.MastodonAccount> body with
| profile when profile.username = profile.acct -> Ok profile
| profile -> Error (sprintf "Profiles must be from noagendasocial.com; yours is %s" profile.acct)
| res -> return Error (sprintf "Could not retrieve credentials: %d ~ %s" (int res.StatusCode) res.ReasonPhrase)
}
/// Handler to return the Vue application
module Vue =
/// The application index page
let app = Files.file "wwwroot/index.html"
/// Handlers for error conditions
module Error =
open Suave.Logging
open Suave.Logging.Message
/// Handle errors
let error (ex : Exception) msg =
fun ctx ->
seq {
string ctx.request.url
match msg with "" -> () | _ -> " ~ "; msg
"\n"; (ex.GetType().Name); ": "; ex.Message; "\n"
ex.StackTrace
}
|> Seq.reduce (+)
|> (eventX >> ctx.runtime.logger.error)
ServerErrors.INTERNAL_ERROR (Json.serialize {| error = ex.Message |}) ctx
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : WebPart =
fun ctx ->
[ "/user"; "/jobs" ]
|> List.filter ctx.request.path.StartsWith
|> List.length
|> function
| 0 -> RequestErrors.NOT_FOUND "err" ctx
| _ -> Vue.app ctx
/// /api/citizen route handlers
module Citizen =
open ViewModels.Citizen
/// Either add the user, or update their display name and last seen date
let establishCitizen result profile = async {
match result with
| Some citId ->
match! Citizens.update citId profile.displayName with
| Ok _ -> return Ok citId
| Error exn -> return Error exn
| None ->
let now = DateTime.Now.toMillis ()
let! citId = CitizenId.create ()
match! Citizens.add
{ id = citId
naUser = profile.username
displayName = profile.displayName
profileUrl = profile.url
joinedOn = now
lastSeenOn = now
} with
| Ok _ -> return Ok citId
| Error exn -> return Error exn
}
/// POST: /api/citizen/log-on
let logOn : WebPart =
fun ctx -> async {
match ctx.fromJsonBody<LogOn> () with
| Ok data ->
match! Auth.verifyWithMastodon data.accessToken with
| Ok profile ->
match! Citizens.findIdByNaUser profile.username with
| Ok idResult ->
match! establishCitizen idResult profile with
| Ok citizenId ->
// TODO: replace this with a JWT issued by the server user
match! Citizens.tryFind citizenId with
| Ok (Some citizen) -> return! json citizen ctx
| Ok None -> return! Error.error (exn ()) "Citizen record not found" ctx
| Error exn -> return! Error.error exn "Could not retrieve user from database" ctx
| Error exn -> return! Error.error exn "Could not update Jobs, Jobs, Jobs database" ctx
| Error exn -> return! Error.error exn "Token not received" ctx
| Error msg ->
// Error message regarding exclusivity to No Agenda Social members
return Some ctx
| Error exn -> return! Error.error exn "Token not received" ctx
}
open Suave.Filters
/// The routes for Jobs, Jobs, Jobs
let webApp =
choose
[ GET >=> choose
[ path "/" >=> Vue.app
Files.browse "wwwroot/"
]
// PUT >=> choose
// [ ]
// PATCH >=> choose
// [ ]
POST >=> choose
[ path "/api/citizen/log-on" >=> Citizen.logOn
]
Error.notFound
]

View File

@ -6,11 +6,22 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Extensions.fs" />
<Compile Include="Data.fs" />
<Compile Include="ViewModels.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Program.fs" /> <Compile Include="Program.fs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FSharp.Json" Version="0.4.0" /> <PackageReference Include="FSharp.Json" Version="0.4.0" />
<PackageReference Include="Markdig" Version="0.21.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.1.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.8" />
<PackageReference Include="Nanoid" Version="2.1.0" />
<PackageReference Include="Npgsql.FSharp" Version="3.7.0" /> <PackageReference Include="Npgsql.FSharp" Version="3.7.0" />
<PackageReference Include="Suave" Version="2.5.6" /> <PackageReference Include="Suave" Version="2.5.6" />
</ItemGroup> </ItemGroup>

View File

@ -1,8 +1,18 @@
// Learn more about F# at http://fsharp.org module JobsJobsJobs.Api.App
// Learn more about F# at http://fsharp.org
open System open System
open Suave
[<EntryPoint>] [<EntryPoint>]
let main argv = let main argv =
printfn "Hello World from F#!" { defaultConfig with
0 // return an integer exit code bindings = [ HttpBinding.createSimple HTTP "127.0.0.1" 3002; HttpBinding.createSimple HTTP "::1" 3002 ]
// errorHandler = Handlers.Error.error
// serverKey = config.serverKey
// cookieSerialiser = FSharpJsonCookieSerialiser()
// homeFolder = Some "./wwwroot/"
}
|> (flip startWebServer) Handlers.webApp
0

View File

@ -0,0 +1,27 @@
module JobsJobsJobs.Api.ViewModels
// fsharplint:disable RecordFieldNames MemberNames
/// View models uses for /api/citizen routes
module Citizen =
open FSharp.Json
/// The payload for the log on route
type LogOn = {
/// The access token obtained from No Agenda Social
accessToken : string
}
/// The variables we need from the account information we get from No Agenda Social
type MastodonAccount = {
/// The user name (what we store as naUser)
username : string
/// The account name; will be the same as username for local (non-federated) accounts
acct : string
/// The user's display name as it currently shows on No Agenda Social
[<JsonField "display_name">]
displayName : string
/// The user's profile URL
url : string
}

122
src/database/tables.sql Normal file
View File

@ -0,0 +1,122 @@
CREATE SCHEMA jjj;
COMMENT ON SCHEMA jjj IS 'Jobs, Jobs, Jobs';
CREATE TABLE jjj.citizen (
id VARCHAR(12) NOT NULL,
na_user VARCHAR(50) NOT NULL,
display_name VARCHAR(255) NOT NULL,
profile_url VARCHAR(1024) NOT NULL,
joined_on BIGINT NOT NULL,
last_seen_on BIGINT NOT NULL,
CONSTRAINT pk_citizen PRIMARY KEY (id),
CONSTRAINT uk_na_user UNIQUE (na_user)
);
COMMENT ON TABLE jjj.citizen IS 'Users';
COMMENT ON COLUMN jjj.citizen.id
IS 'A unique identifier for a user';
COMMENT ON COLUMN jjj.citizen.na_user
IS 'The ID of this user from No Agenda Social';
COMMENT ON COLUMN jjj.citizen.display_name
IS 'The display name of the user as it appeared on their profile the last time they logged on';
COMMENT ON COLUMN jjj.citizen.profile_url
IS 'The URL for the No Agenda Social profile for this user';
COMMENT ON COLUMN jjj.citizen.joined_on
IS 'When this user joined Jobs, Jobs, Jobs';
COMMENT ON COLUMN jjj.citizen.last_seen_on
IS 'When this user last logged on to Jobs, Jobs, Jobs';
CREATE TABLE jjj.continent (
id VARCHAR(12) NOT NULL,
name VARCHAR(255) NOT NULL,
CONSTRAINT pk_continent PRIMARY KEY (id)
);
COMMENT ON TABLE jjj.continent IS 'Continents';
COMMENT ON COLUMN jjj.continent.id
IS 'A unique identifier for the continent';
COMMENT ON COLUMN jjj.continent.name
IS 'The name of the continent';
CREATE TABLE jjj.profile (
citizen_id VARCHAR(12) NOT NULL,
seeking_employment BOOLEAN NOT NULL,
is_public BOOLEAN NOT NULL,
continent_id VARCHAR(12) NOT NULL,
region VARCHAR(255) NOT NULL,
remote_work BOOLEAN NOT NULL,
full_time BOOLEAN NOT NULL,
biography TEXT NOT NULL,
last_updated_on BIGINT NOT NULL,
experience TEXT,
CONSTRAINT pk_profile PRIMARY KEY (citizen_id),
CONSTRAINT fk_profile_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id),
CONSTRAINT fk_profile_continent FOREIGN KEY (continent_id) REFERENCES jjj.continent (id)
);
COMMENT ON TABLE jjj.profile IS 'Employment Profiles';
COMMENT ON COLUMN jjj.profile.citizen_id
IS 'The ID of the user to whom this profile belongs';
COMMENT ON COLUMN jjj.profile.seeking_employment
IS 'Whether this user is actively seeking employment';
COMMENT ON COLUMN jjj.profile.is_public
IS 'Whether this profile should appear on the anonymized public job seeker list';
COMMENT ON COLUMN jjj.profile.continent_id
IS 'The ID of the continent on which this user is located';
COMMENT ON COLUMN jjj.profile.region
IS 'The region within the continent where this user is located';
COMMENT ON COLUMN jjj.profile.remote_work
IS 'Whether this user is open to remote work opportunities';
COMMENT ON COLUMN jjj.profile.full_time
IS 'Whether this user is looking for full time work';
COMMENT ON COLUMN jjj.profile.biography
IS 'The professional biography for this user (Markdown)';
COMMENT ON COLUMN jjj.profile.last_updated_on
IS 'When this profile was last updated';
COMMENT ON COLUMN jjj.profile.experience
IS 'The prior employment experience for this user (Markdown)';
CREATE INDEX idx_profile_continent ON jjj.profile (continent_id);
COMMENT ON INDEX jjj.idx_profile_continent IS 'FK Index';
CREATE TABLE jjj.skill (
id VARCHAR(12) NOT NULL,
citizen_id VARCHAR(12) NOT NULL,
skill VARCHAR(100) NOT NULL,
notes VARCHAR(100),
CONSTRAINT pk_skill PRIMARY KEY (id),
CONSTRAINT fk_skill_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id)
);
COMMENT ON TABLE jjj.skill IS 'Skills';
COMMENT ON COLUMN jjj.skill.id
IS 'A unique identifier for each skill entry';
COMMENT ON COLUMN jjj.skill.citizen_id
IS 'The ID of the user to whom this skill applies';
COMMENT ON COLUMN jjj.skill.skill
IS 'The skill itself';
COMMENT ON COLUMN jjj.skill.notes
IS 'Proficiency level, length of experience, etc. in this skill';
CREATE INDEX idx_skill_citizen ON jjj.skill (citizen_id);
COMMENT ON INDEX jjj.idx_skill_citizen IS 'FK index';
CREATE TABLE jjj.success (
id VARCHAR(12) NOT NULL,
citizen_id VARCHAR(12) NOT NULL,
recorded_on BIGINT NOT NULL,
from_here BOOLEAN NOT NULL,
story TEXT,
CONSTRAINT pk_success PRIMARY KEY (id),
CONSTRAINT fk_success_citizen FOREIGN KEY (citizen_id) REFERENCES jjj.citizen (id)
);
COMMENT ON TABLE jjj.success IS 'Success stories';
COMMENT ON COLUMN jjj.success.id
IS 'A unique identifier for each success story';
COMMENT ON COLUMN jjj.success.citizen_id
IS 'The ID of the user to whom this story belongs';
COMMENT ON COLUMN jjj.success.recorded_on
IS 'When the user recorded this success story';
COMMENT ON COLUMN jjj.success.from_here
IS 'Whether the user attributes their employment to their need appearing in Jobs, Jobs, Jobs';
COMMENT ON COLUMN jjj.success.story
IS 'The story of how employment came about (Markdown)';
CREATE INDEX idx_success_citizen ON jjj.success (citizen_id);
COMMENT ON INDEX jjj.idx_success_citizen IS 'FK index';