S3+S4: user param auth, static file serving, Docker deploy config
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ mod routes;
|
|||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Router};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -32,6 +33,8 @@ async fn main() {
|
|||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(routes::health))
|
.route("/api/health", get(routes::health))
|
||||||
|
.route("/api/users", get(routes::list_users))
|
||||||
|
.route("/api/me", get(routes::get_me))
|
||||||
.route(
|
.route(
|
||||||
"/api/channels",
|
"/api/channels",
|
||||||
get(routes::list_channels).post(routes::create_channel),
|
get(routes::list_channels).post(routes::create_channel),
|
||||||
@@ -41,6 +44,10 @@ async fn main() {
|
|||||||
"/api/channels/{channel_id}/messages",
|
"/api/channels/{channel_id}/messages",
|
||||||
get(routes::list_messages).post(routes::post_message),
|
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);
|
.with_state(pool);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
|
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ impl From<sqlx::Error> for AppError {
|
|||||||
|
|
||||||
type Result<T> = std::result::Result<T, AppError>;
|
type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
|
// ── User identity from ?user= param ──
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct UserParam {
|
||||||
|
pub user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_user(db: &SqlitePool, param: &UserParam) -> Result<String> {
|
||||||
|
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 ──
|
// ── Health ──
|
||||||
|
|
||||||
pub async fn health() -> &'static str {
|
pub async fn health() -> &'static str {
|
||||||
@@ -62,13 +84,35 @@ pub async fn list_channels(State(db): State<SqlitePool>) -> Result<Json<Vec<Chan
|
|||||||
Ok(Json(channels))
|
Ok(Json(channels))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_users(State(db): State<SqlitePool>) -> Result<Json<Vec<User>>> {
|
||||||
|
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<SqlitePool>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
|
) -> Result<Json<User>> {
|
||||||
|
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(
|
pub async fn create_channel(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
Json(body): Json<CreateChannel>,
|
Json(body): Json<CreateChannel>,
|
||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
// Hardcoded to benji for now (no auth yet — S4 will extract from middleware)
|
let created_by = resolve_user(&db, &user_param).await?;
|
||||||
let created_by = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
|
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
@@ -150,6 +194,7 @@ pub async fn list_messages(
|
|||||||
pub async fn post_message(
|
pub async fn post_message(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Path(channel_id): Path<String>,
|
Path(channel_id): Path<String>,
|
||||||
|
Query(user_param): Query<UserParam>,
|
||||||
Json(body): Json<PostMessage>,
|
Json(body): Json<PostMessage>,
|
||||||
) -> Result<impl IntoResponse> {
|
) -> Result<impl IntoResponse> {
|
||||||
// Verify channel exists
|
// Verify channel exists
|
||||||
@@ -183,8 +228,7 @@ pub async fn post_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
// Hardcoded to benji for now (no auth yet — S4 will extract from middleware)
|
let user_id = resolve_user(&db, &user_param).await?;
|
||||||
let user_id = "00000000-0000-0000-0000-000000000001";
|
|
||||||
|
|
||||||
let msg_type = match body.r#type {
|
let msg_type = match body.r#type {
|
||||||
MessageType::Text => "text",
|
MessageType::Text => "text",
|
||||||
|
|||||||
3
infra/colony/Caddyfile
Normal file
3
infra/colony/Caddyfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
apes.unslope.com {
|
||||||
|
reverse_proxy colony:3001
|
||||||
|
}
|
||||||
29
infra/colony/Dockerfile
Normal file
29
infra/colony/Dockerfile
Normal file
@@ -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"]
|
||||||
33
infra/colony/docker-compose.yml
Normal file
33
infra/colony/docker-compose.yml
Normal file
@@ -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:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Channel } from "@/types/Channel";
|
import type { Channel } from "@/types/Channel";
|
||||||
import type { Message } from "@/types/Message";
|
import type { Message } from "@/types/Message";
|
||||||
import { getChannels, getMessages } from "@/api";
|
import { getChannels, getMessages, getCurrentUsername } from "@/api";
|
||||||
import { ChannelSidebar } from "@/components/ChannelSidebar";
|
import { ChannelSidebar } from "@/components/ChannelSidebar";
|
||||||
import { MessageItem } from "@/components/MessageItem";
|
import { MessageItem } from "@/components/MessageItem";
|
||||||
import { ComposeBox } from "@/components/ComposeBox";
|
import { ComposeBox } from "@/components/ComposeBox";
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
import type { Channel } from "./types/Channel";
|
import type { Channel } from "./types/Channel";
|
||||||
import type { Message } from "./types/Message";
|
import type { Message } from "./types/Message";
|
||||||
|
import type { User } from "./types/User";
|
||||||
import type { CreateChannel } from "./types/CreateChannel";
|
import type { CreateChannel } from "./types/CreateChannel";
|
||||||
import type { PostMessage } from "./types/PostMessage";
|
import type { PostMessage } from "./types/PostMessage";
|
||||||
|
|
||||||
const BASE = "/api";
|
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<T>(res: Response): Promise<T> {
|
async function json<T>(res: Response): Promise<T> {
|
||||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<User> {
|
||||||
|
return json(await fetch(`${BASE}/me?${userQuery()}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsers(): Promise<User[]> {
|
||||||
|
return json(await fetch(`${BASE}/users`));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getChannels(): Promise<Channel[]> {
|
export async function getChannels(): Promise<Channel[]> {
|
||||||
return json(await fetch(`${BASE}/channels`));
|
return json(await fetch(`${BASE}/channels`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createChannel(body: CreateChannel): Promise<Channel> {
|
export async function createChannel(body: CreateChannel): Promise<Channel> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BASE}/channels`, {
|
await fetch(`${BASE}/channels?${userQuery()}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -26,10 +50,10 @@ export async function createChannel(body: CreateChannel): Promise<Channel> {
|
|||||||
|
|
||||||
export async function getMessages(
|
export async function getMessages(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
params?: { since?: string; type?: string; user_id?: string },
|
params?: { after_seq?: number; type?: string; user_id?: string },
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
const query = new URLSearchParams();
|
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?.type) query.set("type", params.type);
|
||||||
if (params?.user_id) query.set("user_id", params.user_id);
|
if (params?.user_id) query.set("user_id", params.user_id);
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
@@ -43,7 +67,7 @@ export async function postMessage(
|
|||||||
body: PostMessage,
|
body: PostMessage,
|
||||||
): Promise<Message> {
|
): Promise<Message> {
|
||||||
return json(
|
return json(
|
||||||
await fetch(`${BASE}/channels/${channelId}/messages`, {
|
await fetch(`${BASE}/channels/${channelId}/messages?${userQuery()}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Channel } from "@/types/Channel";
|
import type { Channel } from "@/types/Channel";
|
||||||
import { createChannel } from "@/api";
|
import { createChannel, getCurrentUsername } from "@/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
@@ -61,6 +61,12 @@ export function ChannelSidebar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Current user */}
|
||||||
|
<div className="px-3 py-2 border-t border-sidebar-border text-[11px]">
|
||||||
|
<span className="text-muted-foreground">logged in as </span>
|
||||||
|
<span className="font-bold text-foreground">{getCurrentUsername()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* New channel input */}
|
{/* New channel input */}
|
||||||
<div className="p-2 border-t border-sidebar-border">
|
<div className="p-2 border-t border-sidebar-border">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user