Skip to content

Commit

Permalink
v 3.1 (#42)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
danieljsummers authored Jul 3, 2023
1 parent f289bee commit a89eff2
Show file tree
Hide file tree
Showing 23 changed files with 254 additions and 552 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ src/**/obj
src/**/appsettings.*.json
src/.vs
src/.idea

.fake
29 changes: 22 additions & 7 deletions src/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
WORKDIR /jjj
COPY ./JobsJobsJobs.sln ./
COPY ./JobsJobsJobs/Directory.Build.props ./JobsJobsJobs/
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

COPY . ./
WORKDIR /jjj/JobsJobsJobs/Server
RUN dotnet publish JobsJobsJobs.Server.csproj -c Release /p:PublishProfile=Properties/PublishProfiles/FolderProfile.xml
WORKDIR /jjj/JobsJobsJobs/Application
RUN dotnet publish -c Release -r linux-x64

FROM mcr.microsoft.com/dotnet/aspnet:5.0
WORKDIR /jjj
COPY --from=build /jjj/JobsJobsJobs/Server/bin/Release/net5.0/linux-x64/publish/ ./
ENTRYPOINT [ "/jjj/JobsJobsJobs.Server" ]
FROM mcr.microsoft.com/dotnet/aspnet:7.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/net7.0/linux-x64/publish/ ./

EXPOSE 80
CMD [ "dotnet", "/app/JobsJobsJobs.Application.dll" ]
1 change: 1 addition & 0 deletions src/JobsJobsJobs/Application/App.fs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type BufferedBodyMiddleware (next : RequestDelegate) =
let main args =

let builder = WebApplication.CreateBuilder args
let _ = builder.Configuration.AddEnvironmentVariables "JJJ_"
let svc = builder.Services

let _ = svc.AddGiraffe ()
Expand Down
6 changes: 5 additions & 1 deletion src/JobsJobsJobs/Application/JobsJobsJobs.Application.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<PublishSingleFile>true</PublishSingleFile>
<PublishSingleFile>false</PublishSingleFile>
<SelfContained>false</SelfContained>
<WarnOn>3390;$(WarnOn)</WarnOn>
</PropertyGroup>
Expand All @@ -25,4 +25,8 @@
<ProjectReference Include="..\SuccessStories\JobsJobsJobs.SuccessStories.fsproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="FSharp.Core" Version="7.0.300" />
</ItemGroup>

</Project>
7 changes: 7 additions & 0 deletions src/JobsJobsJobs/Application/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@
"JobsJobsJobs.Api.Handlers.Citizen": "Information",
"Microsoft.AspNetCore.StaticFiles": "Warning"
}
},
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://0.0.0.0:80"
}
}
}
}
227 changes: 50 additions & 177 deletions src/JobsJobsJobs/Citizens/Data.fs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module JobsJobsJobs.Citizens.Data

open BitBadger.Npgsql.FSharp.Documents
open JobsJobsJobs.Common.Data
open JobsJobsJobs.Domain
open NodaTime
Expand All @@ -11,45 +12,36 @@ let mutable private lastPurge = Instant.MinValue
/// Lock access to the above
let private locker = obj ()

/// Delete a citizen by their ID using the given connection properties
let private doDeleteById citizenId connProps = backgroundTask {
/// Delete a citizen by their ID
let deleteById citizenId = backgroundTask {
let citId = CitizenId.toString citizenId
let! _ =
connProps
|> Sql.query $"
DELETE FROM {Table.Success} WHERE data @> @criteria;
DELETE FROM {Table.Listing} WHERE data @> @criteria;
DELETE FROM {Table.Citizen} WHERE id = @id"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| citizenId = citId |}); "@id", Sql.string citId ]
|> Sql.executeNonQueryAsync
()
do! Custom.nonQuery
$"{Query.Delete.byContains Table.Success};
{Query.Delete.byContains Table.Listing};
{Query.Delete.byId Table.Citizen}"
[ "@criteria", Query.jsonbDocParam {| citizenId = citId |}; "@id", Sql.string citId ]
}

/// Delete a citizen by their ID
let deleteById citizenId =
doDeleteById citizenId (dataSource ())

/// Save a citizen
let private saveCitizen (citizen : Citizen) connProps =
saveDocument Table.Citizen (CitizenId.toString citizen.Id) connProps (mkDoc citizen)
let private saveCitizen (citizen : Citizen) =
save Table.Citizen (CitizenId.toString citizen.Id) citizen

/// Save security information for a citizen
let private saveSecurity (security : SecurityInfo) connProps =
saveDocument Table.SecurityInfo (CitizenId.toString security.Id) connProps (mkDoc security)
let saveSecurityInfo (security : SecurityInfo) =
save Table.SecurityInfo (CitizenId.toString security.Id) security

/// Purge expired tokens
let private purgeExpiredTokens now = backgroundTask {
let connProps = dataSource ()
let! info =
Sql.query $"SELECT * FROM {Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" connProps
|> Sql.executeAsync toDocument<SecurityInfo>
Custom.list $"{Query.selectFromTable Table.SecurityInfo} WHERE data ->> 'tokenExpires' IS NOT NULL" []
fromData<SecurityInfo>
for expired in info |> List.filter (fun it -> it.TokenExpires.Value < now) do
if expired.TokenUsage.Value = "confirm" then
// Unconfirmed account; delete the entire thing
do! doDeleteById expired.Id connProps
do! deleteById expired.Id
else
// 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
Expand All @@ -62,216 +54,97 @@ let private checkForPurge skipCheck =
})

