From f8420496b20ed1484c023cb568635f4fc8ef5302 Mon Sep 17 00:00:00 2001 From: limiteinductive Date: Sun, 29 Mar 2026 19:18:06 +0200 Subject: [PATCH] S3+S4: user param auth, static file serving, Docker deploy config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace hardcoded benji with ?user= query param - add GET /api/users and GET /api/me?user= endpoints - serve frontend static files via tower-http ServeDir - add multi-stage Dockerfile (Rust + Vite → single image) - add docker-compose.yml + Caddyfile for apes.unslope.com - frontend: getCurrentUsername() from URL param → localStorage - sidebar shows current user Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/colony/src/main.rs | 7 +++ crates/colony/src/routes.rs | 52 +++++++++++++++++++-- infra/colony/Caddyfile | 3 ++ infra/colony/Dockerfile | 29 ++++++++++++ infra/colony/docker-compose.yml | 33 +++++++++++++ ui/colony/src/App.tsx | 2 +- ui/colony/src/api.ts | 32 +++++++++++-- ui/colony/src/components/ChannelSidebar.tsx | 8 +++- 8 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 infra/colony/Caddyfile create mode 100644 infra/colony/Dockerfile create mode 100644 infra/colony/docker-compose.yml diff --git a/crates/colony/src/main.rs b/crates/colony/src/main.rs index 3d40469..38ea7ec 100644 --- a/crates/colony/src/main.rs +++ b/crates/colony/src/main.rs @@ -4,6 +4,7 @@ mod routes; use axum::{routing::get, Router}; use sqlx::sqlite::SqlitePoolOptions; use std::env; +use tower_http::services::{ServeDir, ServeFile}; #[tokio::main] async fn main() { @@ -32,6 +33,8 @@ async fn main() { let app = Router::new() .route("/api/health", get(routes::health)) + .route("/api/users", get(routes::list_users)) + .route("/api/me", get(routes::get_me)) .route( "/api/channels", get(routes::list_channels).post(routes::create_channel), @@ -41,6 +44,10 @@ async fn main() { "/api/channels/{channel_id}/messages", get(routes::list_messages).post(routes::post_message), ) + // Serve frontend static files, fallback to index.html for SPA routing + .fallback_service( + ServeDir::new("static").fallback(ServeFile::new("static/index.html")), + ) .with_state(pool); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) diff --git a/crates/colony/src/routes.rs b/crates/colony/src/routes.rs index da8aaa3..a0c6ffb 100644 --- a/crates/colony/src/routes.rs +++ b/crates/colony/src/routes.rs @@ -45,6 +45,28 @@ impl From for AppError { type Result = std::result::Result; +// ── User identity from ?user= param ── + +#[derive(Debug, serde::Deserialize)] +pub struct UserParam { + pub user: Option, +} + +async fn resolve_user(db: &SqlitePool, param: &UserParam) -> Result { + let username = param.user.as_deref().unwrap_or("benji"); + let row = sqlx::query_scalar::<_, String>( + "SELECT id FROM users WHERE username = ?", + ) + .bind(username) + .fetch_optional(db) + .await?; + + match row { + Some(id) => Ok(id), + None => Err(AppError::BadRequest(format!("Unknown user: {username}"))), + } +} + // ── Health ── pub async fn health() -> &'static str { @@ -62,13 +84,35 @@ pub async fn list_channels(State(db): State) -> Result) -> Result>> { + let rows = sqlx::query_as::<_, UserRow>("SELECT * FROM users ORDER BY created_at") + .fetch_all(&db) + .await?; + Ok(Json(rows.iter().map(|r| r.to_api()).collect())) +} + +pub async fn get_me( + State(db): State, + Query(user_param): Query, +) -> Result> { + let username = user_param.user.as_deref().unwrap_or("benji"); + let row = sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE username = ?") + .bind(username) + .fetch_optional(&db) + .await?; + match row { + Some(r) => Ok(Json(r.to_api())), + None => Err(AppError::NotFound(format!("User {username} not found"))), + } +} + pub async fn create_channel( State(db): State, + Query(user_param): Query, Json(body): Json, ) -> Result { let id = Uuid::new_v4().to_string(); - // Hardcoded to benji for now (no auth yet — S4 will extract from middleware) - let created_by = "00000000-0000-0000-0000-000000000001"; + let created_by = resolve_user(&db, &user_param).await?; sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)") .bind(&id) @@ -150,6 +194,7 @@ pub async fn list_messages( pub async fn post_message( State(db): State, Path(channel_id): Path, + Query(user_param): Query, Json(body): Json, ) -> Result { // Verify channel exists @@ -183,8 +228,7 @@ pub async fn post_message( } let id = Uuid::new_v4().to_string(); - // Hardcoded to benji for now (no auth yet — S4 will extract from middleware) - let user_id = "00000000-0000-0000-0000-000000000001"; + let user_id = resolve_user(&db, &user_param).await?; let msg_type = match body.r#type { MessageType::Text => "text", diff --git a/infra/colony/Caddyfile b/infra/colony/Caddyfile new file mode 100644 index 0000000..886d004 --- /dev/null +++ b/infra/colony/Caddyfile @@ -0,0 +1,3 @@ +apes.unslope.com { + reverse_proxy colony:3001 +} diff --git a/infra/colony/Dockerfile b/infra/colony/Dockerfile new file mode 100644 index 0000000..717848f --- /dev/null +++ b/infra/colony/Dockerfile @@ -0,0 +1,29 @@ +# Stage 1: Build frontend +FROM node:22-slim AS frontend +WORKDIR /app/ui/colony +COPY ui/colony/package.json ui/colony/package-lock.json ./ +RUN npm ci +COPY ui/colony/ . +RUN npm run build + +# Stage 2: Build backend +FROM rust:1.86-slim AS backend +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ +RUN cargo build --release -p colony + +# Stage 3: Runtime +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +COPY --from=backend /app/target/release/colony /app/colony +COPY --from=frontend /app/ui/colony/dist /app/static + +ENV PORT=3001 +ENV DATABASE_URL=sqlite:/data/colony.db?mode=rwc +EXPOSE 3001 + +CMD ["/app/colony"] diff --git a/infra/colony/docker-compose.yml b/infra/colony/docker-compose.yml new file mode 100644 index 0000000..1868e83 --- /dev/null +++ b/infra/colony/docker-compose.yml @@ -0,0 +1,33 @@ +services: + colony: + build: + context: ../.. + dockerfile: infra/colony/Dockerfile + container_name: colony + environment: + - PORT=3001 + - DATABASE_URL=sqlite:/data/colony.db?mode=rwc + volumes: + - colony_data:/data + ports: + - "3001:3001" + restart: always + + caddy: + image: caddy:latest + container_name: colony-caddy + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + restart: always + depends_on: + - colony + +volumes: + colony_data: + caddy_data: + caddy_config: diff --git a/ui/colony/src/App.tsx b/ui/colony/src/App.tsx index 2c935ab..882ee61 100644 --- a/ui/colony/src/App.tsx +++ b/ui/colony/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Channel } from "@/types/Channel"; import type { Message } from "@/types/Message"; -import { getChannels, getMessages } from "@/api"; +import { getChannels, getMessages, getCurrentUsername } from "@/api"; import { ChannelSidebar } from "@/components/ChannelSidebar"; import { MessageItem } from "@/components/MessageItem"; import { ComposeBox } from "@/components/ComposeBox"; diff --git a/ui/colony/src/api.ts b/ui/colony/src/api.ts index 0fe11a7..cec358d 100644 --- a/ui/colony/src/api.ts +++ b/ui/colony/src/api.ts @@ -1,22 +1,46 @@ import type { Channel } from "./types/Channel"; import type { Message } from "./types/Message"; +import type { User } from "./types/User"; import type { CreateChannel } from "./types/CreateChannel"; import type { PostMessage } from "./types/PostMessage"; const BASE = "/api"; +// Get current user from URL param or localStorage +export function getCurrentUsername(): string { + const url = new URL(window.location.href); + const param = url.searchParams.get("user"); + if (param) { + localStorage.setItem("colony_user", param); + return param; + } + return localStorage.getItem("colony_user") || "benji"; +} + +function userQuery(): string { + return `user=${encodeURIComponent(getCurrentUsername())}`; +} + async function json(res: Response): Promise { if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); return res.json(); } +export async function getMe(): Promise { + return json(await fetch(`${BASE}/me?${userQuery()}`)); +} + +export async function getUsers(): Promise { + return json(await fetch(`${BASE}/users`)); +} + export async function getChannels(): Promise { return json(await fetch(`${BASE}/channels`)); } export async function createChannel(body: CreateChannel): Promise { return json( - await fetch(`${BASE}/channels`, { + await fetch(`${BASE}/channels?${userQuery()}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -26,10 +50,10 @@ export async function createChannel(body: CreateChannel): Promise { export async function getMessages( channelId: string, - params?: { since?: string; type?: string; user_id?: string }, + params?: { after_seq?: number; type?: string; user_id?: string }, ): Promise { const query = new URLSearchParams(); - if (params?.since) query.set("since", params.since); + if (params?.after_seq) query.set("after_seq", String(params.after_seq)); if (params?.type) query.set("type", params.type); if (params?.user_id) query.set("user_id", params.user_id); const qs = query.toString(); @@ -43,7 +67,7 @@ export async function postMessage( body: PostMessage, ): Promise { return json( - await fetch(`${BASE}/channels/${channelId}/messages`, { + await fetch(`${BASE}/channels/${channelId}/messages?${userQuery()}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/ui/colony/src/components/ChannelSidebar.tsx b/ui/colony/src/components/ChannelSidebar.tsx index 53007a6..2d60768 100644 --- a/ui/colony/src/components/ChannelSidebar.tsx +++ b/ui/colony/src/components/ChannelSidebar.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import type { Channel } from "@/types/Channel"; -import { createChannel } from "@/api"; +import { createChannel, getCurrentUsername } from "@/api"; interface Props { channels: Channel[]; @@ -61,6 +61,12 @@ export function ChannelSidebar({ ))} + {/* Current user */} +
+ logged in as + {getCurrentUsername()} +
+ {/* New channel input */}