Compare commits

...

8 Commits
v3.0.1 ... main

Author SHA1 Message Date
3a12478312 Merge pull request 'Version 3.2' (#46) from 3point2 into main
Reviewed-on: #46
2024-06-21 00:09:35 +00:00
db3ca53ba6 Update deps (#43) 2024-06-19 22:27:09 -04:00
62352f9bf3 Remove No Agenda Social references (#44) 2024-06-19 22:24:30 -04:00
43378ed53b Update build target to .NET 8 (#43) 2024-06-19 22:22:22 -04:00
41c3904e85 Upgrade e-mail config 2023-07-09 23:08:10 -04:00
5d431b81c7 Add canonical domain support 2023-07-08 18:20:48 -04:00
a89eff2363
v 3.1 (#42)
- Use PostgreSQL document library
- Remove `isLegacy` property from profiles and listings
- Update Docker image parameters (v 3.1 is deployed as a container)
- Update dependencies
2023-07-02 20:00:16 -04:00
f289bee79d Fix migration query (#41)
- Bump version
2023-02-09 11:33:59 -05:00
25 changed files with 315 additions and 577 deletions

2
.gitignore vendored
View File

@ -5,3 +5,5 @@ src/**/obj
src/**/appsettings.*.json src/**/appsettings.*.json
src/.vs src/.vs
src/.idea src/.idea
.fake

View File

@ -1,10 +1,26 @@
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /jjj WORKDIR /jjj
COPY . ./ COPY ./JobsJobsJobs.sln ./
WORKDIR /jjj/JobsJobsJobs/Server COPY ./JobsJobsJobs/Directory.Build.props ./JobsJobsJobs/
RUN dotnet publish JobsJobsJobs.Server.csproj -c Release /p:PublishProfile=Properties/PublishProfiles/FolderProfile.xml COPY ./JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj ./JobsJobsJobs/Application/
COPY ./JobsJobsJobs/Citizens/JobsJobsJobs.Citizens.fsproj ./JobsJobsJobs/Citizens/
COPY ./JobsJobsJobs/Common/JobsJobsJobs.Common.fsproj ./JobsJobsJobs/Common/
COPY ./JobsJobsJobs/Home/JobsJobsJobs.Home.fsproj ./JobsJobsJobs/Home/
COPY ./JobsJobsJobs/Listings/JobsJobsJobs.Listings.fsproj ./JobsJobsJobs/Listings/
COPY ./JobsJobsJobs/Profiles/JobsJobsJobs.Profiles.fsproj ./JobsJobsJobs/Profiles/
COPY ./JobsJobsJobs/SuccessStories/JobsJobsJobs.SuccessStories.fsproj ./JobsJobsJobs/SuccessStories/
RUN dotnet restore
FROM mcr.microsoft.com/dotnet/aspnet:5.0 COPY . ./
WORKDIR /jjj WORKDIR /jjj/JobsJobsJobs/Application
COPY --from=build /jjj/JobsJobsJobs/Server/bin/Release/net5.0/linux-x64/publish/ ./ RUN dotnet publish -c Release -r linux-x64
ENTRYPOINT [ "/jjj/JobsJobsJobs.Server" ] RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final
WORKDIR /app
RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
COPY --from=build /jjj/JobsJobsJobs/Application/bin/Release/net8.0/linux-x64/publish/ ./
EXPOSE 80
CMD [ "dotnet", "/app/JobsJobsJobs.Application.dll" ]

View File

@ -3,6 +3,7 @@ module JobsJobsJobs.App
open System open System
open System.Text open System.Text
open BitBadger.AspNetCore.CanonicalDomains
open Giraffe open Giraffe
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
@ -30,6 +31,7 @@ type BufferedBodyMiddleware (next : RequestDelegate) =
let main args = let main args =
let builder = WebApplication.CreateBuilder args let builder = WebApplication.CreateBuilder args
let _ = builder.Configuration.AddEnvironmentVariables "JJJ_"
let svc = builder.Services let svc = builder.Services
let _ = svc.AddGiraffe () let _ = svc.AddGiraffe ()
@ -57,6 +59,9 @@ let main args =
opts.Cookie.HttpOnly <- true opts.Cookie.HttpOnly <- true
opts.Cookie.IsEssential <- true) opts.Cookie.IsEssential <- true)
let emailCfg = cfg.GetSection "Email"
if (emailCfg.GetChildren >> Seq.isEmpty >> not) () then ConfigurationBinder.Bind(emailCfg, Email.options)
let app = builder.Build () let app = builder.Build ()
// Unify the endpoints from all features // Unify the endpoints from all features
@ -70,6 +75,7 @@ let main args =
] ]
let _ = app.UseForwardedHeaders () let _ = app.UseForwardedHeaders ()
let _ = app.UseCanonicalDomains ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseStaticFiles () let _ = app.UseStaticFiles ()
let _ = app.UseRouting () let _ = app.UseRouting ()

View File

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
<SelfContained>false</SelfContained> <SelfContained>false</SelfContained>
<WarnOn>3390;$(WarnOn)</WarnOn> <WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup> </PropertyGroup>
@ -25,4 +25,12 @@
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" /> <ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BitBadger.AspNetCore.CanonicalDomains" Version="1.0.0" />
</ItemGroup>
</Project> </Project>

View File

@ -4,5 +4,12 @@
"JobsJobsJobs.Api.Handlers.Citizen": "Information", "JobsJobsJobs.Api.Handlers.Citizen": "Information",
"Microsoft.AspNetCore.StaticFiles": "Warning" "Microsoft.AspNetCore.StaticFiles": "Warning"
} }
},
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://0.0.0.0:80"
}
}
} }
} }

View File

