Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .envrc.sample
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on: push
jobs:
test:
runs-on: ubuntu-latest
environment: test
steps:
- uses: actions/checkout@v4

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.envrc
.gren
db/*.db*
dist/app
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion src/Email.gren
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ toString (Email emailString) =
-}
example : Email
example =
Email "a@example.com"
Email "test@blackhole.postmarkapp.com"
73 changes: 50 additions & 23 deletions src/Main.gren
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -28,8 +30,8 @@ main =
}


config : { host : String, port_ : Int }
config =
serverConfig : { host : String, port_ : Int }
serverConfig =
{ host = "0.0.0.0"
, port_ = 3000
}
Expand All @@ -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
}

Expand All @@ -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 =
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions src/Postmark.gren
Original file line number Diff line number Diff line change
@@ -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 (\_ -> {})
17 changes: 9 additions & 8 deletions src/Route/Error.gren
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module Route.Error exposing
( notFound
, noSecureContext
, invalidRequestData
, serverError
)


Expand All @@ -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

54 changes: 52 additions & 2 deletions src/Route/Session.gren
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
6 changes: 3 additions & 3 deletions src/Session.gren
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import User exposing (User)

type alias Session =
{ created : Time.Posix
, userId : Int
, user : User
, emailValidationToken : String
, fetchSessionToken : String
}
Expand All @@ -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
}
Expand All @@ -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
]
Expand Down
Loading