diff --git a/.github/workflows/dockerhub-publish.yml b/.github/workflows/dockerhub-publish.yml new file mode 100644 index 0000000..28fce40 --- /dev/null +++ b/.github/workflows/dockerhub-publish.yml @@ -0,0 +1,65 @@ +name: Build and publish Docker image to Docker Hub + +on: + push: + branches: [ main, master ] + tags: [ 'v*' ] + paths: + - 'dockerimage/**' + - 'backend/**' + - 'frontend/**' + - '.github/workflows/dockerhub-publish.yml' + pull_request: + branches: [ main, master ] + paths: + - 'dockerimage/**' + - 'backend/**' + - 'frontend/**' + - '.github/workflows/dockerhub-publish.yml' + +env: + DOCKERHUB_REPO: rapnuss/ciphernotes + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_REPO }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: dockerimage/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + + diff --git a/.vscode/settings.json b/.vscode/settings.json index 638b3b4..8a40482 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,8 @@ "Betroffenenrechte", "bowser", "bubblewrap", + "buildx", + "Buildx", "CAROOT", "cascadia", "Cascadia", @@ -22,20 +24,25 @@ "clipb", "comlink", "Consolas", + "createdb", "CSIC", "CTAN", "customtabs", "Dailymotion", "dasharray", "dashoffset", + "datname", "dexie", "Dexie", + "dockerhub", + "dockerimage", "downl", "DSGVO", "Ecosia", "esnext", "Eurogamer", "genkeypair", + "github", "hcaptcha", "HCAPTCHA", "healthcheck", @@ -48,6 +55,7 @@ "jszip", "keyalg", "keymap", + "keypair", "keysize", "keytool", "Kotaku", @@ -55,14 +63,22 @@ "levenshtein", "linecap", "linejoin", + "linux", "mantine", "matplotlib", "Metacritic", "nginx", + "nocrypt", "Nußbaumer", + "nvmrc", + "openssl", "outdir", + "outform", "panzoom", "passwortloser", + "PGDATA", + "pkcs", + "PKCS", "postgres", "postgresql", "precache", @@ -70,10 +86,14 @@ "presign", "presigner", "printcert", + "psql", + "pubout", "qrcode", "Qwant", + "rapnuss", "replit", "rgba", + "rolname", "Searchalot", "sels", "serverside", @@ -83,9 +103,12 @@ "Supportanfragen", "Swisscows", "tabler", + "topk", "unarch", "urlset", "Usec", + "uuid", + "Vite", "Wickenburggasse", "yudiel" ], diff --git a/backend/.dockerignore b/backend/.dockerignore index dd269be..b1a05e3 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,5 +1,6 @@ dist node_modules +.bun-version .dockerignore .env .env.example diff --git a/backend/drizzle/0000_warm_blink.sql b/backend/drizzle/0000_warm_blink.sql new file mode 100644 index 0000000..99dce15 --- /dev/null +++ b/backend/drizzle/0000_warm_blink.sql @@ -0,0 +1,49 @@ +CREATE TYPE "public"."note_type" AS ENUM('note', 'todo', 'label', 'file');--> statement-breakpoint +CREATE TYPE "public"."subscription_type" AS ENUM('free', 'plus', 'pro');--> statement-breakpoint +CREATE TABLE "notes" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "notes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "user_id" bigint NOT NULL, + "clientside_id" varchar(36) NOT NULL, + "type" "note_type" DEFAULT 'note' NOT NULL, + "cipher_text" text, + "iv" varchar(16), + "version" integer DEFAULT 1 NOT NULL, + "serverside_created_at" bigint NOT NULL, + "serverside_updated_at" bigint NOT NULL, + "clientside_created_at" bigint NOT NULL, + "clientside_updated_at" bigint NOT NULL, + "clientside_deleted_at" bigint, + "committed_size" integer DEFAULT 0 NOT NULL, + CONSTRAINT "user_client_id" UNIQUE("user_id","clientside_id") +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "sessions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "user_id" bigint NOT NULL, + "access_token_hash" varchar(64) NOT NULL, + "access_token_salt" varchar(32) NOT NULL, + "created_at" bigint NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "email" varchar(255) NOT NULL, + "password_hash" varchar(255), + "is_admin" boolean DEFAULT false NOT NULL, + "login_code" varchar(6), + "login_code_created_at" bigint, + "login_tries_left" integer DEFAULT 0 NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL, + "sync_token" varchar(24), + "confirm_code" varchar(6), + "confirm_code_created_at" bigint, + "confirm_code_tries_left" integer DEFAULT 0 NOT NULL, + "new_email" varchar(255), + "subscription" "subscription_type" DEFAULT 'free' NOT NULL, + "successful_login_at" bigint, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/backend/drizzle/0001_migrate_to_bigint_ids.sql b/backend/drizzle/0001_migrate_to_bigint_ids.sql deleted file mode 100644 index 0e5393d..0000000 --- a/backend/drizzle/0001_migrate_to_bigint_ids.sql +++ /dev/null @@ -1,91 +0,0 @@ --- Migration to convert integer IDs to bigint IDs while preserving data --- This migration handles the conversion safely without data loss - --- Step 1: Create new tables with bigint IDs -CREATE TABLE "users_new" ( - "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_new_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), - "email" varchar(255) NOT NULL, - "login_code" varchar(6), - "login_code_created_at" bigint, - "login_tries_left" integer DEFAULT 0 NOT NULL, - "created_at" bigint NOT NULL, - "updated_at" bigint NOT NULL, - "sync_token" varchar(24), - "confirm_code" varchar(6), - "confirm_code_created_at" bigint, - "confirm_code_tries_left" integer DEFAULT 0 NOT NULL, - "new_email" varchar(255), - "subscription" "subscription_type" DEFAULT 'free' NOT NULL, - CONSTRAINT "users_new_email_unique" UNIQUE("email") -); - -CREATE TABLE "sessions_new" ( - "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "sessions_new_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), - "user_id" bigint NOT NULL, - "access_token_hash" varchar(64) NOT NULL, - "access_token_salt" varchar(32) NOT NULL, - "created_at" bigint NOT NULL -); - -CREATE TABLE "notes_new" ( - "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "notes_new_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), - "user_id" bigint NOT NULL, - "clientside_id" varchar(36) NOT NULL, - "type" "note_type" DEFAULT 'note' NOT NULL, - "cipher_text" text, - "iv" varchar(16), - "version" integer DEFAULT 1 NOT NULL, - "serverside_created_at" bigint NOT NULL, - "serverside_updated_at" bigint NOT NULL, - "clientside_created_at" bigint NOT NULL, - "clientside_updated_at" bigint NOT NULL, - "clientside_deleted_at" bigint, - "committed_size" integer DEFAULT 0 NOT NULL, - CONSTRAINT "user_client_id_new" UNIQUE("user_id","clientside_id") -); - --- Step 2: Copy data from old tables to new tables with OVERRIDING SYSTEM VALUE -INSERT INTO "users_new" ("id", "email", "login_code", "login_code_created_at", "login_tries_left", "created_at", "updated_at", "sync_token", "confirm_code", "confirm_code_created_at", "confirm_code_tries_left", "new_email", "subscription") -OVERRIDING SYSTEM VALUE -SELECT "id", "email", "login_code", "login_code_created_at", "login_tries_left", "created_at", "updated_at", "sync_token", "confirm_code", "confirm_code_created_at", "confirm_code_tries_left", "new_email", "subscription" -FROM "users"; - -INSERT INTO "sessions_new" ("id", "user_id", "access_token_hash", "access_token_salt", "created_at") -OVERRIDING SYSTEM VALUE -SELECT "id", "user_id", "access_token_hash", "access_token_salt", "created_at" -FROM "sessions"; - -INSERT INTO "notes_new" ("id", "user_id", "clientside_id", "type", "cipher_text", "iv", "version", "serverside_created_at", "serverside_updated_at", "clientside_created_at", "clientside_updated_at", "clientside_deleted_at", "committed_size") -OVERRIDING SYSTEM VALUE -SELECT "id", "user_id", "clientside_id", "type", "cipher_text", "iv", "version", "serverside_created_at", "serverside_updated_at", "clientside_created_at", "clientside_updated_at", "clientside_deleted_at", "committed_size" -FROM "notes"; - --- Step 3: Set sequence values to continue from the highest ID -SELECT setval('users_new_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM users_new)); -SELECT setval('sessions_new_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM sessions_new)); -SELECT setval('notes_new_id_seq', (SELECT COALESCE(MAX(id), 0) + 1 FROM notes_new)); - --- Step 4: Add foreign key constraints to new tables -ALTER TABLE "sessions_new" ADD CONSTRAINT "sessions_new_user_id_users_new_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users_new"("id") ON DELETE no action ON UPDATE no action; -ALTER TABLE "notes_new" ADD CONSTRAINT "notes_new_user_id_users_new_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users_new"("id") ON DELETE no action ON UPDATE no action; - --- Step 5: Drop old tables -DROP TABLE "notes"; -DROP TABLE "sessions"; -DROP TABLE "users"; - --- Step 6: Rename new tables to original names -ALTER TABLE "users_new" RENAME TO "users"; -ALTER TABLE "sessions_new" RENAME TO "sessions"; -ALTER TABLE "notes_new" RENAME TO "notes"; - --- Step 7: Rename constraints to original names -ALTER TABLE "users" RENAME CONSTRAINT "users_new_email_unique" TO "users_email_unique"; -ALTER TABLE "sessions" RENAME CONSTRAINT "sessions_new_user_id_users_new_id_fk" TO "sessions_user_id_users_id_fk"; -ALTER TABLE "notes" RENAME CONSTRAINT "notes_new_user_id_users_new_id_fk" TO "notes_user_id_users_id_fk"; -ALTER TABLE "notes" RENAME CONSTRAINT "user_client_id_new" TO "user_client_id"; - --- Step 8: Rename sequences to original names -ALTER SEQUENCE "users_new_id_seq" RENAME TO "users_id_seq"; -ALTER SEQUENCE "sessions_new_id_seq" RENAME TO "sessions_id_seq"; -ALTER SEQUENCE "notes_new_id_seq" RENAME TO "notes_id_seq"; \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json index 40fd29b..07b2eb4 100644 --- a/backend/drizzle/meta/0000_snapshot.json +++ b/backend/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "30ed7634-b16d-4176-b067-561c5f6d48a9", + "id": "b828c258-78b3-4758-b8f7-9fda1add2a07", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -228,6 +228,19 @@ "primaryKey": false, "notNull": true }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "login_code": { "name": "login_code", "type": "varchar(6)", @@ -297,6 +310,12 @@ "primaryKey": false, "notNull": true, "default": "'free'" + }, + "successful_login_at": { + "name": "successful_login_at", + "type": "bigint", + "primaryKey": false, + "notNull": false } }, "indexes": {}, diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 1723a85..88ce3ed 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -1,12 +1,12 @@ { - "version": "5", + "version": "7", "dialect": "postgresql", "entries": [ { "idx": 0, - "version": "5", - "when": 1732487460000, - "tag": "0001_migrate_to_bigint_ids", + "version": "7", + "when": 1756705234111, + "tag": "0000_warm_blink", "breakpoints": true } ] diff --git a/backend/src/endpoints/getPresignedUrls.ts b/backend/src/endpoints/getPresignedUrls.ts index fceea1b..f54f8b2 100644 --- a/backend/src/endpoints/getPresignedUrls.ts +++ b/backend/src/endpoints/getPresignedUrls.ts @@ -5,7 +5,7 @@ import {notesTbl} from '../db/schema' import {and, eq, gt, inArray, isNull} from 'drizzle-orm' import {s3} from '../services/s3' import {indexByProp} from '../util/misc' -import {env} from '../env' +import {env, hostingMode} from '../env' import {GetObjectCommand} from '@aws-sdk/client-s3' import {getSignedUrl} from '@aws-sdk/s3-request-presigner' import {createPresignedPost} from '@aws-sdk/s3-presigned-post' @@ -21,7 +21,7 @@ export const getPresignedUrlsEndpoint = authEndpointsFactory.build({ upload_urls: z.array( z.object({ note_id: z.string().uuid(), - url: z.string().url(), + url: z.string(), fields: z.record(z.string(), z.string()), }) ), @@ -29,7 +29,7 @@ export const getPresignedUrlsEndpoint = authEndpointsFactory.build({ download_urls: z.array( z.object({ note_id: z.string().uuid(), - url: z.string().url(), + url: z.string(), }) ), }), @@ -116,7 +116,7 @@ const getUploadUrls = async ( }) return { note_id: note.clientside_id, - url, + url: hostingMode !== 'self' ? url : url.replace(/http:\/\/[^\/]+\//, '/s3/'), fields, } }) @@ -151,7 +151,7 @@ const getDownloadUrls = async (note_ids: string[], user_id: number) => { ) return { note_id: note.clientside_id, - url, + url: hostingMode !== 'self' ? url : url.replace(/http:\/\/[^\/]+\//, '/s3/'), } }) ) diff --git a/dockerimage/.env.example b/dockerimage/.env.example new file mode 100644 index 0000000..ee10e60 --- /dev/null +++ b/dockerimage/.env.example @@ -0,0 +1,20 @@ +# Only the admin can create users. +# ou can use the admin use as regular app user. +ADMIN_USERNAME=TODO +ADMIN_PASSWORD=TODO + +# Security (change in production, use a uuid) +COOKIE_SECRET=TODO + +# !!! choose secure credentials, anyone with you root password can read/write from/to the ciphernotes bucket !!! +# The s3 api is partially exposed through the /s3 proxy route. +MINIO_ROOT_USER=TODO +MINIO_ROOT_PASSWORD=TODO + +# Optional overrides +# TRUST_PROXY=3 +# RATE_WINDOW_SEC=60 +# RATE_LIMIT=200 +# SESSION_TTL_MIN=43200 +# NOTES_STORAGE_LIMIT=1000000000 +# FILES_STORAGE_LIMIT=1000000000 diff --git a/dockerimage/Dockerfile b/dockerimage/Dockerfile new file mode 100644 index 0000000..1389684 --- /dev/null +++ b/dockerimage/Dockerfile @@ -0,0 +1,79 @@ +######################################## +# Keys stage: generate JWT keypair +######################################## +FROM alpine:3 AS keys +RUN apk add --no-cache openssl +RUN openssl genrsa -out /tmp/private_rsa.pem 2048 \ + && openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in /tmp/private_rsa.pem -out /tmp/private_pkcs8.pem \ + && openssl rsa -in /tmp/private_rsa.pem -pubout -out /tmp/public.pem + +######################################## +# Frontend build stage +######################################## +FROM node:22-alpine AS frontend-build +WORKDIR /app/frontend +RUN apk add --no-cache openssl +COPY frontend/package.json frontend/yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY frontend/ . +# Use generated public key so the static bundle verifies backend JWTs +COPY --from=keys /tmp/public.pem ./jwt-public.pub +# Build-time env for Vite defines +ARG HOSTING_MODE=self +ENV NODE_ENV=production \ + HOSTING_MODE=$HOSTING_MODE +RUN yarn build + +######################################## +# Backend build stage +######################################## +FROM oven/bun:1 AS backend-build +WORKDIR /app/backend +COPY backend/package.json backend/bun.lockb ./ +RUN bun install --frozen-lockfile +COPY backend/ . +RUN bun run build + +######################################## +# Runtime stage: bun + nginx +######################################## +FROM oven/bun:1 AS app +WORKDIR /app + +# Install nginx and curl +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + nginx curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Overwrite frontend public key with generated one and then copy build output +COPY --from=keys /tmp/public.pem /app/frontend/jwt-public.pub +COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html + +# Copy backend runtime bits +COPY --from=backend-build /app/backend/dist /app/backend/dist +COPY --from=backend-build /app/backend/package.json /app/backend/package.json +# Copy backend node_modules so we can run drizzle-kit at runtime +COPY --from=backend-build /app/backend/node_modules /app/backend/node_modules +# Copy drizzle config and migrations +COPY --from=backend-build /app/backend/drizzle /app/backend/drizzle +COPY --from=backend-build /app/backend/drizzle.config.ts /app/backend/drizzle.config.ts +# Copy only what's needed for migrations (avoid shipping source) +COPY --from=backend-build /app/backend/tsconfig.json /app/backend/tsconfig.json +# Use generated private key (PKCS8) for backend JWT signing +COPY --from=keys /tmp/private_pkcs8.pem /app/backend/jwt-private.pem + +# Nginx config for SPA + backend proxy +COPY dockerimage/nginx.conf /etc/nginx/conf.d/default.conf + +# Entrypoint script to run both backend and nginx +COPY dockerimage/start.sh /start.sh +RUN chmod +x /start.sh + +EXPOSE 80 + +ENV NODE_ENV=production + +CMD ["/start.sh"] + + diff --git a/dockerimage/README.md b/dockerimage/README.md new file mode 100644 index 0000000..24382d9 --- /dev/null +++ b/dockerimage/README.md @@ -0,0 +1,125 @@ +Ciphernotes (Self‑Hosted) – Docker Compose +========================================= + +Production‑ready, self‑hosted deployment of Ciphernotes. This setup runs: +- app: Nginx (serving the SPA and proxy) + backend (Bun) +- db: Postgres 16 +- minio: S3‑compatible object storage +- minio‑setup: one‑shot bucket initializer + +What you get +------------ +- Single `docker compose` up for the whole stack +- DB migrations auto‑applied on startup (no local tooling needed) +- MinIO bucket auto‑created +- Frontend served at port 8080; backend proxied at `/api`; S3 proxied at `/s3` with restricted methods + +Quick start +----------- +1) Copy env and adjust minimal settings: + - On first use: + - Create `dockerimage/.env` (or copy from an example) with at least: + - `COOKIE_SECRET` – strong random string + - Optional admin bootstrap (self‑hosted): `ADMIN_USERNAME`, `ADMIN_PASSWORD` + +2) Run: +``` +docker compose -f dockerimage/docker-compose.yml up --build -d +``` + +3) Open: +``` +http://localhost:8080 +``` + +Default ports +------------- +- App (Nginx + backend): 8080 → 80 +- MinIO API/Console (optional): 9000/9001 (published by default for convenience) + +Environment variables (app) +--------------------------- +- `COOKIE_SECRET` (required): secret for cookie signing +- `HOSTING_MODE` (optional): default `self` +- `ADMIN_USERNAME`, `ADMIN_PASSWORD` (optional): create/update an admin on first start +- Rate/limits (optional, sensible defaults): `RATE_LIMIT`, `RATE_WINDOW_SEC`, `SESSION_TTL_MIN`, `LIMIT_JSON`, `LIMIT_RAW`, `NOTES_STORAGE_LIMIT`, `FILES_STORAGE_LIMIT` + +Environment variables (db/minio) +-------------------------------- +- Postgres: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` (defaults: `notes`/`notes`/`notes`) +- MinIO: `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD` (defaults set in compose) +- S3 bucket: `S3_BUCKET` (default: `ciphernotes`) + +Security notes +-------------- +- The app proxies MinIO under `/s3` and limits methods to GET/HEAD/POST for browser flows. +- Keep MinIO root credentials secret. Anyone with valid credentials can perform actions through the proxy. +- Consider not publishing 9000/9001 externally in production; rely on the `/s3` proxy for browser access and the app’s internal S3 client for server‑side ops. + +Upgrades +-------- +- Pull new app image, then: +``` +docker compose -f dockerimage/docker-compose.yml pull app +docker compose -f dockerimage/docker-compose.yml up -d +``` +- Migrations run automatically on app startup. + +Troubleshooting +--------------- +- Check logs: +``` +docker compose -f dockerimage/docker-compose.yml logs -f app +``` +- Schema not created: ensure `db` is healthy, then restart `app`. +- S3 signature errors: ensure `/s3` proxy preserves `Host 127.0.0.1:9000` (already configured) and that the request path does not include the `/s3` prefix after proxying (rewrite is configured). + +Docker Hub usage (publishing) +----------------------------- +If you publish the app image, your `compose.yml` can reference it directly, e.g.: +```yaml +services: + app: + image: your-dockerhub-username/ciphernotes-app:latest + env_file: [.env] + ports: ["8080:80"] + depends_on: + db: { condition: service_healthy } + minio: { condition: service_started } + minio-setup: { condition: service_completed_successfully } + db: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-notes} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-notes} + POSTGRES_DB: ${POSTGRES_DB:-notes} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-notes}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + volumes: ["db_data:/var/lib/postgresql/data"] + minio: + image: minio/minio:latest + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio-admin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio-admin} + command: server /data --console-address ":9001" + ports: ["9000:9000", "9001:9001"] + volumes: ["minio_data:/data"] + minio-setup: + image: minio/mc:latest + depends_on: { minio: { condition: service_started } } + entrypoint: ["/bin/sh", "-c"] + command: >- + "mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minio-admin} ${MINIO_ROOT_PASSWORD:-minio-admin} && + (mc ls local/${S3_BUCKET:-ciphernotes} || mc mb -p local/${S3_BUCKET:-ciphernotes})" + restart: "no" +volumes: { db_data: {}, minio_data: {} } +``` + +Licenses & attribution +---------------------- +- MinIO and `mc` are AGPLv3; in this setup you use the official images. See MinIO: https://github.com/minio/minio +- Ensure you comply with AGPLv3 when redistributing MinIO (e.g., include license notices and source references). diff --git a/dockerimage/docker-compose.yml b/dockerimage/docker-compose.yml new file mode 100644 index 0000000..f5186df --- /dev/null +++ b/dockerimage/docker-compose.yml @@ -0,0 +1,72 @@ +services: + app: + build: + context: .. + dockerfile: dockerimage/Dockerfile + container_name: ciphernotes_app + env_file: + - .env + ports: + - "13064:80" + depends_on: + db: + condition: service_healthy + minio: + condition: service_healthy + minio-setup: + condition: service_completed_successfully + restart: unless-stopped + + db: + image: postgres:16 + container_name: ciphernotes_db + environment: + POSTGRES_USER: notes + POSTGRES_PASSWORD: notes + POSTGRES_DB: notes + healthcheck: + test: ["CMD-SHELL", "pg_isready -U notes"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + volumes: + - db_data:/var/lib/postgresql/data + restart: unless-stopped + + minio: + image: minio/minio:latest + container_name: ciphernotes_minio + env_file: + - .env + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:9000/minio/health/ready || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + restart: unless-stopped + + minio-setup: + image: minio/mc:latest + container_name: ciphernotes_minio_setup + env_file: + - .env + depends_on: + minio: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: >- + "mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} && + (mc ls local/ciphernotes || mc mb -p local/ciphernotes || true)" + restart: "no" + +volumes: + db_data: + minio_data: diff --git a/dockerimage/nginx.conf b/dockerimage/nginx.conf new file mode 100644 index 0000000..9b42ae0 --- /dev/null +++ b/dockerimage/nginx.conf @@ -0,0 +1,57 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # API proxy to backend bun server + location /api/ { + proxy_pass http://127.0.0.1:5100/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + } + + # Socket.IO proxy + location /socket.io/ { + proxy_pass http://127.0.0.1:5100/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Allow only GET/HEAD/POST to your bucket; block management/listing + location = /s3 { return 403; } + location = /s3/ { return 403; } + + location ~ ^/s3/ciphernotes(/|$) { + # Only allow browser flows + limit_except GET HEAD POST { deny all; } + + # Strip "/s3" prefix so MinIO sees "/ciphernotes/..." + rewrite ^/s3/(.*)$ /$1 break; + proxy_pass http://minio:9000; + proxy_http_version 1.1; + proxy_set_header Host minio:9000; # keep SigV4 host consistent + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + + # presigned POST uploads + client_max_body_size 0; + proxy_request_buffering off; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/dockerimage/start.sh b/dockerimage/start.sh new file mode 100644 index 0000000..cfa3f03 --- /dev/null +++ b/dockerimage/start.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################ +# Defaults for required backend env vars +############################################ +export NODE_ENV=${NODE_ENV:-production} +export HOSTING_MODE=${HOSTING_MODE:-self} +export PORT=${PORT:-5100} +export TRUST_PROXY=${TRUST_PROXY:-3} +export RATE_WINDOW_SEC=${RATE_WINDOW_SEC:-60} +export RATE_LIMIT=${RATE_LIMIT:-200} +export SESSION_TTL_MIN=${SESSION_TTL_MIN:-43200} +export COOKIE_SECRET=${COOKIE_SECRET:-dev-cookie-secret} +export LIMIT_JSON=${LIMIT_JSON:-2mb} +export LIMIT_RAW=${LIMIT_RAW:-2mb} +export NOTES_STORAGE_LIMIT=${NOTES_STORAGE_LIMIT:-1000000000} +export FILES_STORAGE_LIMIT=${FILES_STORAGE_LIMIT:-1000000000} + +############################################ +# External Postgres and MinIO endpoints via compose +############################################ +export DATABASE_URL=${DATABASE_URL:-postgresql://notes:notes@db:5432/notes} +export MINIO_ROOT_USER=${MINIO_ROOT_USER} +export MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} +export S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-$MINIO_ROOT_USER} +export S3_ACCESS_KEY_SECRET=${S3_ACCESS_KEY_SECRET:-$MINIO_ROOT_PASSWORD} +export S3_REGION=${S3_REGION:-EU-CENTRAL-1} +export S3_ENDPOINT=${S3_ENDPOINT:-http://minio:9000} +export S3_BUCKET=${S3_BUCKET:-ciphernotes} + +############################################ +# Apply migrations (DB health is ensured by compose depends_on) +############################################ +cd /app/backend +echo "Applying migrations..." +./node_modules/.bin/drizzle-kit migrate || bun run db:migrate || true + +############################################ +# Start backend +############################################ +bun /app/backend/dist/index.js & +BACKEND_PID=$! + +############################################ +# Start nginx (foreground) +############################################ +rm -f /etc/nginx/sites-enabled/default 2>/dev/null || true +nginx -g 'daemon off;' & +NGINX_PID=$! + +wait -n $BACKEND_PID $NGINX_PID +exit $? + + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a2ef496 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +dist +node_modules +.dockerignore +.env +.env.example +.nvmrc +cert.pem +Dockerfile +key.pem diff --git a/todo.txt b/todo.txt index 1af5359..e20889d 100644 --- a/todo.txt +++ b/todo.txt @@ -60,10 +60,8 @@ DONE: on change encryption key delete all blobs with the old key FIRST: - confirm flow via password - three tries every 5 minutes - reset to 3 tries after 5 minutes - hcaptcha site code via env TODO: + error if minio password is not set or too short + make features pem optional LATER: MAYBE: