9 Commits

Author SHA1 Message Date
9db20fdf90 Fix: Raise nginx ingress body size limit to 52m for image uploads
Default client_max_body_size of 1MB was rejecting uploads larger than 1MB
with a 413 before the request reached the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:34:08 +00:00
9b66fe1918 Docs: Update constitution to v1.4.0
Aligns principles with actual project state: soften TDD wording to allow
tests alongside implementation, replace CI gate with concrete local test
suite gate, add production infrastructure to tech stack (k3s, nginx,
Vault + VSO), and document plaintext password storage as a known gap
that must be resolved before further auth work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:01:48 +00:00
e9a2e9f014 Docs: Update example image for README.md 2026-05-08 11:54:36 -04:00
7b3d4a9257 Docs: Add comprehensive README with local dev and production deployment guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:51:32 +00:00
7c57629941 Fix: Add correct annotation to ingress 2026-05-07 18:36:24 -04:00
4fe8b19d19 Fix: Adjust Minio security context 2026-05-07 18:29:36 -04:00
e34c9f7b7f Chore: Set image pull policy 2026-05-07 18:21:43 -04:00
551ddbec3b Ops: Adjust deployment manifests for environment 2026-05-07 17:49:48 -04:00
666c32cd69 Ops: Point manifests at Juggalol container registry 2026-05-07 17:38:28 -04:00
10 changed files with 198 additions and 32 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,8 +1,8 @@
<!-- <!--
SYNC IMPACT REPORT SYNC IMPACT REPORT
================== ==================
Version change: 1.2.0 → 1.3.0 Version change: 1.3.0 → 1.4.0
Ratified: 2026-05-01 | Last amended: 2026-05-06 Ratified: 2026-05-01 | Last amended: 2026-05-08
Principles introduced (first population from docs/CONSTITUTION.md): Principles introduced (first population from docs/CONSTITUTION.md):
- §2 Architecture Principles (6 sub-principles) - §2 Architecture Principles (6 sub-principles)
@@ -171,11 +171,14 @@ OR/NOT logic is explicitly out of scope until the constitution is revised.
## 5. Testing Discipline ## 5. Testing Discipline
### 5.1 TDD is non-negotiable ### 5.1 Tests are required alongside every implementation task
No production code MAY be written before a failing test exists for it. This Every implementation task MUST be accompanied by tests covering its behaviour.
applies to both API and UI. Tasks MUST include a "write failing test" step The ideal is red-green-refactor: write a failing test, then make it pass. In
before any implementation step. practice, tests written in the same task as the implementation are acceptable;
what is non-negotiable is that no implementation task is marked done without
corresponding test coverage. Tasks MUST NOT be split such that implementation
is complete but tests are deferred to a later task.
### 5.2 Test pyramid ### 5.2 Test pyramid
@@ -194,10 +197,15 @@ Unit and integration tests are required. E2E tests are best-effort in v1.
API tests in `api/tests/`, UI tests colocated with their components. No API tests in `api/tests/`, UI tests colocated with their components. No
separate top-level `tests/` directory that mirrors the source tree. separate top-level `tests/` directory that mirrors the source tree.
### 5.4 CI must pass before any task is considered done ### 5.4 The test suite must pass before any task is considered done
"Done" means: all tests pass, linter passes, type checker passes. A task MUST "Done" means: all tests pass, linter passes, type checker passes. A task MUST
NOT be marked complete while CI is failing. NOT be marked complete while any of these are failing.
The acceptance gate is `make test-unit && make test-integration` plus `ruff
check` / `ruff format --check` for the API. A formal CI pipeline is planned
but not yet in place; until one exists, passing the above commands locally is
the required gate. When CI is introduced it MUST enforce the same checks.
--- ---
@@ -214,6 +222,9 @@ NOT be marked complete while CI is failing.
| UI framework | Angular (latest stable) | Job-relevant, learning goal | | UI framework | Angular (latest stable) | Job-relevant, learning goal |
| UI language | TypeScript strict mode | No `any`, no implicit types | | UI language | TypeScript strict mode | No `any`, no implicit types |
| Containerisation | Docker + Docker Compose | Local dev must start with one command | | Containerisation | Docker + Docker Compose | Local dev must start with one command |
| Production runtime | k3s (Kubernetes) | Manifests in `k8s/`; see deployment docs |
| Ingress | nginx ingress controller + cert-manager | TLS via Let's Encrypt (`letsencrypt-prod` ClusterIssuer) |
| Secret management | HashiCorp Vault + VSO (Vault Secrets Operator) | Secrets never committed; VSO syncs Vault KV v2 → K8s Secrets |
--- ---
@@ -251,6 +262,15 @@ revised:
- Mobile-native app - Mobile-native app
- OIDC auth (planned Phase 3) - OIDC auth (planned Phase 3)
**Known gaps carried forward from v1** — these are not out of scope; they are
acknowledged deficiencies that MUST be resolved before the affected area is
expanded:
- **Password hashing**: The owner password is currently stored and compared in
plaintext. Hashing (bcrypt or Argon2) MUST be implemented before any
additional authentication work (e.g. OIDC, additional accounts) is started.
Specs that touch credential storage MUST address this first.
--- ---
## 9. Governance ## 9. Governance
@@ -289,7 +309,8 @@ Phase 1 design is complete.
| 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation | | 1.1.1 | 2026-05-03 | Clarify that the only acceptable form of image transformation or editing is thumbnail generation |
| 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) | | 1.2.0 | 2026-05-03 | §2.4: Mark Phase 2 (JWT bearer auth) complete, reword phase status; §6: Add PyJWT to tech stack table; §8: Remove username/password auth from out-of-scope (now shipped) |
| 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. | | 1.3.0 | 2026-05-06 | §2.5: Remove planned PostgreSQL → SQLite refactor note; prohibit alternative database engines in integration tests. §5.2: Explicitly require PostgreSQL for integration tests; prohibit SQLite — a production HAVING/GROUP BY bug was masked by SQLite's permissive dialect. |
| 1.4.0 | 2026-05-08 | §5.1: Soften strict TDD wording to reflect actual practice — tests alongside implementation are acceptable; deferring tests to a later task is not. §5.4: Replace "CI must pass" with local test suite gate; note CI is planned but not yet in place. §6: Add production runtime rows (k3s, nginx ingress + cert-manager, Vault + VSO). §8: Add "known gaps" subsection; document plaintext password storage as a deficiency that must be resolved before further auth work. |
--- ---
**Version**: 1.3.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-06 **Version**: 1.4.0 | **Ratified**: 2026-05-01 | **Last Amended**: 2026-05-08

138
README.md
View File

@@ -2,3 +2,141 @@
_Organize your reaction images._ _Organize your reaction images._
![Reactbin UI](.img/reactbin-ui.png) ![Reactbin UI](.img/reactbin-ui.png)
A self-hosted reaction image board. Single owner account, tag-based browsing, S3-compatible image storage.
---
## Local development
```bash
cp .env.example .env
# Edit .env — defaults work out of the box for local dev
docker compose up
```
- UI: http://localhost:4200
- API: http://localhost:8000
- MinIO console: http://localhost:9001 (minioadmin / minioadmin)
The API serves on port 8000 directly in dev. In production the nginx ingress routes `/api/` there.
### Running tests
```bash
make test-unit # pytest unit tests (no Docker)
make test-integration # builds api-test image, runs full suite against Postgres + MinIO
```
### Production image builds
```bash
make build-prod # builds reactbin-api-prod:latest from api/Dockerfile.prod
make verify-prod # smoke-tests the production image
make build-ui-prod # builds reactbin-ui-prod:latest from ui/Dockerfile.prod
make verify-ui-prod # smoke-tests the production UI image
```
---
## Production deployment (k3s)
### Cluster prerequisites
- nginx ingress controller
- cert-manager with a `letsencrypt-prod` ClusterIssuer
- Vault Secrets Operator (VSO) installed and connected to Vault
- Vault KV v2 secrets populated (see below)
### Vault secrets
Two KV v2 paths. VSO syncs these into Kubernetes Secrets automatically.
**`reactbin/api/config`** → K8s Secret `api-env`
| Key | Notes |
|-----|-------|
| `DATABASE_URL` | `postgresql+asyncpg://user:pass@host:5432/db` |
| `JWT_SECRET_KEY` | Long random string — `openssl rand -base64 48` |
| `OWNER_USERNAME` | Login username |
| `OWNER_PASSWORD` | Login password |
| `S3_ENDPOINT_URL` | `http://minio.reactbin.svc.cluster.local:9000` |
| `S3_BUCKET_NAME` | `reactbin` |
| `S3_ACCESS_KEY_ID` | Same value as `MINIO_ROOT_USER` |
| `S3_SECRET_ACCESS_KEY` | Same value as `MINIO_ROOT_PASSWORD` |
| `API_BASE_URL` | `https://<your-domain>` |
| `LOGIN_TRUSTED_PROXY_IPS` | Pod CIDR of nginx ingress pods, e.g. `10.42.0.0/16` — needed for per-client login rate limiting behind the ingress |
**`reactbin/minio/credentials`** → K8s Secret `minio-credentials`
| Key | Notes |
|-----|-------|
| `MINIO_ROOT_USER` | MinIO admin username |
| `MINIO_ROOT_PASSWORD` | `openssl rand -base64 32` |
### Apply order
```bash
# 1. Namespace first
kubectl apply -f k8s/namespace.yaml
# 2. Vault CRDs — wait for VSO to create api-env and minio-credentials Secrets
kubectl apply -f k8s/vault/
kubectl get secret -n reactbin api-env minio-credentials # wait until both appear
# 3. API, UI, Ingress — replace 'latest' tags and <your-domain> first
kubectl apply -f k8s/api/ -f k8s/ui/ -f k8s/ingress.yaml
kubectl rollout status deployment/api -n reactbin # Alembic init container runs here
# 4. MinIO — wait for StatefulSet ready before running the bucket init Job
kubectl apply -f k8s/minio/service.yaml -f k8s/minio/statefulset.yaml
kubectl rollout status statefulset/minio -n reactbin
kubectl apply -f k8s/minio/init-job.yaml
```
Before applying: substitute real image tags in the Deployment manifests and replace `<your-domain>` in `k8s/ingress.yaml`.
### Updating a secret
1. Update the value in Vault
2. Force VSO to sync immediately (otherwise waits up to 1 hour):
```bash
kubectl annotate vaultstaticsecret api-secret -n reactbin \
secrets.hashicorp.com/force-sync=$(date +%s) --overwrite
```
3. Restart the deployment to pick up the new Secret:
```bash
kubectl rollout restart deployment/api -n reactbin
```
### Validating manifests
```bash
make validate-k8s # yamllint + kubectl apply --dry-run=client (requires kubeconfig)
```
---
## Environment variables reference
All variables are read at startup from environment / `.env`.
| Variable | Default | Notes |
|----------|---------|-------|
| `DATABASE_URL` | — | Async DSN: `postgresql+asyncpg://...` |
| `JWT_SECRET_KEY` | — | Required; use a long random string in production |
| `JWT_EXPIRY_SECONDS` | `86400` | Token lifetime (24 h) |
| `OWNER_USERNAME` | — | Single owner account username |
| `OWNER_PASSWORD` | — | Single owner account password |
| `S3_ENDPOINT_URL` | — | MinIO or any S3-compatible endpoint |
| `S3_BUCKET_NAME` | `reactbin` | |
| `S3_ACCESS_KEY_ID` | — | |
| `S3_SECRET_ACCESS_KEY` | — | |
| `S3_REGION` | `us-east-1` | |
| `MAX_UPLOAD_BYTES` | `52428800` | 50 MiB |
| `API_BASE_URL` | — | Used for generating public URLs |
| `API_DOCS_ENABLED` | `true` | Set to `false` in production |
| `LOGIN_MAX_FAILURES` | `5` | Failed attempts before cooldown |
| `LOGIN_WINDOW_SECONDS` | `300` | Sliding window for failure count |
| `LOGIN_COOLDOWN_SECONDS` | `900` | Lock duration after threshold hit |
| `LOGIN_TRUSTED_PROXY_IPS` | `""` | Comma-separated CIDRs of trusted upstream proxies |