/// Find a citizen by their ID
let findById citizenId = backgroundTask {
match! dataSource () |> getDocument<Citizen> Table.Citizen (CitizenId.toString citizenId) with
| Some c when not c.IsLegacy -> return Some c
| Some _
| None -> return None
}
let findById citizenId =
Find.byId Table.Citizen (CitizenId.toString citizenId)

/// Save a 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
let register citizen (security : SecurityInfo) = backgroundTask {
let connProps = dataSource ()
use conn = Sql.createConnection connProps
use! txn = conn.BeginTransactionAsync ()
let register (citizen : Citizen) (security : SecurityInfo) = backgroundTask {
try
do! saveCitizen citizen connProps
do! saveSecurity security connProps
do! txn.CommitAsync ()
let! _ =
Configuration.dataSource ()
|> 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
with
| :? Npgsql.PostgresException as ex when ex.SqlState = "23505" && ex.ConstraintName = "uk_citizen_email" ->
do! txn.RollbackAsync ()
return false
}

/// Try to find the security information matching a confirmation token
let private tryConfirmToken (token : string) connProps = backgroundTask {
let! tryInfo =
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
}
let private tryConfirmToken (token : string) =
Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token; tokenUsage = "confirm" |}

/// Confirm a citizen's account
let confirmAccount token = backgroundTask {
do! checkForPurge true
let connProps = dataSource ()
match! tryConfirmToken token connProps with
match! tryConfirmToken token with
| Some info ->
do! saveSecurity { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
connProps
do! saveSecurityInfo { info with AccountLocked = false; Token = None; TokenUsage = None; TokenExpires = None }
return true
| None -> return false
}

/// Deny a citizen's account (user-initiated; used if someone used their e-mail address without their consent)
let denyAccount token = backgroundTask {
do! checkForPurge true
let connProps = dataSource ()
match! tryConfirmToken token connProps with
match! tryConfirmToken token with
| Some info ->
do! doDeleteById info.Id connProps
do! deleteById info.Id
return true
| None -> return false
}

/// Attempt a user log on
let tryLogOn email password (pwVerify : Citizen -> string -> bool option) (pwHash : Citizen -> string -> string)
now = backgroundTask {
let tryLogOn (email : string) password (pwVerify : Citizen -> string -> bool option)
(pwHash : Citizen -> string -> string) now = backgroundTask {
do! checkForPurge false
let connProps = dataSource ()
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
match! Find.firstByContains<Citizen> Table.Citizen {| email = email |} with
| Some citizen ->
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 {
match tryInfo with
| Some it -> return it
| None ->
let it = { SecurityInfo.empty with Id = citizen.Id }
do! saveSecurity it connProps
do! saveSecurityInfo it
return it
}
if info.AccountLocked then return Error "Log on unsuccessful (Account Locked)"
else
match pwVerify citizen password with
| Some rehash ->
let hash = if rehash then pwHash citizen password else citizen.PasswordHash
do! saveSecurity { info with FailedLogOnAttempts = 0 } connProps
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash } connProps
do! saveSecurityInfo { info with FailedLogOnAttempts = 0 }
do! saveCitizen { citizen with LastSeenOn = now; PasswordHash = hash }
return Ok { citizen with LastSeenOn = now }
| None ->
let locked = info.FailedLogOnAttempts >= 4
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 ""}"""
| None -> return Error "Log on unsuccessful"
}

/// Try to retrieve a citizen and their security information by their e-mail address
let tryByEmailWithSecurity email = backgroundTask {
let toCitizenSecurityPair row = (toDocument<Citizen> row, toDocumentFrom<SecurityInfo> "sec_data" row)
let! results =
dataSource ()
|> Sql.query $"
SELECT c.*, s.data AS sec_data
FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
WHERE c.data @> @criteria"
|> Sql.parameters [ "@criteria", Sql.jsonb (mkDoc {| email = email |}) ]
|> Sql.executeAsync toCitizenSecurityPair
return List.tryHead results
}

/// Save an updated security information document
let saveSecurityInfo security = backgroundTask {
do! saveSecurity security (dataSource ())
}
let tryByEmailWithSecurity email =
Custom.single
$"SELECT c.*, s.data AS sec_data
FROM {Table.Citizen} c
INNER JOIN {Table.SecurityInfo} s ON s.id = c.id
WHERE c.data @> @criteria"
[ "@criteria", Query.jsonbDocParam {| email = email |} ]
(fun row -> (fromData<Citizen> row, fromDocument<SecurityInfo> "sec_data" row))

/// Try to retrieve security information by the given token
let trySecurityByToken (token : string) = backgroundTask {
do! checkForPurge false
let! results =
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.jsonb 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
return! Find.firstByContains<SecurityInfo> Table.SecurityInfo {| token = token |}
}
Loading

0 comments on commit a89eff2

Please sign in to comment.