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:
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