@ -1,5 +1,6 @@
module JobsJobsJobs.Citizens.Data module JobsJobsJobs.Citizens.Data
open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open NodaTime open NodaTime
@ -11,45 +12,36 @@ let mutable private lastPurge = Instant.MinValue
/// Lock access to the above /// Lock access to the above
let private locker = obj () let private locker = obj ()
/// Delete a citizen by their ID using the given connection properties /// Delete a citizen by their ID
let private doDeleteById citizenId connProps = backgroundTask { let deleteById citizenId = backgroundTask {
let citId = CitizenId.toString citizenId let citId = CitizenId.toString citizenId
let! _ = do! Custom.nonQuery
connProps $"{Query.Delete.byContains Table.Success};
|> Sql.query $" {Query.Delete.byContains Table.Listing};
DELETE FROM {Table.Success} WHERE data @> @criteria; {Query.Delete.byId Table.Citizen}"
DELETE FROM {Table.Listing} WHERE data @> @criteria; [ "@criteria", Query.jsonbDocParam {| citizenId = citId |}; "@id", Sql.string citId ]
DELETE FROM {Table.Citizen} WHERE id = @id"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| citizenId = citId |}); "@id", Sql.string citId ]
|> Sql.executeNonQueryAsync
()
} }
/// Delete a citizen by their ID
let deleteById citizenId =
doDeleteById citizenId (dataSource ())
/// Save a citizen /// Save a citizen
let private saveCitizen (citizen : Citizen) connProps = let private saveCitizen (citizen : Citizen) =
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen) save Table.Citizen (CitizenId.toString citizen.Id) citizen
/// Save security information for a citizen /// Save security information for a citizen
let private saveSecurity (security : SecurityInfo) connProps = let saveSecurityInfo (security : SecurityInfo) =
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security) save Table.SecurityInfo (CitizenId.toString security.Id) security
/// Purge expired tokens /// Purge expired tokens
let private purgeExpiredTokens now = backgroundTask { let private purgeExpiredTokens now = backgroundTask {
let connProps = dataSource ()
let! info = let! info =
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps Custom.list $"{Query.selectFromTable Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" []
|> Sql.executeAsync toDocument<SecurityInfo> fromData<SecurityInfo>
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
if expired.TokenUsage.Value = "confirm" then if expired.TokenUsage.Value = "confirm" then
// Unconfirmed account; delete the entire thing // Unconfirmed account; delete the entire thing
do! doDeleteById expired.Id connProps do! deleteById expired.Id
else else
// Some other use; just clear the token // Some other use; just clear the token
do! saveSecurity { expired with Token = None; TokenUsage = None; TokenExpires = None } connProps do! saveSecurityInfo { expired with Token = None; TokenUsage = None; TokenExpires = None }
} }
/// Check for tokens to purge if it's been more than 10 minutes since we last checked /// Check for tokens to purge if it's been more than 10 minutes since we last checked
@ -62,51 +54,39 @@ let private checkForPurge skipCheck =
}) })
/// Find a citizen by their ID /// Find a citizen by their ID
let findById citizenId = backgroundTask { let findById citizenId =
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with Find.byId Table.Citizen (CitizenId.toString citizenId)
| Some c when not c.IsLegacy -> return Some c
| Some _
| None -> return None
}
/// Save a citizen /// Save a citizen
let save citizen = let save citizen =
saveCitizen citizen (dataSource ()) saveCitizen citizen
/// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken /// Register a citizen (saves citizen and security settings); returns false if the e-mail is already taken
let register citizen (security : SecurityInfo) = backgroundTask { let register (citizen : Citizen) (security : SecurityInfo) = backgroundTask {
let connProps = dataSource ()
use conn = Sql.createConnection connProps
use! txn = conn.BeginTransactionAsync ()
try try
do! saveCitizen citizen connProps let! _ =
do! saveSecurity security connProps Configuration.dataSource ()
do! txn.CommitAsync () |> Sql.fromDataSource
|> Sql.executeTransactionAsync
[ Query.save Table.Citizen, [ Query.docParameters (CitizenId.toString citizen.Id) citizen ]
Query.save Table.SecurityInfo, [ Query.docParameters (CitizenId.toString citizen.Id) security ]
]
return true return true
with with
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" -> | :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
do! txn.RollbackAsync ()
return false return false
} }
/// Try to find the security information matching a confirmation token /// Try to find the security information matching a confirmation token
let private tryConfirmToken (token : string) connProps = backgroundTask { let private tryConfirmToken (token : string) =
let! tryInfo = Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token; tokenUsage = "confirm" |}
connProps
|> Sql.query $" SELECT * FROM {Table.SecurityInfo} WHERE data @> @criteria"
|> Sql.parameters [ "criteria", Sql.jsonb (mkDoc {| token = token; tokenUsage = "confirm" |}) ]
|> Sql.executeAsync toDocument<SecurityInfo>
return List.tryHead tryInfo
}
/// Confirm a citizen's account /// Confirm a citizen's account
let confirmAccount token = backgroundTask { let confirmAccount token = backgroundTask {
do! checkForPurge true do! checkForPurge true
let connProps = dataSource () match! tryConfirmToken token with
match! tryConfirmToken token connProps with
| Some info -> | Some info ->
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None } do! saveSecurityInfo { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
connProps
return true return true
| None -> return false | None -> return false
} }
@ -114,34 +94,27 @@ let confirmAccount token = backgroundTask {
/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent) /// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
let denyAccount token = backgroundTask { let denyAccount token = backgroundTask {
do! checkForPurge true do! checkForPurge true
let connProps = dataSource () match! tryConfirmToken token with
match! tryConfirmToken token connProps with
| Some info -> | Some info ->
do! doDeleteById info.Id connProps do! deleteById info.Id
return true return true
| None -> return false | None -> return false
} }
/// Attempt a user log on /// Attempt a user log on
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string) let tryLogOn (email : string) password (pwVerify : Citizen -> string -> bool option)
now = backgroundTask { (pwHash : Citizen -> string -> string) now = backgroundTask {
do! checkForPurge false do! checkForPurge false
let connProps = dataSource () match! Find.firstByContains<Citizen> Table.Citizen {| email = email |} with
let! tryCitizen =
connProps
|> Sql.query $"SELECT * FROM {Table.Citizen} WHERE data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| email = email; isLegacy = false |}) ]
|> Sql.executeAsync toDocument<Citizen>
match List.tryHead tryCitizen with
| Some citizen -> | Some citizen ->
let citizenId = CitizenId.toString citizen.Id let citizenId = CitizenId.toString citizen.Id
let! tryInfo = getDocument<SecurityInfo> Table.SecurityInfo citizenId connProps let! tryInfo = Find.byId<SecurityInfo> Table.SecurityInfo citizenId
let! info = backgroundTask { let! info = backgroundTask {
match tryInfo with match tryInfo with
| Some it -> return it | Some it -> return it
| None -> | None ->
let it = { SecurityInfo.empty with Id = citizen.Id } let it = { SecurityInfo.empty with Id = citizen.Id }
do! saveSecurity it connProps do! saveSecurityInfo it
return it return it
} }
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)" if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
@ -149,129 +122,29 @@ let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHas
match pwVerify citizen password with match pwVerify citizen password with
| Some rehash -> | Some rehash ->
let hash = if rehash then pwHash citizen password else citizen.PasswordHash let hash = if rehash then pwHash citizen password else citizen.PasswordHash
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps do! saveSecurityInfo { info with FailedLogOnAttempts = 0 }
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash }
return Ok { citizen with LastSeenOn = now } return Ok { citizen with LastSeenOn = now }
| None -> | None ->
let locked = info.FailedLogOnAttempts >= 4 let locked = info.FailedLogOnAttempts >= 4
do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked } do! { info with FailedLogOnAttempts = info.FailedLogOnAttempts + 1; AccountLocked = locked }
|> saveSecurity <| connProps |> saveSecurityInfo
return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}""" return Error $"""Log on unsuccessful{if locked then " - Account is now locked" else ""}"""
| None -> return Error "Log on unsuccessful" | None -> return Error "Log on unsuccessful"
} }
/// Try to retrieve a citizen and their security information by their e-mail address /// Try to retrieve a citizen and their security information by their e-mail address
let tryByEmailWithSecurity email = backgroundTask { let tryByEmailWithSecurity email =
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row) Custom.single
let! results = $"SELECT c.*, s.data AS sec_data
dataSource ()
|> Sql.query $"
SELECT c.*, s.data AS sec_data
FROM {Table.Citizen} c FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
WHERE c.data @> @criteria" WHERE c.data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| email = email |}) ] [ "@criteria", Query.jsonbDocParam {| email = email |} ]
|> Sql.executeAsync toCitizenSecurityPair (fun row -> (fromData<Citizen> row, fromDocument<SecurityInfo> "sec_data" row))
return List.tryHead results
}
/// Save an updated security information document
let saveSecurityInfo security = backgroundTask {
do! saveSecurity security (dataSource ())
}
/// Try to retrieve security information by the given token /// Try to retrieve security information by the given token
let trySecurityByToken (token : string) = backgroundTask { let trySecurityByToken (token : string) = backgroundTask {
do! checkForPurge false do! checkForPurge false
let! results = return! Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token |}
dataSource ()
|> Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| token = token |}) ]
|> Sql.executeAsync toDocument<SecurityInfo>
return List.tryHead results
}
// ~~~ LEGACY MIGRATION ~~~ //
/// Get all legacy citizens
let legacy () = backgroundTask {
return!
dataSource ()
|> Sql.query $"""
SELECT *
FROM {Table.Citizen}
WHERE data @> '{{ "isLegacy": true }}'::jsonb
ORDER BY data ->> 'firstName'"""
|> Sql.executeAsync toDocument<Citizen>
}
/// Get all current citizens with verified accounts but without a profile
let current () = backgroundTask {
return!
dataSource ()
|> Sql.query $"""
SELECT c.*
FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} si ON si.id = c.id
WHERE c.data @> '{{ "isLegacy": false }}'::jsonb
AND si.data @> '{{ "accountLocked": false }}'::jsonb
AND NOT EXISTS (SELECT 1 FROM {Table.Profile} p WHERE p.id = c.id)"""
|> Sql.executeAsync toDocument<Citizen>
}
let migrateLegacy currentId legacyId = backgroundTask {
let oldId = CitizenId.toString legacyId
let connProps = dataSource ()
use conn = Sql.createConnection connProps
use! txn = conn.BeginTransactionAsync ()
try
// Add legacy data to current user
let! profiles =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Profile} WHERE id = @oldId"
|> Sql.parameters [ "@oldId", Sql.string oldId ]
|> Sql.executeAsync toDocument<Profile>
match List.tryHead profiles with
| Some profile ->
do! saveDocument
Table.Profile (CitizenId.toString currentId) (Sql.existingConnection conn)
(mkDoc { profile with Id = currentId; IsLegacy = false })
| None -> ()
let oldCriteria = mkDoc {| citizenId = oldId |}
let! listings =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Listing} WHERE data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.jsonb oldCriteria ]
|> Sql.executeAsync toDocument<Listing>
for listing in listings do
let newListing = { listing with Id = ListingId.create (); CitizenId = currentId; IsLegacy = false }
do! saveDocument
Table.Listing (ListingId.toString newListing.Id) (Sql.existingConnection conn) (mkDoc newListing)
let! successes =
conn
|> Sql.existingConnection
|> Sql.query $"SELECT * FROM {Table.Success} WHERE data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.string oldCriteria ]
|> Sql.executeAsync toDocument<Success>
for success in successes do
let newSuccess = { success with Id = SuccessId.create (); CitizenId = currentId }
do! saveDocument
Table.Success (SuccessId.toString newSuccess.Id) (Sql.existingConnection conn) (mkDoc newSuccess)
// Delete legacy data
let! _ =
conn
|> Sql.existingConnection
|> Sql.query $"
DELETE FROM {Table.Success} WHERE data @> @criteria;
DELETE FROM {Table.Listing} WHERE data @> @criteria;
DELETE FROM {Table.Citizen} WHERE id = @oldId"
|> Sql.parameters [ "@criteria", Sql.jsonb oldCriteria; "@oldId", Sql.string oldId ]
|> Sql.executeNonQueryAsync
do! txn.CommitAsync ()
return Ok ""
with :? Npgsql.PostgresException as ex ->
do! txn.RollbackAsync ()
return Error ex.MessageText
} }

View File

@ -151,14 +151,3 @@ type ResetPasswordForm =
/// The new password for the account /// The new password for the account
Password : string Password : string
} }
// ~~~ LEGACY MIGRATION ~~ //
[<CLIMutable; NoComparison; NoEquality>]
type LegacyMigrationForm =
{ /// The ID of the current citizen
Id : string
/// The ID of the legacy citizen to be migrated
LegacyId : string
}

View File

@ -59,13 +59,6 @@ module private Auth =
| PasswordVerificationResult.SuccessRehashNeeded -> Some true | PasswordVerificationResult.SuccessRehashNeeded -> Some true
| _ -> None | _ -> None
/// Require an administrative user (used for legacy migration endpoints)
let requireAdmin : HttpHandler = requireUser >=> fun next ctx -> task {
let adminUser = (config ctx)["AdminUser"]
if adminUser = defaultArg (tryUser ctx) "" then return! next ctx
else return! Error.notAuthorized next ctx
}
// GET: /citizen/account // GET: /citizen/account
let account : HttpHandler = fun next ctx -> task { let account : HttpHandler = fun next ctx -> task {
@ -332,25 +325,6 @@ let saveAccount : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx ->
let soLong : HttpHandler = requireUser >=> fun next ctx -> let soLong : HttpHandler = requireUser >=> fun next ctx ->
Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx Views.deletionOptions (csrf ctx) |> render "Account Deletion Options" next ctx
// ~~~ LEGACY MIGRATION ~~~ //
// GET: /citizen/legacy
let legacy : HttpHandler = Auth.requireAdmin >=> fun next ctx -> task {
let! currentUsers = Data.current ()
let! legacyUsers = Data.legacy ()
return! Views.legacy currentUsers legacyUsers (csrf ctx) |> render "Migrate Legacy Account" next ctx
}
// POST: /citizen/legacy/migrate
let migrateLegacy : HttpHandler = Auth.requireAdmin >=> validateCsrf >=> fun next ctx -> task {
let! form = ctx.BindFormAsync<LegacyMigrationForm> ()
let currentId = CitizenId.ofString form.Id
let legacyId = CitizenId.ofString form.LegacyId
match! Data.migrateLegacy currentId legacyId with
| Ok _ -> do! addSuccess "Migration successful" ctx
| Error err -> do! addError err ctx
return! redirectToGet "/citizen/legacy" next ctx
}
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
@ -369,7 +343,6 @@ let endpoints =
route "/register" register route "/register" register
routef "/reset-password/%s" resetPassword routef "/reset-password/%s" resetPassword
route "/so-long" soLong route "/so-long" soLong
route "/legacy" legacy
] ]
POST [ POST [
route "/delete" delete route "/delete" delete
@ -378,6 +351,5 @@ let endpoints =
route "/register" doRegistration route "/register" doRegistration
route "/reset-password" doResetPassword route "/reset-password" doResetPassword
route "/save-account" saveAccount route "/save-account" saveAccount
route "/legacy/migrate" migrateLegacy
] ]
] ]

View File

@ -16,4 +16,8 @@
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" /> <ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
</Project> </Project>

View File

@ -360,7 +360,7 @@ let registered =
txt "register again." txt "register again."
] ]
p [] [ p [] [
txt "If you encounter issues, feel free to reach out to @danieljsummers on No Agenda Social for assistance." txt "If you encounter issues, feel free to reach out to @daniel@fedi.summershome.org for assistance."
] ]
] ]
@ -393,54 +393,3 @@ let resetPassword (m : ResetPasswordForm) isHtmx csrf =
jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx jsOnLoad $"jjj.citizen.validatePasswords('{nameof m.Password}', 'ConfirmPassword', true)" isHtmx
] ]
] ]
// ~~~ LEGACY MIGRATION ~~~ //
let legacy (current : Citizen list) (legacy : Citizen list) csrf =
form [ _class "container"; _hxPost "/citizen/legacy/migrate" ] [
antiForgery csrf
let canProcess = not (List.isEmpty current)
div [ _class "row" ] [
if canProcess then
div [ _class "col-12 col-lg-6 col-xxl-4" ] [
div [ _class "form-floating" ] [
select [ _id "current"; _name "Id"; _class "form-control" ] [
option [ _value "" ] [ txt "&ndash; Select &ndash;" ]
yield!
current
|> List.sortBy Citizen.name
|> List.map (fun it ->
option [ _value (CitizenId.toString it.Id) ] [
str (Citizen.name it); txt " ("; str it.Email; txt ")"
])
]
label [ _for "current" ] [ txt "Current User" ]
]
]
else p [] [ txt "There are no current accounts to which legacy accounts can be migrated" ]
div [ _class "col-12 col-lg-6 offset-xxl-2"] [
table [ _class "table table-sm table-hover" ] [
thead [] [
tr [] [
th [ _scope "col" ] [ txt "Select" ]
th [ _scope "col" ] [ txt "NAS Profile" ]
]
]
legacy |> List.map (fun it ->
let theId = CitizenId.toString it.Id
tr [] [
td [] [
if canProcess then
input [ _type "radio"; _id $"legacy_{theId}"; _name "LegacyId"; _value theId ]
else txt "&nbsp;"
]
td [] [ label [ _for $"legacy_{theId}" ] [ str it.Email ] ]
])
|> tbody []
]
]
]
submitButton "content-save-outline" "Migrate Account"
]
|> List.singleton
|> pageWithTitle "Migrate Legacy Account"

View File

@ -35,12 +35,6 @@ module private CacheHelpers =
/// Get the current instant /// Get the current instant
let getNow () = SystemClock.Instance.GetCurrentInstant () let getNow () = SystemClock.Instance.GetCurrentInstant ()
/// Get the first result of the given query
let tryHead<'T> (query : Task<'T list>) = backgroundTask {
let! results = query
return List.tryHead results
}
/// Create a parameter for a non-standard type /// Create a parameter for a non-standard type
let typedParam<'T> name (it : 'T) = let typedParam<'T> name (it : 'T) =
$"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it)) $"@%s{name}", Sql.parameter (NpgsqlParameter ($"@{name}", it))
@ -56,6 +50,7 @@ module private CacheHelpers =
open System.Threading open System.Threading
open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
open Microsoft.Extensions.Caching.Distributed open Microsoft.Extensions.Caching.Distributed
@ -69,46 +64,38 @@ type DistributedCache () =
do do
task { task {
let dataSource = dataSource ()
let! exists = let! exists =
dataSource Custom.scalar
|> Sql.query $" $"SELECT EXISTS
SELECT EXISTS
(SELECT 1 FROM pg_tables WHERE schemaname = 'jjj' AND tablename = 'session') (SELECT 1 FROM pg_tables WHERE schemaname = 'jjj' AND tablename = 'session')
AS does_exist" AS does_exist"
|> Sql.executeRowAsync (fun row -> row.bool "does_exist") [] (fun row -> row.bool "does_exist")
if not exists then if not exists then
let! _ = do! Custom.nonQuery
dataSource
|> Sql.query
"CREATE TABLE jjj.session ( "CREATE TABLE jjj.session (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
payload BYTEA NOT NULL, payload BYTEA NOT NULL,
expire_at TIMESTAMPTZ NOT NULL, expire_at TIMESTAMPTZ NOT NULL,
sliding_expiration INTERVAL, sliding_expiration INTERVAL,
absolute_expiration TIMESTAMPTZ); absolute_expiration TIMESTAMPTZ);
CREATE INDEX idx_session_expiration ON jjj.session (expire_at)" CREATE INDEX idx_session_expiration ON jjj.session (expire_at)" []
|> Sql.executeNonQueryAsync
()
} |> sync } |> sync
// ~~~ SUPPORT FUNCTIONS ~~~ // ~~~ SUPPORT FUNCTIONS ~~~
/// Get an entry, updating it for sliding expiration /// Get an entry, updating it for sliding expiration
let getEntry key = backgroundTask { let getEntry key = backgroundTask {
let dataSource = dataSource ()
let idParam = "@id", Sql.string key let idParam = "@id", Sql.string key
let! tryEntry = let! tryEntry =
dataSource Custom.single
|> Sql.query "SELECT * FROM jjj.session WHERE id = @id" "SELECT * FROM jjj.session WHERE id = @id" [ idParam ]
|> Sql.parameters [ idParam ] (fun row ->
|> Sql.executeAsync (fun row ->
{ Id = row.string "id" { Id = row.string "id"
Payload = row.bytea "payload" Payload = row.bytea "payload"
ExpireAt = row.fieldValue<Instant> "expire_at" ExpireAt = row.fieldValue<Instant> "expire_at"
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration" SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" }) AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration"
|> tryHead })
match tryEntry with match tryEntry with
| Some entry -> | Some entry ->
let now = getNow () let now = getNow ()
@ -121,12 +108,9 @@ type DistributedCache () =
true, { entry with ExpireAt = absExp } true, { entry with ExpireAt = absExp }
else true, { entry with ExpireAt = now.Plus slideExp } else true, { entry with ExpireAt = now.Plus slideExp }
if needsRefresh then if needsRefresh then
let! _ = do! Custom.nonQuery
dataSource "UPDATE jjj.session SET expire_at = @expireAt WHERE id = @id"
|> Sql.query "UPDATE jjj.session SET expire_at = @expireAt WHERE id = @id" [ expireParam item.ExpireAt; idParam ]
|> Sql.parameters [ expireParam item.ExpireAt; idParam ]
|> Sql.executeNonQueryAsync
()
return if item.ExpireAt > now then Some entry else None return if item.ExpireAt > now then Some entry else None
| None -> return None | None -> return None
} }
@ -138,23 +122,13 @@ type DistributedCache () =
let purge () = backgroundTask { let purge () = backgroundTask {
let now = getNow () let now = getNow ()
if lastPurge.Plus (Duration.FromMinutes 30L) < now then if lastPurge.Plus (Duration.FromMinutes 30L) < now then
let! _ = do! Custom.nonQuery "DELETE FROM jjj.session WHERE expire_at < @expireAt" [ expireParam now ]
dataSource ()
|> Sql.query "DELETE FROM jjj.session WHERE expire_at < @expireAt"
|> Sql.parameters [ expireParam now ]
|> Sql.executeNonQueryAsync
lastPurge <- now lastPurge <- now
} }
/// Remove a cache entry /// Remove a cache entry
let removeEntry key = backgroundTask { let removeEntry key =
let! _ = Custom.nonQuery "DELETE FROM jjj.session WHERE id = @id" [ "@id", Sql.string key ]
dataSource ()
|> Sql.query "DELETE FROM jjj.session WHERE id = @id"
|> Sql.parameters [ "@id", Sql.string key ]
|> Sql.executeNonQueryAsync
()
}
/// Save an entry /// Save an entry
let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask { let saveEntry (opts : DistributedCacheEntryOptions) key payload = backgroundTask {
@ -173,9 +147,7 @@ type DistributedCache () =
// Default to 1 hour sliding expiration // Default to 1 hour sliding expiration
let slide = Duration.FromHours 1 let slide = Duration.FromHours 1
now.Plus slide, Some slide, None now.Plus slide, Some slide, None
let! _ = do! Custom.nonQuery
dataSource ()
|> Sql.query
"INSERT INTO jjj.session ( "INSERT INTO jjj.session (
id, payload, expire_at, sliding_expiration, absolute_expiration id, payload, expire_at, sliding_expiration, absolute_expiration
) VALUES ( ) VALUES (
@ -185,14 +157,11 @@ type DistributedCache () =
expire_at = EXCLUDED.expire_at, expire_at = EXCLUDED.expire_at,
sliding_expiration = EXCLUDED.sliding_expiration, sliding_expiration = EXCLUDED.sliding_expiration,
absolute_expiration = EXCLUDED.absolute_expiration" absolute_expiration = EXCLUDED.absolute_expiration"
|> Sql.parameters
[ "@id", Sql.string key [ "@id", Sql.string key
"@payload", Sql.bytea payload "@payload", Sql.bytea payload
expireParam expireAt expireParam expireAt
optParam "slideExp" slideExp optParam "slideExp" slideExp
optParam "absExp" absExp ] optParam "absExp" absExp ]
|> Sql.executeNonQueryAsync
()
} }
// ~~~ IMPLEMENTATION FUNCTIONS ~~~ // ~~~ IMPLEMENTATION FUNCTIONS ~~~

View File

@ -29,80 +29,63 @@ module Table =
let Success = "jjj.success" let Success = "jjj.success"
open BitBadger.Npgsql.FSharp.Documents
open Npgsql.FSharp open Npgsql.FSharp
/// Connection management for the document store /// Connection management for the document store
[<AutoOpen>] [<AutoOpen>]
module DataConnection = module DataConnection =
open System.Text.Json
open BitBadger.Npgsql.Documents
open JobsJobsJobs
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Npgsql open Npgsql
/// The data source for the document store
let mutable private theDataSource : NpgsqlDataSource option = None
/// Get the data source as the start of a SQL statement
let dataSource () =
match theDataSource with
| Some ds -> Sql.fromDataSource ds
| None -> invalidOp "DataConnection.setUp() must be called before accessing the database"
/// Create tables /// Create tables
let private createTables () = backgroundTask { let private createTables () = backgroundTask {
let sql = [ do! Custom.nonQuery "CREATE SCHEMA IF NOT EXISTS jjj" []
"CREATE SCHEMA IF NOT EXISTS jjj" do! Definition.ensureTable Table.Citizen
// Tables do! Definition.ensureTable Table.Continent
$"CREATE TABLE IF NOT EXISTS {Table.Citizen} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)" do! Definition.ensureTable Table.Listing
$"CREATE TABLE IF NOT EXISTS {Table.Continent} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)" do! Definition.ensureTable Table.Success
$"CREATE TABLE IF NOT EXISTS {Table.Listing} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)" // Tables that use more than the default document configuration, key indexes, and text search index
$"CREATE TABLE IF NOT EXISTS {Table.Profile} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL, do! Custom.nonQuery
text_search TSVECTOR NOT NULL, $"CREATE TABLE IF NOT EXISTS {Table.Profile}
CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)" (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL, text_search TSVECTOR NOT NULL,
$"CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL, CONSTRAINT fk_profile_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE);
CONSTRAINT fk_security_info_citizen FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE)" CREATE TABLE IF NOT EXISTS {Table.SecurityInfo} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL,
$"CREATE TABLE IF NOT EXISTS {Table.Success} (id TEXT NOT NULL PRIMARY KEY, data JSONB NOT NULL)" CONSTRAINT fk_security_info_citizen
// Key indexes FOREIGN KEY (id) REFERENCES {Table.Citizen} (id) ON DELETE CASCADE);
$"CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'))" CREATE UNIQUE INDEX IF NOT EXISTS uk_citizen_email ON {Table.Citizen} ((data -> 'email'));
$"CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'))" CREATE INDEX IF NOT EXISTS idx_listing_citizen ON {Table.Listing} ((data -> 'citizenId'));
$"CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'))" CREATE INDEX IF NOT EXISTS idx_listing_continent ON {Table.Listing} ((data -> 'continentId'));
$"CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'))" CREATE INDEX IF NOT EXISTS idx_profile_continent ON {Table.Profile} ((data -> 'continentId'));
$"CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'))" CREATE INDEX IF NOT EXISTS idx_success_citizen ON {Table.Success} ((data -> 'citizenId'));
// Profile text search index CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)"
$"CREATE INDEX IF NOT EXISTS idx_profile_search ON {Table.Profile} USING GIN(text_search)" []
]
let! _ =
dataSource ()
|> Sql.executeTransactionAsync (sql |> List.map (fun sql -> sql, [ [] ]))
()
} }
/// Create functions and triggers required to /// Create functions and triggers required to keep the search index current
let createTriggers () = backgroundTask { let private createTriggers () = backgroundTask {
let! functions = let! functions =
dataSource () Custom.list
|> Sql.query
"SELECT p.proname "SELECT p.proname
FROM pg_catalog.pg_proc p FROM pg_catalog.pg_proc p
LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'jjj'" WHERE n.nspname = 'jjj'"
|> Sql.executeAsync (fun row -> row.string "proname") [] (fun row -> row.string "proname")
if not (functions |> List.contains "indexable_array_string") then if not (functions |> List.contains "indexable_array_string") then
let! _ = do! Custom.nonQuery
dataSource () """CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
|> Sql.query """
CREATE FUNCTION jjj.indexable_array_string(target jsonb, path jsonpath) RETURNS text AS $$
BEGIN BEGIN
RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text, RETURN REPLACE(REPLACE(REPLACE(REPLACE(jsonb_path_query_array(target, path)::text,
'["', ''), '", "', ' '), '"]', ''), '[]', ''); '["', ''), '", "', ' '), '"]', ''), '[]', '');
END; END;
$$ LANGUAGE plpgsql;""" $$ LANGUAGE plpgsql;""" []
|> Sql.executeNonQueryAsync
()
if not (functions |> List.contains "set_text_search") then if not (functions |> List.contains "set_text_search") then
let! _ = do! Custom.nonQuery
dataSource () $"CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
|> Sql.query $"
CREATE FUNCTION jjj.set_text_search() RETURNS trigger AS $$
BEGIN BEGIN
NEW.text_search := to_tsvector('english', NEW.text_search := to_tsvector('english',
COALESCE(NEW.data ->> 'region', '') || ' ' COALESCE(NEW.data ->> 'region', '') || ' '
@ -116,73 +99,33 @@ module DataConnection =
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile} CREATE TRIGGER set_text_search BEFORE INSERT OR UPDATE ON {Table.Profile}
FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();" FOR EACH ROW EXECUTE FUNCTION jjj.set_text_search();" []
|> Sql.executeNonQueryAsync
()
} }
/// Set up the data connection from the given configuration /// Set up the data connection from the given configuration
let setUp (cfg : IConfiguration) = backgroundTask { let setUp (cfg : IConfiguration) = backgroundTask {
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL") let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "PostgreSQL")
let _ = builder.UseNodaTime () let _ = builder.UseNodaTime ()
theDataSource <- Some (builder.Build ()) Configuration.useDataSource (builder.Build ())
Configuration.useSerializer
{ new IDocumentSerializer with
member _.Serialize<'T> (it : 'T) = JsonSerializer.Serialize (it, Json.options)
member _.Deserialize<'T> (it : string) = JsonSerializer.Deserialize<'T> (it, Json.options)
}
do! createTables () do! createTables ()
do! createTriggers () do! createTriggers ()
} }
open System.Text.Json
open System.Threading.Tasks
open JobsJobsJobs
/// Map the data field to the requested document type
let toDocumentFrom<'T> fieldName (row : RowReader) =
JsonSerializer.Deserialize<'T> (row.string fieldName, Json.options)
/// Map the data field to the requested document type
let toDocument<'T> (row : RowReader) = toDocumentFrom<'T> "data" row
/// Get a document
let getDocument<'T> table docId sqlProps : Task<'T option> = backgroundTask {
let! doc =
Sql.query $"SELECT * FROM %s{table} where id = @id" sqlProps
|> Sql.parameters [ "@id", Sql.string docId ]
|> Sql.executeAsync toDocument
return List.tryHead doc
}
/// Serialize a document to JSON
let mkDoc<'T> (doc : 'T) =
JsonSerializer.Serialize<'T> (doc, Json.options)
/// Save a document
let saveDocument table docId sqlProps doc = backgroundTask {
let! _ =
Sql.query
$"INSERT INTO %s{table} (id, data) VALUES (@id, @data)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data"
sqlProps
|> Sql.parameters
[ "@id", Sql.string docId
"@data", Sql.jsonb doc ]
|> Sql.executeNonQueryAsync
()
}
/// Create a match-anywhere clause for a LIKE or ILIKE clause /// Create a match-anywhere clause for a LIKE or ILIKE clause
let like value = let like value =
Sql.string $"%%%s{value}%%" Sql.string $"%%%s{value}%%"
/// The JSON access operator ->> makes values text; this makes a parameter that will compare the properly
let jsonBool value =
Sql.string (if value then "true" else "false")
/// Get the SQL for a search WHERE clause /// Get the SQL for a search WHERE clause
let searchSql criteria = let searchSql criteria =
let sql = criteria |> List.map fst |> String.concat " AND " let sql = criteria |> List.map fst |> String.concat " AND "
if sql = "" then "" else $"AND {sql}" if sql = "" then "" else $"AND {sql}"
/// Continent data access functions /// Continent data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Continents = module Continents =
@ -191,10 +134,8 @@ module Continents =
/// Retrieve all continents /// Retrieve all continents
let all () = let all () =
dataSource () Custom.list $"{Query.selectFromTable Table.Continent} ORDER BY data ->> 'name'" [] fromData<Continent>
|> Sql.query $"SELECT * FROM {Table.Continent} ORDER BY data ->> 'name'"
|> Sql.executeAsync toDocument<Continent>
/// Retrieve a continent by its ID /// Retrieve a continent by its ID
let findById continentId = let findById continentId =
dataSource () |> getDocument<Continent> Table.Continent (ContinentId.toString continentId) Find.byId<Continent> Table.Continent (ContinentId.toString continentId)

View File

@ -146,7 +146,7 @@ type OtherContact =
{ /// The type of contact { /// The type of contact
ContactType : ContactType ContactType : ContactType
/// The name of the contact (Email, No Agenda Social, LinkedIn, etc.) /// The name of the contact (Email, Mastodon, LinkedIn, etc.)
Name : string option Name : string option
/// The value for the contact (e-mail address, user name, URL, etc.) /// The value for the contact (e-mail address, user name, URL, etc.)
@ -249,9 +249,6 @@ type Citizen =
/// The other contacts for this user /// The other contacts for this user
OtherContacts : OtherContact list OtherContacts : OtherContact list
/// Whether this is a legacy citizen
IsLegacy : bool
} }
/// Support functions for citizens /// Support functions for citizens
@ -268,7 +265,6 @@ module Citizen =
PasswordHash = "" PasswordHash = ""
DisplayName = None DisplayName = None
OtherContacts = [] OtherContacts = []
IsLegacy = false
} }
/// Get the name of the citizen (either their preferred display name or first/last names) /// Get the name of the citizen (either their preferred display name or first/last names)
@ -334,9 +330,6 @@ type Listing =
/// Was this job filled as part of its appearance on Jobs, Jobs, Jobs? /// Was this job filled as part of its appearance on Jobs, Jobs, Jobs?
WasFilledHere : bool option WasFilledHere : bool option
/// Whether this is a legacy listing
IsLegacy : bool
} }
/// Support functions for job listings /// Support functions for job listings
@ -356,7 +349,6 @@ module Listing =
Text = Text "" Text = Text ""
NeededBy = None NeededBy = None
WasFilledHere = None WasFilledHere = None
IsLegacy = false
} }
@ -434,9 +426,6 @@ type Profile =
/// When the citizen last updated their profile /// When the citizen last updated their profile
LastUpdatedOn : Instant LastUpdatedOn : Instant
/// Whether this is a legacy profile
IsLegacy : bool
} }
/// Support functions for Profiles /// Support functions for Profiles
@ -456,7 +445,6 @@ module Profile =
Experience = None Experience = None
Visibility = Private Visibility = Private
LastUpdatedOn = Instant.MinValue LastUpdatedOn = Instant.MinValue
IsLegacy = false
} }

View File

@ -3,17 +3,41 @@ module JobsJobsJobs.Email
open System.Net open System.Net
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open MailKit.Net.Smtp open MailKit.Net.Smtp
open MailKit.Security
open MimeKit open MimeKit
/// Options to use when sending e-mail
type EmailOptions() =
/// The hostname of the SMTP server
member val SmtpHost : string = "localhost" with get, set
/// The port over which SMTP communication should occur
member val Port : int = 25 with get, set
/// Whether to use SSL when communicating with the SMTP server
member val UseSsl : bool = false with get, set
/// The authentication to use with the SMTP server
member val Authentication : string = "" with get, set
/// The e-mail address from which messages should be sent
member val FromAddress : string = "nobody@noagendacareers.com" with get, set
/// The name from which messages should be sent
member val FromName : string = "Jobs, Jobs, Jobs" with get, set
/// The options for the SMTP server
let mutable options = EmailOptions ()
/// Private functions for sending e-mail /// Private functions for sending e-mail
[<AutoOpen>] [<AutoOpen>]
module private Helpers = module private Helpers =
/// Create an SMTP client /// Create an SMTP client
let smtpClient () = backgroundTask { let createClient () = backgroundTask {
let client = new SmtpClient () let client = new SmtpClient ()
do! client.ConnectAsync ("localhost", 25, SecureSocketOptions.None) do! client.ConnectAsync (options.SmtpHost, options.Port, options.UseSsl)
do! client.AuthenticateAsync (options.FromAddress, options.Authentication)
return client return client
} }
@ -25,11 +49,17 @@ module private Helpers =
msg.Subject <- subject msg.Subject <- subject
msg msg
/// Send a message
let sendMessage msg = backgroundTask {
use! client = createClient ()
let! result = client.SendAsync msg
do! client.DisconnectAsync true
return result
}
/// Send an account confirmation e-mail /// Send an account confirmation e-mail
let sendAccountConfirmation citizen security = backgroundTask { let sendAccountConfirmation citizen security = backgroundTask {
let token = WebUtility.UrlEncode security.Token.Value let token = WebUtility.UrlEncode security.Token.Value
use! client = smtpClient ()
use msg = createMessage citizen "Account Confirmation Request" use msg = createMessage citizen "Account Confirmation Request"
let text = let text =
@ -57,13 +87,12 @@ let sendAccountConfirmation citizen security = backgroundTask {
use msgText = new TextPart (Text = text) use msgText = new TextPart (Text = text)
msg.Body <- msgText msg.Body <- msgText
return! client.SendAsync msg return! sendMessage msg
} }
/// Send a password reset link /// Send a password reset link
let sendPasswordReset citizen security = backgroundTask { let sendPasswordReset citizen security = backgroundTask {
let token = WebUtility.UrlEncode security.Token.Value let token = WebUtility.UrlEncode security.Token.Value
use! client = smtpClient ()
use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs" use msg = createMessage citizen "Reset Password for Jobs, Jobs, Jobs"
let text = let text =
@ -90,5 +119,5 @@ let sendPasswordReset citizen security = backgroundTask {
use msgText = new TextPart (Text = text) use msgText = new TextPart (Text = text)
msg.Body <- msgText msg.Body <- msgText
return! client.SendAsync msg return! sendMessage msg
} }

View File

@ -15,17 +15,19 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FSharp.SystemTextJson" Version="1.0.7" /> <PackageReference Include="BitBadger.Npgsql.FSharp.Documents" Version="1.0.0-beta3" />
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="FSharp.SystemTextJson" Version="1.3.13" />
<PackageReference Include="Giraffe.Htmx" Version="1.8.5" /> <PackageReference Include="Giraffe" Version="6.4.0" />
<PackageReference Include="Giraffe.Htmx" Version="2.0.0" />
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" /> <PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.5" /> <PackageReference Include="Giraffe.ViewEngine.Htmx" Version="2.0.0" />
<PackageReference Include="MailKit" Version="3.3.0" /> <PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="Markdig" Version="0.30.4" /> <PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
<PackageReference Include="Npgsql.FSharp" Version="5.6.0" /> <PackageReference Include="Npgsql.FSharp" Version="5.7.0" />
<PackageReference Include="Npgsql.NodaTime" Version="7.0.1" /> <PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,10 +1,10 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<AssemblyVersion>3.0.1.0</AssemblyVersion> <AssemblyVersion>3.2.0.0</AssemblyVersion>
<FileVersion>3.0.1.0</FileVersion> <FileVersion>3.2.0.0</FileVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -13,4 +13,8 @@
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" /> <ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
</Project> </Project>

View File

@ -984,12 +984,9 @@ module Help =
h4 [ mainHeading ] [ txt "Help / Suggestions" ] h4 [ mainHeading ] [ txt "Help / Suggestions" ]
p [] [ p [] [
txt "This is open-source software " txt "This is open-source software "
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs"; _target "_blank"; _rel "noopener" ] [ a [ _href "https://git.bitbadger.solutions/bit-badger/jobs-jobs-jobs"; _target "_blank"
txt "developed on Github" _rel "noopener" ] [ txt "developed in Git" ]
]; txt "; feel free to " txt "; feel free to ping @daniel@fedi.summershome.org if you run into any issues."
a [ _href "https://github.com/bit-badger/jobs-jobs-jobs/issues"; _target "_blank"; _rel "noopener" ] [
txt "create an issue there"
]; txt ", or look up @danieljsummers on No Agenda Social."
] ]
] ]

View File

@ -1,5 +1,6 @@
module JobsJobsJobs.Listings.Data module JobsJobsJobs.Listings.Data
open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Listings.Domain open JobsJobsJobs.Listings.Domain
@ -14,57 +15,46 @@ let viewSql =
/// Map a result for a listing view /// Map a result for a listing view
let private toListingForView row = let private toListingForView row =
{ Listing = toDocument<Listing> row { Listing = fromData<Listing> row
ContinentName = row.string "continent_name" ContinentName = row.string "continent_name"
Citizen = toDocumentFrom<Citizen> "cit_data" row Citizen = fromDocument<Citizen> "cit_data" row
} }
/// Find all job listings posted by the given citizen /// Find all job listings posted by the given citizen
let findByCitizen citizenId = let findByCitizen citizenId =
dataSource () Custom.list<ListingForView>
|> Sql.query $"{viewSql} WHERE l.data @> @criteria" $"{viewSql} WHERE l.data @> @criteria"
|> Sql.parameters [ "@criteria", Query.jsonbDocParam {| citizenId = CitizenId.toString citizenId |} ]
[ "@criteria", Sql.jsonb (mkDoc {| citizenId = CitizenId.toString citizenId; isLegacy = false |}) ] toListingForView
|> Sql.executeAsync toListingForView
/// Find a listing by its ID /// Find a listing by its ID
let findById listingId = backgroundTask { let findById listingId =
match! dataSource () |> getDocument<Listing> Table.Listing (ListingId.toString listingId) with Find.byId<Listing> Table.Listing (ListingId.toString listingId)
| Some listing when not listing.IsLegacy -> return Some listing
| Some _
| None -> return None
}
/// Find a listing by its ID for viewing (includes continent information) /// Find a listing by its ID for viewing (includes continent information)
let findByIdForView listingId = backgroundTask { let findByIdForView listingId =
let! tryListing = Custom.single<ListingForView>
dataSource () $"{viewSql} WHERE l.id = @id" [ "@id", Sql.string (ListingId.toString listingId) ] toListingForView
|> Sql.query $"""{viewSql} WHERE l.id = @id AND l.data @> '{{ "isLegacy": false }}'::jsonb"""
|> Sql.parameters [ "@id", Sql.string (ListingId.toString listingId) ]
|> Sql.executeAsync toListingForView
return List.tryHead tryListing
}
/// Save a listing /// Save a listing
let save (listing : Listing) = let save (listing : Listing) =
dataSource () |> saveDocument Table.Listing (ListingId.toString listing.Id) <| mkDoc listing save Table.Listing (ListingId.toString listing.Id) listing
/// Search job listings /// Search job listings
let search (search : ListingSearchForm) = let search (search : ListingSearchForm) =
let searches = [ let searches = [
if search.ContinentId <> "" then if search.ContinentId <> "" then
"l.data @> @continent", [ "@continent", Sql.jsonb (mkDoc {| continentId = search.ContinentId |}) ] "l.data @> @continent", [ "@continent", Query.jsonbDocParam {| continentId = search.ContinentId |} ]
if search.Region <> "" then if search.Region <> "" then
"l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ] "l.data ->> 'region' ILIKE @region", [ "@region", like search.Region ]
if search.RemoteWork <> "" then if search.RemoteWork <> "" then
"l.data @> @remote", [ "@remote", Sql.jsonb (mkDoc {| isRemote = search.RemoteWork = "yes" |}) ] "l.data @> @remote", [ "@remote", Query.jsonbDocParam {| isRemote = search.RemoteWork = "yes" |} ]
if search.Text <> "" then if search.Text <> "" then
"l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ] "l.data ->> 'text' ILIKE @text", [ "@text", like search.Text ]
] ]
dataSource () Custom.list<ListingForView>
|> Sql.query $""" $"""{viewSql}
{viewSql} WHERE l.data @> '{{ "isExpired": false }}'::jsonb
WHERE l.data @> '{{ "isExpired": false, "isLegacy": false }}'::jsonb
{searchSql searches}""" {searchSql searches}"""
|> Sql.parameters (searches |> List.collect snd) (searches |> List.collect snd)
|> Sql.executeAsync toListingForView toListingForView

View File

@ -96,7 +96,6 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
CreatedOn = now CreatedOn = now
IsExpired = false IsExpired = false
WasFilledHere = None WasFilledHere = None
IsLegacy = false
} }
| _ -> return! Data.findById (ListingId.ofString form.Id) | _ -> return! Data.findById (ListingId.ofString form.Id)
} }

View File

@ -16,4 +16,8 @@
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" /> <ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
</Project> </Project>

View File

@ -1,5 +1,6 @@
module JobsJobsJobs.Profiles.Data module JobsJobsJobs.Profiles.Data
open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.Profiles.Domain open JobsJobsJobs.Profiles.Domain
@ -7,83 +8,63 @@ open Npgsql.FSharp
/// Count the current profiles /// Count the current profiles
let count () = let count () =
dataSource () Count.all Table.Profile
|> Sql.query
$"""SELECT COUNT(id) AS the_count FROM {Table.Profile} WHERE data @> '{{ "isLegacy": false }}'::jsonb"""
|> Sql.executeRowAsync (fun row -> row.int64 "the_count")
/// Delete a profile by its ID /// Delete a profile by its ID
let deleteById citizenId = backgroundTask { let deleteById citizenId =
let! _ = Delete.byId Table.Profile (CitizenId.toString citizenId)
dataSource ()
|> Sql.query $"DELETE FROM {Table.Profile} WHERE id = @id"
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ]
|> Sql.executeNonQueryAsync
()
}
/// Find a profile by citizen ID /// Find a profile by citizen ID
let findById citizenId = backgroundTask { let findById citizenId =
match! dataSource () |> getDocument<Profile> Table.Profile (CitizenId.toString citizenId) with Find.byId<Profile> Table.Profile (CitizenId.toString citizenId)
| Some profile when not profile.IsLegacy -> return Some profile
| Some _
| None -> return None
}
/// Convert a data row to a profile for viewing /// Convert a data row to a profile for viewing
let private toProfileForView row = let private toProfileForView row =
{ Profile = toDocument<Profile> row { Profile = fromData<Profile> row
Citizen = toDocumentFrom<Citizen> "cit_data" row Citizen = fromDocument<Citizen> "cit_data" row
Continent = toDocumentFrom<Continent> "cont_data" row Continent = fromDocument<Continent> "cont_data" row
} }
/// Find a profile by citizen ID for viewing (includes citizen and continent information) /// Find a profile by citizen ID for viewing (includes citizen and continent information)
let findByIdForView citizenId = backgroundTask { let findByIdForView citizenId =
let! tryCitizen = Custom.single<ProfileForView>
dataSource () $"SELECT p.*, c.data AS cit_data, o.data AS cont_data
|> Sql.query $"""
SELECT p.*, c.data AS cit_data, o.data AS cont_data
FROM {Table.Profile} p FROM {Table.Profile} p
INNER JOIN {Table.Citizen} c ON c.id = p.id INNER JOIN {Table.Citizen} c ON c.id = p.id
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId' INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
WHERE p.id = @id WHERE p.id = @id"
AND p.data @> '{{ "isLegacy": false }}'::jsonb""" [ "@id", Sql.string (CitizenId.toString citizenId) ]
|> Sql.parameters [ "@id", Sql.string (CitizenId.toString citizenId) ] toProfileForView
|> Sql.executeAsync toProfileForView
return List.tryHead tryCitizen
}
/// Save a profile /// Save a profile
let save (profile : Profile) = let save (profile : Profile) =
dataSource () |> saveDocument Table.Profile (CitizenId.toString profile.Id) <| mkDoc profile save Table.Profile (CitizenId.toString profile.Id) profile
/// Search profiles /// Search profiles
let search (search : ProfileSearchForm) isPublic = backgroundTask { let search (search : ProfileSearchForm) isPublic = backgroundTask {
let searches = [ let searches = [
if search.ContinentId <> "" then if search.ContinentId <> "" then
"p.data @> @continent", [ "@continent", Sql.jsonb (mkDoc {| continentId = search.ContinentId |}) ] "p.data @> @continent", [ "@continent", Query.jsonbDocParam {| continentId = search.ContinentId |} ]
if search.RemoteWork <> "" then if search.RemoteWork <> "" then
"p.data @> @remote", [ "@remote", Sql.jsonb (mkDoc {| isRemote = search.RemoteWork = "yes" |}) ] "p.data @> @remote", [ "@remote", Query.jsonbDocParam {| isRemote = search.RemoteWork = "yes" |} ]
if search.Text <> "" then if search.Text <> "" then
"p.text_search @@ plainto_tsquery(@text_search)", [ "@text_search", Sql.string search.Text ] "p.text_search @@ plainto_tsquery(@text_search)", [ "@text_search", Sql.string search.Text ]
] ]
let vizSql = let vizSql =
if isPublic then if isPublic then
sprintf "(p.data @> '%s'::jsonb OR p.data @> '%s'::jsonb)" sprintf "(p.data @> '%s'::jsonb OR p.data @> '%s'::jsonb)"
(mkDoc {| visibility = ProfileVisibility.toString Public |}) (Configuration.serializer().Serialize {| visibility = ProfileVisibility.toString Public |})
(mkDoc {| visibility = ProfileVisibility.toString Anonymous |}) (Configuration.serializer().Serialize {| visibility = ProfileVisibility.toString Anonymous |})
else sprintf "p.data ->> 'visibility' <> '%s'" (ProfileVisibility.toString Hidden) else sprintf "p.data ->> 'visibility' <> '%s'" (ProfileVisibility.toString Hidden)
let! results = let! results =
dataSource () Custom.list<ProfileForView>
|> Sql.query $""" $" SELECT p.*, c.data AS cit_data, o.data AS cont_data
SELECT p.*, c.data AS cit_data, o.data AS cont_data
FROM {Table.Profile} p FROM {Table.Profile} p
INNER JOIN {Table.Citizen} c ON c.id = p.id INNER JOIN {Table.Citizen} c ON c.id = p.id
INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId' INNER JOIN {Table.Continent} o ON o.id = p.data ->> 'continentId'
WHERE p.data @> '{{ "isLegacy": false }}'::jsonb WHERE {vizSql}
AND {vizSql} {searchSql searches}"
{searchSql searches}""" (searches |> List.collect snd)
|> Sql.parameters (searches |> List.collect snd) toProfileForView
|> Sql.executeAsync toProfileForView
return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ()) return results |> List.sortBy (fun pfv -> (Citizen.name pfv.Citizen).ToLowerInvariant ())
} }

View File

@ -15,4 +15,8 @@
<ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" /> <ProjectReference Include="..\Common\JobsJobsJobs.Common.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
</Project> </Project>

View File

@ -1,21 +1,21 @@
module JobsJobsJobs.SuccessStories.Data module JobsJobsJobs.SuccessStories.Data
open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain open JobsJobsJobs.Domain
open JobsJobsJobs.SuccessStories.Domain open JobsJobsJobs.SuccessStories.Domain
open Npgsql.FSharp
// Retrieve all success stories // Retrieve all success stories
let all () = let all () =
dataSource () Custom.list<StoryEntry>
|> Sql.query $" $" SELECT s.*, c.data AS cit_data
SELECT s.*, c.data AS cit_data
FROM {Table.Success} s FROM {Table.Success} s
INNER JOIN {Table.Citizen} c ON c.id = s.data ->> 'citizenId' INNER JOIN {Table.Citizen} c ON c.id = s.data ->> 'citizenId'
ORDER BY s.data ->> 'recordedOn' DESC" ORDER BY s.data ->> 'recordedOn' DESC"
|> Sql.executeAsync (fun row -> []
let success = toDocument<Success> row (fun row ->
let citizen = toDocumentFrom<Citizen> "cit_data" row let success = fromData<Success> row
let citizen = fromDocument<Citizen> "cit_data" row
{ Id = success.Id { Id = success.Id
CitizenId = success.CitizenId CitizenId = success.CitizenId
CitizenName = Citizen.name citizen CitizenName = Citizen.name citizen
@ -26,8 +26,8 @@ let all () =
/// Find a success story by its ID /// Find a success story by its ID
let findById successId = let findById successId =
dataSource () |> getDocument<Success> Table.Success (SuccessId.toString successId) Find.byId<Success> Table.Success (SuccessId.toString successId)
/// Save a success story /// Save a success story
let save (success : Success) = let save (success : Success) =
(dataSource (), mkDoc success) ||> saveDocument Table.Success (SuccessId.toString success.Id) save Table.Success (SuccessId.toString success.Id) success

View File

@ -17,4 +17,8 @@
<ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" /> <ProjectReference Include="..\Profiles\JobsJobsJobs.Profiles.fsproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Update="FSharp.Core" Version="8.0.300" />
</ItemGroup>
</Project> </Project>