View File

@@ -1,4 +1,3 @@
# Replace 'latest' with the real image tag before applying
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -16,7 +15,8 @@ spec:
spec: spec:
initContainers: initContainers:
- name: migrate - name: migrate
image: reactbin-api:latest imagePullPolicy: Always
image: git.juggalol.com/juggalol/reactbin-api:v1.0.0
command: ["alembic", "upgrade", "head"] command: ["alembic", "upgrade", "head"]
workingDir: /app workingDir: /app
envFrom: envFrom:
@@ -27,7 +27,7 @@ spec:
runAsUser: 1001 runAsUser: 1001
containers: containers:
- name: api - name: api
image: reactbin-api:latest image: git.juggalol.com/juggalol/reactbin-api:v1.0.0
ports: ports:
- containerPort: 8000 - containerPort: 8000
envFrom: envFrom:

View File

@@ -1,4 +1,3 @@
# Replace <your-domain> with the real domain before applying
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -6,18 +5,19 @@ metadata:
namespace: reactbin namespace: reactbin
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/tls-acme: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "52m"
spec: spec:
ingressClassName: nginx ingressClassName: nginx-public
tls: tls:
- hosts: - hosts:
- <your-domain> - reactbin.juggalol.com
secretName: reactbin-tls secretName: reactbin-tls
rules: rules:
- host: <your-domain> - host: reactbin.juggalol.com
http: http:
paths: paths:
# /api/ must appear before / — nginx evaluates paths in declaration order
- path: /api/ - path: /api/
pathType: Prefix pathType: Prefix
backend: backend:
@@ -31,4 +31,4 @@ spec:
service: service:
name: ui name: ui
port: port:
number: 8080 number: 8080

View File

@@ -1,4 +1,3 @@
# Replace 'latest' with the real image tag before applying
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
@@ -15,6 +14,11 @@ spec:
labels: labels:
app: minio app: minio
spec: spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers: containers:
- name: minio - name: minio
image: minio/minio:latest image: minio/minio:latest
@@ -44,9 +48,6 @@ spec:
volumeMounts: volumeMounts:
- name: data - name: data
mountPath: /data mountPath: /data
securityContext:
runAsNonRoot: true
runAsUser: 1000
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - metadata:
name: data name: data

View File

@@ -1,4 +1,3 @@
# Replace 'latest' with the real image tag before applying
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -16,7 +15,8 @@ spec:
spec: spec:
containers: containers:
- name: ui - name: ui
image: reactbin-ui:latest imagePullPolicy: Always
image: git.juggalol.com/juggalol/reactbin-ui:v1.0.0
ports: ports:
- containerPort: 8080 - containerPort: 8080
livenessProbe: livenessProbe:

View File

@@ -4,8 +4,8 @@ metadata:
name: api-secret name: api-secret
namespace: reactbin namespace: reactbin
spec: spec:
vaultAuthRef: reactbin-auth vaultAuthRef: reactbin-vault-auth
mount: secret mount: kv
type: kv-v2 type: kv-v2
# Required Vault keys at this path: # Required Vault keys at this path:
# DATABASE_URL, JWT_SECRET_KEY, OWNER_USERNAME, OWNER_PASSWORD, # DATABASE_URL, JWT_SECRET_KEY, OWNER_USERNAME, OWNER_PASSWORD,

View File

@@ -4,8 +4,8 @@ metadata:
name: minio-secret name: minio-secret
namespace: reactbin namespace: reactbin
spec: spec:
vaultAuthRef: reactbin-auth vaultAuthRef: reactbin-vault-auth
mount: secret mount: kv
type: kv-v2 type: kv-v2
# Required Vault keys at this path: # Required Vault keys at this path:
# MINIO_ROOT_USER, MINIO_ROOT_PASSWORD # MINIO_ROOT_USER, MINIO_ROOT_PASSWORD

View File

@@ -1,7 +1,13 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: vso-reactbin
namespace: reactbin
---
apiVersion: secrets.hashicorp.com/v1beta1 apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth kind: VaultAuth
metadata: metadata:
name: reactbin-auth name: reactbin-vault-auth
namespace: reactbin namespace: reactbin
spec: spec:
method: kubernetes method: kubernetes
@@ -10,7 +16,7 @@ spec:
# The operator must create this role in Vault and bind it to the # The operator must create this role in Vault and bind it to the
# default service account in the reactbin namespace with read access # default service account in the reactbin namespace with read access
# to both reactbin/api/config and reactbin/minio/credentials. # to both reactbin/api/config and reactbin/minio/credentials.
role: reactbin role: vso-reactbin
serviceAccount: default serviceAccount: vso-reactbin
audiences: audiences:
- https://kubernetes.default.svc - vault