diff --git a/README.md b/README.md index 0961e62..2b6a5b4 100644 --- a/README.md +++ b/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. +## 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": ""} +``` + +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 - `docker-compose.yml` — services, volume, Traefik labels, init sidecar +- `signup/` — Gleam signup service (Dockerfile, gleam.toml, src/) diff --git a/docker-compose.yml b/docker-compose.yml index e0af07f..982e0a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,33 @@ services: put_config cors methods 'GET, PUT, POST, HEAD, DELETE' 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: coolify: external: true diff --git a/signup/.dockerignore b/signup/.dockerignore new file mode 100644 index 0000000..ce73946 --- /dev/null +++ b/signup/.dockerignore @@ -0,0 +1,3 @@ +build/ +.git/ +*.beam diff --git a/signup/Dockerfile b/signup/Dockerfile new file mode 100644 index 0000000..d20b985 --- /dev/null +++ b/signup/Dockerfile @@ -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"] diff --git a/signup/gleam.toml b/signup/gleam.toml new file mode 100644 index 0000000..9214b20 --- /dev/null +++ b/signup/gleam.toml @@ -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" diff --git a/signup/src/coolcouch_signup.gleam b/signup/src/coolcouch_signup.gleam new file mode 100644 index 0000000..f6411de --- /dev/null +++ b/signup/src/coolcouch_signup.gleam @@ -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(<>, 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) +}