From 580a160300db25eee92142f79a5f2dbfc724f0bb Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Thu, 1 Oct 2026 21:34:56 +0200 Subject: [PATCH] Pipeline test --- .gitea/workflows/deploy.yml | 55 ++++++++++ README.md | 209 ++++++++++++++++++++++++++++++++++++ backend/Dockerfile | 23 ++++ docker-compose.yml | 21 ++++ frontend/Dockerfile | 32 ++++++ nginx.conf | 35 ++++++ 6 files changed, 375 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 nginx.conf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..99570a2 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Build and Deploy Nuxt + +on: + push: + branches: [master] + +jobs: + build: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install SSH client + run: | + apt-get update -y && apt-get install -y openssh-client + + - name: Setup SSH inside container + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + # Add the container host to known_hosts + ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts + + - name: Log in to registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build frontend + run: | + docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-frontend:latest ./frontend + docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-frontend:latest + + - name: Build backend + run: | + docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest ./backend + docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest + + - name: Copy files + run: | + scp docker-compose.yml deploy@${{ secrets.DEPLOY_HOST}}:/var/www/app/ + scp nginx.conf deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/nginx.conf + + - name: Deploy + run: | + ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF' + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin + cd /var/www/app/ + docker-compose pull + docker-compose up -d + EOF + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae3ccba --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Mathew — a wiki for mathematics + +A collaborative wiki of mathematical concepts. Articles are written in **Markdown** with +**LaTeX** (KaTeX), stored in MongoDB behind an Express API, and rendered by a Nuxt 4 app +as an open book with a table of contents, a table of topics, a link graph, and a +review-then-publish flow for edits. + +The interface speaks five languages — English, Spanish, Catalan, French, German — chosen +per account, and articles themselves are multilingual: one article can exist in several +languages, each with its own address. + +``` +mathew/ +├── dev.sh # start/stop both dev servers together +├── docker-compose.yml # nginx + frontend + backend (prebuilt images) +├── nginx.conf # reverse proxy: / → frontend, /api/ → backend +├── backend/ # Express 5 + Mongoose API (port 5000) +└── frontend/ # Nuxt 4 / Vue 3 wiki UI (port 3000) +``` + +## Quick start + +Requires **MongoDB** on `localhost:27017` (database `mathew`) and **Node 20+**. + +```bash +# 1. API +cd backend +npm install +cp .env.example .env # then set JWT_SECRET +npm run seed # optional: sample articles with LaTeX +npm run dev # → http://localhost:5000 + +# 2. UI +cd ../frontend +npm install +npm run dev # → http://localhost:3000 +``` + +Or run both together from the repo root: + +```bash +./dev.sh # both dev servers in the foreground (Ctrl+C stops both) +./dev.sh start|stop|restart|status +./dev.sh logs [all|backend|frontend] +``` + +### Scripts + +| Where | Command | What it does | +| --- | --- | --- | +| root | `./dev.sh [dev]` | Both dev servers, foreground, tagged output, one Ctrl+C stops both | +| `backend/` | `npm run dev` | API with `node --watch` (no build step) | +| `backend/` | `npm start` | API without watch | +| `backend/` | `npm run seed` | A handful of sample articles | +| `backend/` | `npm run seed:bulk` | 100 cross-linked articles for load-testing the UI and graph (idempotent) | +| `frontend/` | `npm run dev` / `build` / `preview` | Nuxt dev server, production build, preview of the build | + +There is **no test suite, linter, or typecheck script** configured. + +### Configuration + +| Where | Variable | Default | Meaning | +| --- | --- | --- | --- | +| `backend/.env` | `PORT` | `5000` | API port | +| `backend/.env` | `MONGO_URI` | `mongodb://127.0.0.1:27017/mathew` | Mongo connection | +| `backend/.env` | `JWT_SECRET` | *(insecure dev key)* | Signs session tokens — set this in production | +| `backend/.env` | `AUTH_TOKEN_TTL` | `7d` | How long a session lasts | +| `frontend` env | `NUXT_PUBLIC_API_BASE` | `http://localhost:5000` | Where the UI calls the API | + +## How it works + +**Reading is open; writing takes an account.** Anyone can register, and the **first account +ever created becomes the admin** (on a database whose accounts all predate roles, the oldest +one gets it at startup). The admin hands the role on or takes it back from `/admin`; nobody +may change their own role, so the dashboard always has a door. + +- An **admin**'s save applies immediately. +- A **member**'s save is answered `202` and filed as an **edit proposal** instead — the live + article does not move. Saving again while a proposal waits *revises* it, and each proposal + keeps a snapshot of the article as it read when the request was sent, so the desk can show + a diff and warn when the page has moved since. +- Proposals include **new articles**: a request for a page the wiki does not have yet keeps + the address it asks for and appears only when approved. +- Approving or rejecting happens at `/admin/proposals`, and approval runs the same code an + admin's direct write runs — so a retitle re-slugs, and a language the article already has + in another version is refused. +- **Deleting is admin-only**, and it settles that article's waiting proposals with it. + +Articles name no author, so they outlive the account that wrote them. + +### Languages + +Two language ideas, deliberately apart: + +- The **interface language** (`en`, `es`, `ca`, `fr`, `de`) is a preference and never part of + a URL. Precedence: the signed-in account's choice → the `mathew-locale` cookie → the + browser → English. Pick it in the account popup. +- The **article language** appears in the address: `/wiki//`. Every version + of an article has its own address, English included — `/wiki/en/bayes-theorem` and + `/wiki/fr/bayes-theorem` are one article two ways. The short form `/wiki/` redirects + onto the version the reader should see: their own language if the article speaks it, + English failing that. + +Switching an article's language turns the page of the open book rather than opening a second +one, and "recently viewed" remembers article + language as a single entry. + +### Themes + +Light and dark, plus eight accent colours, chosen in the settings popup and applied before +first paint so there is no flash. Colours are CSS custom properties swapped by +`data-theme` / `data-accent` on ``. + +## Pages + +| Route | What it is | +| --- | --- | +| `/` | Search, recently viewed, a way into the shelf | +| `/articles` | The shelf, filed by topic — one folder per topic, opening onto a batch-at-a-time grid of cards; language and topic filters are applied by the API | +| `/graph` | The wiki's link map: one node per article, one edge per linked pair | +| `/wiki//` | One language version of an article, drawn as a page of the book with its table of contents | +| `/wiki/` | The short form, naming no language — turned onto the version the slug speaks | +| `/wiki//edit` | Markdown editor with live preview (Write / Split / Preview) | +| `/new` | Create a new article | +| `/admin` | Accounts and roles (admin only) | +| `/admin/proposals` | The edit-request desk: diff, approve, reject (admin only) | + +## Writing articles + +GitHub-ish Markdown — headings, tables, lists, blockquotes, fenced code with syntax +highlighting — plus LaTeX: `$E = mc^2$` inline, `$$ … $$` in display mode. Number-set +macros `\R`, `\N`, `\Z`, `\Q`, `\C` are available. Raw HTML in an article is escaped, and +everything rendered is sanitized with DOMPurify. + +Link between articles with wiki links — `[[Bayes' theorem]]`, `[[Fourier series#definition]]`, +`[[Zeno's paradoxes|Zeno]]` — or with a normal Markdown link to `/wiki/` or +`/wiki//`. Both feed the graph view. + +Shortcuts: `/` focuses search on the home page, `Ctrl/Cmd+K` opens the search popup, +`Ctrl/Cmd+S` saves in the editor. Changing an article's title can change its slug — old +links then 404, as no redirects are kept. + +## API + +`GET /health` answers `{ status, mongo }`. Everything below lives under `/api`. +Reads are public; writes need `Authorization: Bearer ` from `/auth/login` or +`/auth/register`. All errors answer `{ message }`. + +### Accounts + +| Method | Endpoint | Notes | +| --- | --- | --- | +| POST | `/auth/register` | Creates the account and returns its session; the first account ever is an admin | +| POST | `/auth/login` | A wrong name and a wrong password answer the same way | +| GET | `/auth/me` | Who a token belongs to | +| PUT | `/auth/me` | Rename | +| PUT | `/auth/password` | Needs the current password | +| PUT | `/auth/locale` | Interface language — one of `en es ca fr de` | +| PUT | `/auth/avatar` | A small data URL; `""` clears it | + +### Articles + +Articles are addressed by **slug**, not by Mongo id. + +| Method | Endpoint | Notes | +| --- | --- | --- | +| GET | `/articles` | `{ articles, total, hasMore }`, paged **by article** — `?q=`, `?tag=`, `?lang=`, `?limit=`, `?offset=`; `?prefer=` picks which version stands for an article, `?preferFirst` ranks those versions first; no `limit` means the whole shelf | +| GET | `/articles/facets` | `{ total, topics, languages }` counted over the whole shelf | +| GET | `/articles/random` | One random article | +| GET | `/articles/graph` | `{ nodes, edges }` — the link map built from wiki and Markdown links | +| GET | `/articles/slug/:slug` | One article | +| POST | `/articles` | Create (`title` required; the slug is generated and uniquified). Admin applies; a member is answered `202 { proposal }` | +| PUT | `/articles/slug/:slug` | Update. Admin applies; a member is answered `202 { proposal }` | +| DELETE | `/articles/slug/:slug` | Admin only | + +### Administration + +Everything behind `requireAuth, requireAdmin`. + +| Method | Endpoint | Notes | +| --- | --- | --- | +| GET | `/admin/users` | Every account, newest first | +| PUT | `/admin/users/:id/role` | `role` is `admin` or `member`; never your own | +| DELETE | `/admin/users/:id` | Close an account — never your own; its articles stay | +| GET | `/admin/proposals` | Every request, decided ones included, with its diff material | +| GET | `/admin/proposals/count` | Just `{ pending }` — what the sidebar's badge wears | +| PUT | `/admin/proposals/:id/approve` | Writes the change; a conflict refuses it and the request keeps waiting | +| PUT | `/admin/proposals/:id/reject` | Refuses; the page stays put | + +`/api/items` is a small demo resource (CRUD over one schema) left in as a reference for how +a router, model and error path fit together. + +## Deploy + +`docker-compose.yml` runs nginx in front of prebuilt frontend and backend images: nginx +listens on `3000` and routes `/api/` to the backend and everything else to the Nuxt server, +passing the standard forwarded headers. Point `NUXT_PUBLIC_API_BASE` and `MONGO_URI` at the +deployment's real addresses and set a real `JWT_SECRET` — the fallback key is a development +convenience only, and anyone holding it can mint a session. + +## Notes for contributors + +[`AGENTS.md`](AGENTS.md) is the working map of the codebase: file-by-file layout, the +backend and frontend conventions, and the gotchas that are easy to get wrong (article +identity vs. document slug, versioned addresses, MongoDB's reserved `language` field, +localization rules). + +## License + +MIT. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d1fbd37 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +# Use official Node.js runtime +FROM node:20-alpine + +# Create app directory +WORKDIR /usr/src/app + +# Copy package files first (better caching) +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy application source +COPY . . + +# Expose the app port +# EXPOSE 3000 + +# Environment variable +ENV NODE_ENV=production + +# Start the application +CMD ["node", "src/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..898a00d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" + +services: + nginx: + image: nginx:latest + ports: + - "3000:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - frontend + - backend + restart: always + + frontend: + image: git.aranroig.com/syndria98/mathew-frontend:latest + restart: always + + backend: + image: git.aranroig.com/syndria98/mathew-backend:latest + restart: always diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f6e45ab --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,32 @@ +# ---------- Build Stage ---------- +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache python3 make g++ git + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +ARG CACHEBUST=1 +COPY . . + +# Build the Nuxt app +RUN npm run build + +# ---------- Production Stage ---------- +FROM node:20-alpine + +WORKDIR /app + +# Copy built output +COPY --from=builder /app/.output ./.output + +# Expose Nuxt port +EXPOSE 3000 + +# Start Nuxt production server +CMD ["node", ".output/server/index.mjs"] diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..507bd5c --- /dev/null +++ b/nginx.conf @@ -0,0 +1,35 @@ +events {} + +http { + server { + listen 80; + server_name _; + + # Api Requests + location /api/ { + proxy_pass http://backend:5000; + + 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 Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # Normal requests + location / { + proxy_pass http://frontend:3000; + + 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 Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + } +}