diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e7883f --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +POSTGRES_USER=admin +POSTGRES_PASSWORD=changeme +POSTGRES_DB=postcard-im-app +REDIS_URL=redis://redis:6379 +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=changeme +MINIO_BUCKET=media +MINIO_ENDPOINT=http://minio:9000 +JWT_SECRET=changeme +BACKEND_URL=http://backend:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..3a2eeb8 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,11 @@ +:80 { + route /api/* { + reverse_proxy backend:8080 + } + route /ws { + reverse_proxy backend:8080 + } + route /* { + reverse_proxy web:3000 + } +} diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..bc118c9 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/api/Containerfile b/api/Containerfile new file mode 100644 index 0000000..e5ae42f --- /dev/null +++ b/api/Containerfile @@ -0,0 +1,12 @@ +# syntax=docker/dockerfile:1 +FROM rust:1.82-slim AS builder +WORKDIR /app +COPY . . +RUN cargo build --release + +FROM debian:trixie-slim +WORKDIR /app +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/backend /app/backend +EXPOSE 8080 +CMD ["/app/backend"] diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..3244909 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,9 @@ +use std::time::Duration; +use std::thread; + +fn main() { + println!("Backend stub started..."); + loop { + thread::sleep(Duration::from_secs(60)); + } +} diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..80b2465 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,56 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(32) UNIQUE NOT NULL, + display_name VARCHAR(64) NOT NULL, + avatar_url TEXT, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + next_sequence BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE conversation_participants ( + conversation_id UUID REFERENCES conversations(id), + user_id UUID REFERENCES users(id), + last_read_seq BIGINT NOT NULL DEFAULT 0, + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (conversation_id, user_id), + UNIQUE (conversation_id, user_id) +); + +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id) NOT NULL, + sender_id UUID REFERENCES users(id) NOT NULL, + sequence BIGINT NOT NULL, + message_type VARCHAR(16) NOT NULL, + body TEXT, + media_metadata JSONB, + client_msg_id UUID NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'sent', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (conversation_id, sequence), + UNIQUE (client_msg_id) +); + +CREATE TABLE sticker_packs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(64) NOT NULL, + author VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE stickers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pack_id UUID REFERENCES sticker_packs(id) NOT NULL, + emoji_tag VARCHAR(8), + image_url TEXT NOT NULL, + thumbnail_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/002_indexes.sql b/migrations/002_indexes.sql new file mode 100644 index 0000000..d12a4e6 --- /dev/null +++ b/migrations/002_indexes.sql @@ -0,0 +1,7 @@ +-- Unique constraints on (conversation_id, sequence) and (client_msg_id) +-- inherently create indexes in PostgreSQL, so we only need to explicitly +-- define the remaining requested indexes. + +CREATE INDEX idx_messages_conv_created_at ON messages(conversation_id, created_at); +CREATE INDEX idx_conv_participants_user_id ON conversation_participants(user_id); +CREATE INDEX idx_stickers_pack_id ON stickers(pack_id); diff --git a/migrations/003_functions.sql b/migrations/003_functions.sql new file mode 100644 index 0000000..363d900 --- /dev/null +++ b/migrations/003_functions.sql @@ -0,0 +1,13 @@ +-- Atomic sequence increment for message ordering +CREATE OR REPLACE FUNCTION next_message_sequence(conv_id UUID) +RETURNS BIGINT AS $$ +DECLARE + seq BIGINT; +BEGIN + UPDATE conversations + SET next_sequence = next_sequence + 1, updated_at = now() + WHERE id = conv_id + RETURNING next_sequence INTO seq; + RETURN seq; +END; +$$ LANGUAGE plpgsql; diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..b7c5b82 --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,110 @@ +version: '3.8' + +services: + postgres: + image: postgres:18 + environment: + POSTGRES_USER: ${POSTGRES_USER:-admin} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-Lord5673} + POSTGRES_DB: ${POSTGRES_DB:-postcard-im-app} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-admin} -d ${POSTGRES_DB:-postcard-im-app}"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "5432:5432" + + redis: + image: redis:8 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-ChuckNorris52!} + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-ChuckNorris52!} && + /usr/bin/mc mb myminio/${MINIO_BUCKET:-media} --ignore-existing && + /usr/bin/mc anonymous set public myminio/${MINIO_BUCKET:-media} + " + + backend: + build: + context: ./backend + dockerfile: Containerfile + environment: + - DATABASE_URL=postgres://${POSTGRES_USER:-admin}:${POSTGRES_PASSWORD:-Lord5673}@postgres:5432/${POSTGRES_DB:-postcard-im-app} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - MINIO_ENDPOINT=${MINIO_ENDPOINT:-http://minio:9000} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-ChuckNorris52!} + - MINIO_BUCKET=${MINIO_BUCKET:-media} + - JWT_SECRET=${JWT_SECRET:-changeme} + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + + web: + build: + context: ./web + dockerfile: Containerfile + environment: + - BACKEND_URL=${BACKEND_URL:-http://backend:8080} + ports: + - "3000:3000" + depends_on: + - backend + + caddy: + image: caddy:latest + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + depends_on: + - backend + - web + +volumes: + postgres_data: + minio_data: + caddy_data: + caddy_config: