This commit is contained in:
55
.gitea/workflows/deploy.yml
Normal file
55
.gitea/workflows/deploy.yml
Normal file
@@ -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
|
||||
|
||||
209
README.md
Normal file
209
README.md
Normal file
@@ -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/<lang>/<identity>`. 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/<slug>` 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 `<html>`.
|
||||
|
||||
## 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/<lang>/<slug>` | One language version of an article, drawn as a page of the book with its table of contents |
|
||||
| `/wiki/<slug>` | The short form, naming no language — turned onto the version the slug speaks |
|
||||
| `/wiki/<slug>/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/<slug>` or
|
||||
`/wiki/<lang>/<slug>`. 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 <token>` 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.
|
||||
23
backend/Dockerfile
Normal file
23
backend/Dockerfile
Normal file
@@ -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"]
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -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
|
||||
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal file
@@ -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"]
|
||||
35
nginx.conf
Normal file
35
nginx.conf
Normal file
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user