diff --git a/.envrc.sample b/.envrc.sample new file mode 100644 index 0000000..8a918ec --- /dev/null +++ b/.envrc.sample @@ -0,0 +1,12 @@ +#!/bin/bash + +# Automatically sets up devbox environment when you cd into this directory. +# see https://www.jetify.com/docs/devbox/ide_configuration/direnv/ for details. +eval "$(devbox generate direnv --print-envrc)" + +# Set environment variables for local development below. +# Other environments should set these using that environment's configuration. + +# Use POSTMARK_API_TEST if you don't need to send real emails. +# https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark +export POSTMARK_SERVER_TOKEN=POSTMARK_API_TEST diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f967e1..39e007a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: push jobs: test: runs-on: ubuntu-latest + environment: test steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9cd6767..b48e3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.envrc .gren db/*.db* dist/app diff --git a/README.md b/README.md index 8113759..5b0bf7c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ Once task ports land (25S?), switch to sqlite via ports with litestream for back ## Local Development -This project uses [devbox](https://www.jetify.com/devbox). +This project uses [devbox](https://www.jetify.com/devbox) and [direnv](https://direnv.net/). +Install both for the smoothest experience. + +Copy `.envrc.sample` to `.envrc` and set environment variables appropriately. You can run the server with `devbox services up` diff --git a/src/Email.gren b/src/Email.gren index c3097d9..a100e3e 100644 --- a/src/Email.gren +++ b/src/Email.gren @@ -38,4 +38,4 @@ toString (Email emailString) = -} example : Email example = - Email "a@example.com" + Email "test@blackhole.postmarkapp.com" diff --git a/src/Main.gren b/src/Main.gren index b641db5..1a0bfa6 100644 --- a/src/Main.gren +++ b/src/Main.gren @@ -3,6 +3,7 @@ module Main exposing (main) import Bytes exposing (Bytes) import Crypto import Db +import Dict import Email exposing (Email) import Db.Encode import HttpClient @@ -11,6 +12,7 @@ import HttpServer.Response as Response exposing (Response) import Init import Json.Decode import Node exposing (Environment, Program) +import Postmark import Registry.Db import Route.Error import Route.Session @@ -28,8 +30,8 @@ main = } -config : { host : String, port_ : Int } -config = +serverConfig : { host : String, port_ : Int } +serverConfig = { host = "0.0.0.0" , port_ = 3000 } @@ -43,6 +45,7 @@ type alias Model = , stderr : Stream.Writable Bytes , server : Maybe HttpServer.Server , db : Db.Connection + , postmark : Maybe Postmark.Configuration , secureContext : Maybe Crypto.SecureContext } @@ -51,9 +54,20 @@ init : Environment -> Init.Task { model : Model, command : Cmd Msg } init env = Init.await HttpServer.initialize <| \serverPermission -> Init.await HttpClient.initialize <| \httpPerm -> + Init.awaitTask Node.getEnvironmentVariables <| \envVars -> let db = Helper.initDb httpPerm + + postmark = + envVars + |> Dict.get "POSTMARK_SERVER_TOKEN" + |> Maybe.map + (\token -> + { httpPermission = httpPerm + , apiToken = token + } + ) in Node.startProgram { model = @@ -62,12 +76,13 @@ init env = , server = Nothing , db = db , secureContext = Nothing + , postmark = postmark } , command = Cmd.batch [ Registry.Db.migrate db |> Task.attempt DbMigrationResult - , HttpServer.createServer serverPermission config + , HttpServer.createServer serverPermission serverConfig |> Task.attempt CreateServerResult , Crypto.getSecureContext |> Task.attempt SecureContextResult @@ -94,7 +109,7 @@ update msg model = Ok server -> { model = { model | server = Just server } , command = - "Server started: http://" ++ config.host ++ ":" ++ String.fromInt config.port_ + "Server started: http://" ++ serverConfig.host ++ ":" ++ String.fromInt serverConfig.port_ |> print model.stdout |> Task.execute } @@ -161,27 +176,39 @@ route model request response = request.url.path |> String.split "/" |> Array.keepIf (\s -> s /= "") + + config = + { secureContext = model.secureContext + , postmark = model.postmark + } in - when { method = request.method, path = path, secureContext = model.secureContext } is + when config is { secureContext = Nothing } -> - Route.Error.noSecureContext response - - { method = POST, path = [ "session" ], secureContext = Just secureContext } -> - when getEmail request.body is - Just email -> - Route.Session.create - { db = model.db - , secureContext = secureContext - , requestData = { email = email } - , response = response - } - - Nothing -> - Route.Error.invalidRequestData response - "Request json did not contain a valid `email` field." - - _ -> - Route.Error.notFound response + Route.Error.serverError response "Missing secure context." + + { postmark = Nothing } -> + Route.Error.serverError response "Missing postmark config." + + { postmark = Just postmark, secureContext = Just secureContext } -> + when { method = request.method, path = path } is + + { method = POST, path = [ "session" ] } -> + when getEmail request.body is + Just email -> + Route.Session.create + { db = model.db + , postmark = postmark + , secureContext = secureContext + , requestData = { email = email } + , response = response + } + + Nothing -> + Route.Error.invalidRequestData response + "Request json did not contain a valid `email` field." + + _ -> + Route.Error.notFound response getEmail : Bytes -> Maybe Email diff --git a/src/Postmark.gren b/src/Postmark.gren new file mode 100644 index 0000000..f9249e2 --- /dev/null +++ b/src/Postmark.gren @@ -0,0 +1,48 @@ +module Postmark exposing (Configuration, send) + + +import HttpClient as Http +import Json.Encode +import Task exposing (Task) + + +type alias Configuration = + { apiToken : String + , httpPermission : Http.Permission + } + + +url : String +url = + "https://api.postmarkapp.com/email" + + +-- Might not want to always hardcode this. But for now we're only using this for +-- email validation. +from : String +from = + "no-reply@gren-lang.org" + + +send : + Configuration + -> { to : String, subject : String, textBody : String } + -> Task Http.Error {} +send config { to, subject, textBody } = + let + body = + Json.Encode.object + [ { key = "From", value = Json.Encode.string from } + , { key = "To", value = Json.Encode.string to } + , { key = "Subject", value = Json.Encode.string subject } + , { key = "TextBody", value = Json.Encode.string textBody } + , { key = "MessageStream", value = Json.Encode.string "outbound" } + ] + in + Http.post url + |> Http.withHeader "Accept" "application/json" + |> Http.withHeader "Content-Type" "application/json" + |> Http.withHeader "X-Postmark-Server-Token" config.apiToken + |> Http.withJsonBody body + |> Http.send config.httpPermission + |> Task.map (\_ -> {}) diff --git a/src/Route/Error.gren b/src/Route/Error.gren index 7a2128c..64d3e5c 100644 --- a/src/Route/Error.gren +++ b/src/Route/Error.gren @@ -1,7 +1,7 @@ module Route.Error exposing ( notFound - , noSecureContext , invalidRequestData + , serverError ) @@ -17,17 +17,18 @@ notFound response = |> Task.succeed -noSecureContext : Response -> Task Never Response -noSecureContext response = +invalidRequestData : Response -> String -> Task Never Response +invalidRequestData response message = response - |> Response.setStatus 500 - |> Response.setBody "Missing secure context." + |> Response.setStatus 400 + |> Response.setBody message |> Task.succeed -invalidRequestData : Response -> String -> Task Never Response -invalidRequestData response message = +serverError : Response -> String -> Task Never Response +serverError response message = response - |> Response.setStatus 400 + |> Response.setStatus 500 |> Response.setBody message |> Task.succeed + diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 73b94bd..3c41c36 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -7,8 +7,10 @@ import Bytes exposing (Bytes) import Crypto import Db import Email exposing (Email) +import HttpClient import HttpServer exposing (Request) import HttpServer.Response as Response exposing (Response) +import Postmark import Json.Decode import Json.Encode import Session exposing (Session) @@ -18,22 +20,31 @@ import User exposing (User) type Error = DbError Db.Error + | SendEmailFailed HttpClient.Error + + +-- ENDPOINTS create : { db : Db.Connection + , postmark : Postmark.Configuration , secureContext : Crypto.SecureContext , requestData : { email : Email } , response : Response } -> Task Never Response -create { db, secureContext, requestData, response } = +create { db, secureContext, postmark, requestData, response } = findOrCreateUser db requestData.email |> Task.andThen (createSession db secureContext ) + |> Task.andThen (sendEmailValidationLink postmark) |> Task.map (createSuccess response) |> Task.onError (createFailed response) +-- ACTIONS + + findOrCreateUser : Db.Connection -> Email -> Task Error User findOrCreateUser db email = User.findOrCreate db email @@ -46,6 +57,21 @@ createSession db secureContext user = |> Task.mapError DbError +sendEmailValidationLink : Postmark.Configuration -> Session -> Task Error Session +sendEmailValidationLink postmarkConfig session = + { to = session.user.email |> Email.toString + , subject = "Gren: Confirm your email address" + , textBody = + "TODO: link with this token: " ++ session.emailValidationToken + } + |> Postmark.send postmarkConfig + |> Task.mapError SendEmailFailed + |> Task.map (\_ -> session) + + +-- RESPONSES + + createSuccess : Response -> Session -> Response createSuccess response session = let @@ -65,7 +91,31 @@ createFailed : Response -> Error -> Task x Response createFailed response error = when error is DbError e -> + -- TODO: log the actual error response |> Response.setStatus 500 - -- TODO: helpful error message in json body + |> setErrorMessage "Database error" |> Task.succeed + + + SendEmailFailed e -> + -- TODO: log the actual error + response + |> Response.setStatus 500 + |> setErrorMessage "Failed to send email" + |> Task.succeed + + +-- HELPERS + + +setErrorMessage : String -> Response -> Response +setErrorMessage message response = + let + body = + Json.Encode.object + [ { key = "message", value = Json.Encode.string message } ] + in + response + |> Response.setHeader "Content-Type" "application/json" + |> Response.setBody (Json.Encode.encode 0 body) diff --git a/src/Session.gren b/src/Session.gren index cf1d6fb..fb49c7c 100644 --- a/src/Session.gren +++ b/src/Session.gren @@ -15,7 +15,7 @@ import User exposing (User) type alias Session = { created : Time.Posix - , userId : Int + , user : User , emailValidationToken : String , fetchSessionToken : String } @@ -33,7 +33,7 @@ create { db, user, secureContext } = Task.await Time.now <| \now -> dbInsert db { created = now - , userId = user.id + , user = user , emailValidationToken = uuid1 , fetchSessionToken = uuid2 } @@ -50,7 +50,7 @@ dbInsert db session = """ , parameters = [ Db.Encode.posix "created" session.created - , Db.Encode.int "user_id" session.userId + , Db.Encode.int "user_id" session.user.id , Db.Encode.string "email_validation_token" session.emailValidationToken , Db.Encode.string "fetch_session_token" session.fetchSessionToken ] diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index 48af396..ba91c96 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -1,11 +1,15 @@ module Test.E2E exposing (tests) import Crypto +import Dict import Expect import HttpClient +import Node +import Task import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test) import Test.E2E.Helper exposing (get, expectBadStatus, initDb) import Test.E2E.Route.Session +import Test.E2E.Postmark import Test.E2E.Session import Test.E2E.User @@ -15,12 +19,19 @@ tests httpPerm = let db = initDb httpPerm + + postmark = + -- https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark + { apiToken = "POSTMARK_API_TEST" + , httpPermission = httpPerm + } in [ await "Get secure context" Crypto.getSecureContext <| \secureContext -> concat [ describe "Session route tests" (Test.E2E.Route.Session.tests httpPerm) , describe "Session module tests" (Test.E2E.Session.tests db secureContext) , describe "User module tests" (Test.E2E.User.tests db) + , describe "Postmark module tests" (Test.E2E.Postmark.tests postmark) , describe "Home route tests" -- There is no Home route yet [ awaitError "GET /" (get httpPerm "/") <| \response -> diff --git a/src/Test/E2E/Postmark.gren b/src/Test/E2E/Postmark.gren new file mode 100644 index 0000000..318cd26 --- /dev/null +++ b/src/Test/E2E/Postmark.gren @@ -0,0 +1,24 @@ +module Test.E2E.Postmark exposing (tests) + + +import Email +import Expect +import HttpClient +import Postmark +import Test.Runner.Effectful exposing (Test, await, awaitError, describe, test) + + +tests : Postmark.Configuration -> Array Test +tests postmark = + [ await "Postmark.send" + (Postmark.send postmark + { to = Email.example |> Email.toString + , subject = "postmark test" + , textBody = "postmark test body" + } + ) + (\result -> + test "Succeeds" <| \_ -> + Expect.equal {} result + ) + ] diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index 9a002f4..bb3cfa9 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -38,7 +38,7 @@ tests httpPerm = , describe "Create session" <| let goodEmail = - "abc@example.com" + Email.example |> Email.toString withNoEmail = post httpPerm "/session" diff --git a/src/Test/E2E/Session.gren b/src/Test/E2E/Session.gren index 32da64e..d1027ce 100644 --- a/src/Test/E2E/Session.gren +++ b/src/Test/E2E/Session.gren @@ -37,7 +37,7 @@ tests db secureContext = [ test "Session is created" <| \_ -> Expect.equal (countBeforeCreate + 1) countAfterCreate , test "Session is created for user" <| \_ -> - Expect.equal user.id session.userId + Expect.equal user session.user ] ] ]