feat(signup): add Gleam signup service gated by shared invite code
A small Gleam HTTP server in signup/ exposes POST /signup that validates a shared INVITE_CODE env var, then PUTs a user document to CouchDB's _users database using the existing admin credentials. The frontend gets back 201 + the username and is expected to call /_session itself to obtain the AuthSession cookie (chosen over server-side cookie forwarding because vaquant.at and couch.apoena.dev are different sites and the cross-site cookie path is fragile under modern browser privacy modes). Wired into docker-compose as a second service, reachable on signup-couch.apoena.dev with its own Traefik labels and the same hardcoded UUID network attachment as couchdb. Reuses COUCHDB_USER / COUCHDB_PASSWORD for the admin call. INVITE_CODE must be set in Coolify env vars.
This commit is contained in:
33
README.md
33
README.md
@@ -52,6 +52,39 @@ They're hardcoded because Coolify does not interpolate `${...}` inside the `labe
|
|||||||
|
|
||||||
A `sed` swap before push, or a Coolify "Custom Build Command" running `sed -i` over the file, is the simplest way to keep this template portable.
|
A `sed` swap before push, or a Coolify "Custom Build Command" running `sed -i` over the file, is the simplest way to keep this template portable.
|
||||||
|
|
||||||
|
## Signup service
|
||||||
|
|
||||||
|
A small Gleam HTTP service in `signup/` exposes one endpoint that creates CouchDB users gated by a shared invite code. Lives on `signup-couch.apoena.dev`.
|
||||||
|
|
||||||
|
### Env vars (set in Coolify UI)
|
||||||
|
|
||||||
|
- `INVITE_CODE` — required. The shared code users must present to sign up.
|
||||||
|
|
||||||
|
The signup service reuses `COUCHDB_USER` / `COUCHDB_PASSWORD` for its CouchDB admin call (already required by the couchdb service).
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://signup-couch.apoena.dev/signup
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"username": "julien", "password": "at-least-8-chars", "invite_code": "<INVITE_CODE>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
- `201 {"ok": true, "username": "..."}` — user created
|
||||||
|
- `400 {"error": "invalid_payload" | "invalid_username" | "password_too_short"}`
|
||||||
|
- `401 {"error": "invalid_invite_code"}`
|
||||||
|
- `409 {"error": "user_exists"}`
|
||||||
|
- `502 {"error": "couchdb_unreachable" | "couchdb_error"}`
|
||||||
|
|
||||||
|
The frontend should then `POST https://couch.apoena.dev/_session` with the same credentials to obtain the AuthSession cookie.
|
||||||
|
|
||||||
|
### CORS
|
||||||
|
|
||||||
|
`SIGNUP_ALLOWED_ORIGINS` (already wired in `docker-compose.yml`) holds the comma-separated origin allow-list. Edit it there or override via Coolify env vars.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `docker-compose.yml` — services, volume, Traefik labels, init sidecar
|
- `docker-compose.yml` — services, volume, Traefik labels, init sidecar
|
||||||
|
- `signup/` — Gleam signup service (Dockerfile, gleam.toml, src/)
|
||||||
|
|||||||
@@ -65,6 +65,33 @@ services:
|
|||||||
put_config cors methods 'GET, PUT, POST, HEAD, DELETE'
|
put_config cors methods 'GET, PUT, POST, HEAD, DELETE'
|
||||||
put_config cors headers 'accept, authorization, content-type, origin, referer, x-csrf-token'
|
put_config cors headers 'accept, authorization, content-type, origin, referer, x-csrf-token'
|
||||||
|
|
||||||
|
signup:
|
||||||
|
build:
|
||||||
|
context: ./signup
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- couchdb
|
||||||
|
environment:
|
||||||
|
SERVICE_FQDN_SIGNUP_8080:
|
||||||
|
COUCHDB_URL: http://couchdb:5984
|
||||||
|
COUCHDB_ADMIN_USER: ${COUCHDB_USER:-admin}
|
||||||
|
COUCHDB_ADMIN_PASSWORD: ${COUCHDB_PASSWORD}
|
||||||
|
INVITE_CODE: ${INVITE_CODE}
|
||||||
|
SIGNUP_ALLOWED_ORIGINS: "http://localhost:8080,http://localhost:5173,http://localhost:3000,http://localhost:4173,https://vaquant.at"
|
||||||
|
PORT: "8080"
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- coolify
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=lvw8efvnvsxduodrkg68zul3"
|
||||||
|
- "traefik.http.routers.coolcouch-signup.rule=Host(`signup-couch.apoena.dev`)"
|
||||||
|
- "traefik.http.routers.coolcouch-signup.entrypoints=https"
|
||||||
|
- "traefik.http.routers.coolcouch-signup.tls=true"
|
||||||
|
- "traefik.http.services.coolcouch-signup.loadbalancer.server.port=8080"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
3
signup/.dockerignore
Normal file
3
signup/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
.git/
|
||||||
|
*.beam
|
||||||
13
signup/Dockerfile
Normal file
13
signup/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM ghcr.io/gleam-lang/gleam:v1.7.0-erlang-alpine AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY gleam.toml ./
|
||||||
|
RUN gleam deps download
|
||||||
|
COPY src ./src
|
||||||
|
RUN gleam export erlang-shipment
|
||||||
|
|
||||||
|
FROM erlang:27-alpine
|
||||||
|
RUN apk add --no-cache openssl-dev
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/build/erlang-shipment .
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh", "run"]
|
||||||
13
signup/gleam.toml
Normal file
13
signup/gleam.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name = "coolcouch_signup"
|
||||||
|
version = "0.1.0"
|
||||||
|
target = "erlang"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
gleam_stdlib = ">= 0.59.0 and < 2.0.0"
|
||||||
|
gleam_http = ">= 4.0.0 and < 5.0.0"
|
||||||
|
gleam_httpc = ">= 4.0.0 and < 5.0.0"
|
||||||
|
gleam_json = ">= 3.0.0 and < 4.0.0"
|
||||||
|
gleam_erlang = ">= 1.0.0 and < 2.0.0"
|
||||||
|
envoy = ">= 1.0.0 and < 2.0.0"
|
||||||
|
mist = ">= 4.0.0 and < 6.0.0"
|
||||||
|
wisp = ">= 1.6.0 and < 2.0.0"
|
||||||
235
signup/src/coolcouch_signup.gleam
Normal file
235
signup/src/coolcouch_signup.gleam
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import envoy
|
||||||
|
import gleam/bit_array
|
||||||
|
import gleam/dynamic/decode
|
||||||
|
import gleam/erlang/process
|
||||||
|
import gleam/http.{Get, Options, Post}
|
||||||
|
import gleam/http/request
|
||||||
|
import gleam/http/response
|
||||||
|
import gleam/httpc
|
||||||
|
import gleam/int
|
||||||
|
import gleam/io
|
||||||
|
import gleam/json
|
||||||
|
import gleam/list
|
||||||
|
import gleam/option.{type Option, None, Some}
|
||||||
|
import gleam/result
|
||||||
|
import gleam/string
|
||||||
|
import mist
|
||||||
|
import wisp.{type Request, type Response}
|
||||||
|
import wisp/wisp_mist
|
||||||
|
|
||||||
|
pub type Config {
|
||||||
|
Config(
|
||||||
|
couchdb_url: String,
|
||||||
|
admin_user: String,
|
||||||
|
admin_password: String,
|
||||||
|
invite_code: String,
|
||||||
|
allowed_origins: List(String),
|
||||||
|
port: Int,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SignupBody {
|
||||||
|
SignupBody(username: String, password: String, invite_code: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main() {
|
||||||
|
wisp.configure_logger()
|
||||||
|
let config = case load_config() {
|
||||||
|
Ok(c) -> c
|
||||||
|
Error(msg) -> {
|
||||||
|
io.println_error("config error: " <> msg)
|
||||||
|
panic as "missing required configuration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let secret = wisp.random_string(64)
|
||||||
|
let assert Ok(_) =
|
||||||
|
wisp_mist.handler(fn(req) { handle_request(req, config) }, secret)
|
||||||
|
|> mist.new
|
||||||
|
|> mist.port(config.port)
|
||||||
|
|> mist.bind("0.0.0.0")
|
||||||
|
|> mist.start
|
||||||
|
io.println("coolcouch-signup listening on " <> int.to_string(config.port))
|
||||||
|
process.sleep_forever()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config() -> Result(Config, String) {
|
||||||
|
use admin_password <- result.try(
|
||||||
|
envoy.get("COUCHDB_ADMIN_PASSWORD")
|
||||||
|
|> result.replace_error("missing COUCHDB_ADMIN_PASSWORD"),
|
||||||
|
)
|
||||||
|
use invite_code <- result.try(
|
||||||
|
envoy.get("INVITE_CODE")
|
||||||
|
|> result.replace_error("missing INVITE_CODE"),
|
||||||
|
)
|
||||||
|
let couchdb_url =
|
||||||
|
envoy.get("COUCHDB_URL") |> result.unwrap("http://couchdb:5984")
|
||||||
|
let admin_user = envoy.get("COUCHDB_ADMIN_USER") |> result.unwrap("admin")
|
||||||
|
let allowed_origins =
|
||||||
|
envoy.get("SIGNUP_ALLOWED_ORIGINS")
|
||||||
|
|> result.unwrap("")
|
||||||
|
|> string.split(",")
|
||||||
|
|> list.map(string.trim)
|
||||||
|
|> list.filter(fn(s) { s != "" })
|
||||||
|
let port =
|
||||||
|
envoy.get("PORT")
|
||||||
|
|> result.try(fn(p) {
|
||||||
|
int.parse(p) |> result.replace_error(Nil)
|
||||||
|
})
|
||||||
|
|> result.unwrap(8080)
|
||||||
|
Ok(Config(
|
||||||
|
couchdb_url: couchdb_url,
|
||||||
|
admin_user: admin_user,
|
||||||
|
admin_password: admin_password,
|
||||||
|
invite_code: invite_code,
|
||||||
|
allowed_origins: allowed_origins,
|
||||||
|
port: port,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_request(req: Request, config: Config) -> Response {
|
||||||
|
let allow_origin = case request.get_header(req, "origin") {
|
||||||
|
Ok(origin) ->
|
||||||
|
case list.contains(config.allowed_origins, origin) {
|
||||||
|
True -> Some(origin)
|
||||||
|
False -> None
|
||||||
|
}
|
||||||
|
Error(_) -> None
|
||||||
|
}
|
||||||
|
case req.method, wisp.path_segments(req) {
|
||||||
|
Options, _ -> preflight(allow_origin)
|
||||||
|
Get, ["health"] -> with_cors(wisp.ok(), allow_origin)
|
||||||
|
Post, ["signup"] -> with_cors(handle_signup(req, config), allow_origin)
|
||||||
|
_, _ -> with_cors(wisp.not_found(), allow_origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preflight(allow_origin: Option(String)) -> Response {
|
||||||
|
let base =
|
||||||
|
wisp.response(204)
|
||||||
|
|> wisp.set_header("access-control-allow-methods", "POST, OPTIONS")
|
||||||
|
|> wisp.set_header("access-control-allow-headers", "content-type")
|
||||||
|
|> wisp.set_header("access-control-max-age", "600")
|
||||||
|
|> wisp.set_header("vary", "Origin")
|
||||||
|
case allow_origin {
|
||||||
|
Some(origin) -> wisp.set_header(base, "access-control-allow-origin", origin)
|
||||||
|
None -> base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_cors(resp: Response, allow_origin: Option(String)) -> Response {
|
||||||
|
case allow_origin {
|
||||||
|
Some(origin) ->
|
||||||
|
resp
|
||||||
|
|> wisp.set_header("access-control-allow-origin", origin)
|
||||||
|
|> wisp.set_header("vary", "Origin")
|
||||||
|
None -> resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_signup(req: Request, config: Config) -> Response {
|
||||||
|
use body <- wisp.require_json(req)
|
||||||
|
let decoder = {
|
||||||
|
use username <- decode.field("username", decode.string)
|
||||||
|
use password <- decode.field("password", decode.string)
|
||||||
|
use invite_code <- decode.field("invite_code", decode.string)
|
||||||
|
decode.success(SignupBody(username:, password:, invite_code:))
|
||||||
|
}
|
||||||
|
case decode.run(body, decoder) {
|
||||||
|
Error(_) -> error_json(400, "invalid_payload")
|
||||||
|
Ok(SignupBody(username, password, invite_code)) ->
|
||||||
|
validate_and_create(config, username, password, invite_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_and_create(
|
||||||
|
config: Config,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
invite_code: String,
|
||||||
|
) -> Response {
|
||||||
|
case invite_code == config.invite_code {
|
||||||
|
False -> error_json(401, "invalid_invite_code")
|
||||||
|
True ->
|
||||||
|
case valid_username(username), string.length(password) >= 8 {
|
||||||
|
False, _ -> error_json(400, "invalid_username")
|
||||||
|
_, False -> error_json(400, "password_too_short")
|
||||||
|
True, True -> create_user(config, username, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_username(name: String) -> Bool {
|
||||||
|
let len = string.length(name)
|
||||||
|
len >= 1 && len <= 64 && string.to_graphemes(name) |> list.all(allowed_char)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_char(c: String) -> Bool {
|
||||||
|
case c {
|
||||||
|
"-" | "_" | "." -> True
|
||||||
|
_ ->
|
||||||
|
// letters and digits only (basic ASCII range)
|
||||||
|
case string.to_utf_codepoints(c) {
|
||||||
|
[cp] -> {
|
||||||
|
let n = string.utf_codepoint_to_int(cp)
|
||||||
|
{ n >= 0x30 && n <= 0x39 }
|
||||||
|
|| { n >= 0x41 && n <= 0x5A }
|
||||||
|
|| { n >= 0x61 && n <= 0x7A }
|
||||||
|
}
|
||||||
|
_ -> False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_user(config: Config, username: String, password: String) -> Response {
|
||||||
|
let doc =
|
||||||
|
json.object([
|
||||||
|
#("_id", json.string("org.couchdb.user:" <> username)),
|
||||||
|
#("name", json.string(username)),
|
||||||
|
#("type", json.string("user")),
|
||||||
|
#("roles", json.array([], json.string)),
|
||||||
|
#("password", json.string(password)),
|
||||||
|
])
|
||||||
|
|> json.to_string
|
||||||
|
let url =
|
||||||
|
config.couchdb_url <> "/_users/org.couchdb.user:" <> username
|
||||||
|
let assert Ok(base_req) = request.to(url)
|
||||||
|
let req =
|
||||||
|
base_req
|
||||||
|
|> request.set_method(http.Put)
|
||||||
|
|> request.set_header("content-type", "application/json")
|
||||||
|
|> request.set_header("authorization", basic_auth(
|
||||||
|
config.admin_user,
|
||||||
|
config.admin_password,
|
||||||
|
))
|
||||||
|
|> request.set_body(doc)
|
||||||
|
case httpc.send(req) {
|
||||||
|
Error(_) -> error_json(502, "couchdb_unreachable")
|
||||||
|
Ok(resp) ->
|
||||||
|
case resp.status {
|
||||||
|
201 | 202 ->
|
||||||
|
json_response(
|
||||||
|
201,
|
||||||
|
json.object([
|
||||||
|
#("ok", json.bool(True)),
|
||||||
|
#("username", json.string(username)),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
409 -> error_json(409, "user_exists")
|
||||||
|
401 | 403 -> error_json(500, "admin_auth_failed")
|
||||||
|
_ -> error_json(502, "couchdb_error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn basic_auth(user: String, password: String) -> String {
|
||||||
|
let creds = user <> ":" <> password
|
||||||
|
"Basic " <> bit_array.base64_encode(<<creds:utf8>>, True)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_json(status: Int, code: String) -> Response {
|
||||||
|
json_response(status, json.object([#("error", json.string(code))]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(status: Int, body: json.Json) -> Response {
|
||||||
|
wisp.json_response(json.to_string_tree(body), status)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user