First commit

This commit is contained in:
2026-09-30 01:49:35 +02:00
commit 6edd03aace
109 changed files with 27020 additions and 0 deletions

4
backend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=5000
MONGO_URI=mongodb://127.0.0.1:27017/mathew
JWT_SECRET=change-me-64-hex-chars
AUTH_TOKEN_TTL=7d

2
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
.env

1279
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
backend/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "mathew-backend",
"version": "1.0.0",
"description": "Minimal Node backend using Express and Mongoose",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"seed": "node src/seed.js"
},
"license": "MIT",
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"mongoose": "^8.24.4"
}
}

View File

@@ -0,0 +1,15 @@
import "dotenv/config";
// Secret that signs session tokens. Anyone holding it can mint tokens that
// pass for accounts, so it belongs in the environment and never in the repo.
// The fallback keeps `./dev.sh` working out of the box — loudly.
const DEV_SECRET = "mathew-development-secret-set-JWT_SECRET-in-backend-env";
export const JWT_SECRET = process.env.JWT_SECRET || DEV_SECRET;
// How long a sign-in lasts. Long enough to survive a weekend of writing.
export const TOKEN_TTL = process.env.AUTH_TOKEN_TTL || "7d";
if (!process.env.JWT_SECRET) {
console.warn("[auth] JWT_SECRET is not set — using an insecure development key.");
}

11
backend/src/config/db.js Normal file
View File

@@ -0,0 +1,11 @@
import mongoose from "mongoose";
export async function connectDB() {
const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/mathew";
await mongoose.connect(uri);
console.log(`MongoDB connected: ${mongoose.connection.host}`);
}
export async function disconnectDB() {
await mongoose.disconnect();
}

72
backend/src/index.js Normal file
View File

@@ -0,0 +1,72 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import mongoose from "mongoose";
import { connectDB, disconnectDB } from "./config/db.js";
import itemsRouter from "./routes/items.js";
import articlesRouter from "./routes/articles.js";
import authRouter from "./routes/auth.js";
import adminRouter from "./routes/admin.js";
import User from "./models/User.js";
const app = express();
// Middleware
app.use(cors());
// Room for a profile picture sent as a data URL (see PUT /api/auth/avatar).
app.use(express.json({ limit: "1.5mb" }));
// Routes
app.get("/health", (req, res) => {
res.json({
status: "ok",
mongo: mongoose.connection.readyState === 1 ? "connected" : "disconnected",
});
});
app.use("/api/items", itemsRouter);
app.use("/api/auth", authRouter);
app.use("/api/articles", articlesRouter);
app.use("/api/admin", adminRouter);
// 404 handler
app.use((req, res) => {
res.status(404).json({ message: "Not found" });
});
// Central error handler
app.use((err, req, res, next) => {
if (err.name === "ValidationError") {
return res.status(400).json({ message: err.message });
}
if (err.name === "CastError") {
return res.status(400).json({ message: "Invalid id format" });
}
console.error(err);
res.status(err.status || 500).json({ message: err.message || "Server error" });
});
// Start server after connecting to MongoDB
const port = process.env.PORT || 5000;
try {
await connectDB();
// Accounts opened before there were roles: the oldest of them gets one, so
// somebody can always open the dashboard and pass the role to anyone else.
const promoted = await User.ensureAnAdmin();
if (promoted) console.log(`Admin role given to the oldest account: ${promoted.username}`);
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
} catch (err) {
console.error("Failed to start server:", err.message);
process.exit(1);
}
// Graceful shutdown
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, async () => {
await disconnectDB();
process.exit(0);
});
}

View File

@@ -0,0 +1,60 @@
import jwt from "jsonwebtoken";
import { JWT_SECRET, TOKEN_TTL } from "../config/auth.js";
import User from "../models/User.js";
/** Issues a session token for an account. */
export function signToken(user) {
return jwt.sign(
{ sub: user._id.toString(), username: user.username },
JWT_SECRET,
{ expiresIn: TOKEN_TTL }
);
}
/**
* Reads the `Authorization: Bearer <token>` header and puts the account on
* `req.user`; anything else and the request stops here with 401. The message
* bodies are what the frontend keys off: a 401 means the session it holds is
* no good, so it drops it and asks the person to sign in again.
*/
export function requireAuth(req, res, next) {
const [scheme, token] = String(req.headers.authorization || "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ message: "Please sign in to do that." });
}
let session;
try {
session = jwt.verify(token, JWT_SECRET);
} catch {
return res
.status(401)
.json({ message: "That session has expired — please sign in again." });
}
req.user = { id: session.sub, username: session.username };
next();
}
/**
* Runs after `requireAuth` and lets an admin through alone. The role is read
* from the account rather than trusted from the token, so taking it away works
* the next time the dashboard is asked for — a token issued while the account
* was an admin is no good once it is not.
*/
export async function requireAdmin(req, res, next) {
try {
const account = await User.findById(req.user.id);
if (!account) {
return res.status(401).json({ message: "No such account any more." });
}
if (account.role !== "admin") {
return res.status(403).json({ message: "This is for administrators." });
}
req.user.role = account.role;
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,52 @@
import mongoose from "mongoose";
const articleSchema = new mongoose.Schema(
{
slug: {
type: String,
required: [true, "Slug is required"],
unique: true,
lowercase: true,
trim: true,
index: true,
},
title: {
type: String,
required: [true, "Title is required"],
trim: true,
},
content: {
type: String,
default: "",
},
tags: {
type: [String],
default: [],
},
// Language this version of the article is written in, as a lowercase
// ISO 639-1 code ("en", "fr", "de", ...). Defaults to English.
language: {
type: String,
default: "en",
lowercase: true,
trim: true,
},
// For a translation: the identity of the article it translates — the
// canonical (original) version's slug. Null on the canonical document.
baseSlug: {
type: String,
default: null,
lowercase: true,
trim: true,
index: true,
},
},
{ timestamps: true }
);
// Text index for search across title and tags
articleSchema.index({ title: "text", tags: "text" });
const Article = mongoose.model("Article", articleSchema);
export default Article;

View File

@@ -0,0 +1,21 @@
import mongoose from "mongoose";
const itemSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, "Name is required"],
trim: true,
},
description: {
type: String,
default: "",
trim: true,
},
},
{ timestamps: true }
);
const Item = mongoose.model("Item", itemSchema);
export default Item;

124
backend/src/models/User.js Normal file
View File

@@ -0,0 +1,124 @@
import mongoose from "mongoose";
import bcrypt from "bcryptjs";
export const ROLES = ["admin", "member"];
/* The interface languages the web app offers (mirrors its own catalogue); the
account stores its owner's choice so the wiki speaks their language on every
device. "" means no choice yet — the device's own language decides. */
export const SUPPORTED_LOCALES = ["en", "es", "ca", "fr", "de"];
export const USERNAME_REGEX = /^[a-z0-9_.-]{3,30}$/;
export const USERNAME_MESSAGE =
"Username must be 3-30 characters: letters, numbers, dot, dash or underscore.";
export const PASSWORD_MIN = 8;
export const PASSWORD_MESSAGE = `Password must be at least ${PASSWORD_MIN} characters.`;
/**
* The one place account rules are written down: both the schema and the routes
* check through this, so they cannot drift apart. Returns a message, or null
* when the pair is usable.
*/
export function credentialProblem({ username, password }) {
if (!USERNAME_REGEX.test(username)) return USERNAME_MESSAGE;
if (password.length < PASSWORD_MIN) return PASSWORD_MESSAGE;
if (password.length > 200) return "Password is too long.";
return null;
}
const userSchema = new mongoose.Schema(
{
username: {
type: String,
required: [true, "Username is required"],
unique: true,
lowercase: true,
trim: true,
index: true,
match: [USERNAME_REGEX, USERNAME_MESSAGE],
},
// Hashed on the way in and left out of queries by default: no route sends
// a password, hashed or otherwise, back to a client.
passwordHash: {
type: String,
required: true,
select: false,
},
// Profile picture kept as a data URL (the frontend shrinks it before
// sending), so the avatar travels in the user object itself and the API
// needs no file storage of its own. "" means no picture: an initial shows.
avatar: {
type: String,
default: "",
},
/* Two kinds of account. A member reads and writes like anyone else; an admin
additionally opens the dashboard of accounts and hands the role on. It is
stored on the account rather than carried in the session token, so taking
it away takes effect the next time a page asks. */
role: {
type: String,
enum: { values: ROLES, message: `Role is one of: ${ROLES.join(", ")}.` },
default: "member",
},
locale: {
type: String,
enum: {
values: ["", ...SUPPORTED_LOCALES],
message: `Language is one of: ${SUPPORTED_LOCALES.join(", ")}.`,
},
default: "",
lowercase: true,
trim: true,
},
},
{ timestamps: true }
);
/** Hashes the plain password into `passwordHash`. The plain one is never saved. */
userSchema.methods.setPassword = async function setPassword(plain) {
this.passwordHash = await bcrypt.hash(String(plain), 10);
return this;
};
/** Checks a plain password against the stored hash; needs that field selected. */
userSchema.methods.checkPassword = function checkPassword(plain) {
return bcrypt.compare(String(plain ?? ""), this.passwordHash);
};
/** The shape the API returns for an account: never the hash. */
userSchema.methods.toPublic = function toPublic() {
return {
id: this._id.toString(),
username: this.username,
avatar: this.avatar ?? "",
// Accounts opened before the role existed answer as plain members.
role: this.role === "admin" ? "admin" : "member",
// "" until the person chooses one in the settings popup.
locale: this.locale ?? "",
};
};
/* Someone has to be able to hand the admin role on, so an empty wiki gives it
to the first account it ever registers (see routes/auth.js). */
userSchema.statics.hasNone = async function hasNone() {
return (await this.estimatedDocumentCount()) === 0;
};
/* And a wiki whose accounts all predate the role gives it to the oldest of
them, once, at startup — otherwise the dashboard would have nobody who can
open it. Answers the account it promoted, or null when there was nothing to
do: an admin already about, or no account at all yet. */
userSchema.statics.ensureAnAdmin = async function ensureAnAdmin() {
if (await this.exists({ role: "admin" })) return null;
const oldest = await this.findOne().sort({ createdAt: 1 });
if (!oldest) return null;
oldest.role = "admin";
await oldest.save();
return oldest;
};
const User = mongoose.model("User", userSchema);
export default User;

View File

@@ -0,0 +1,79 @@
import { Router } from "express";
import User, { ROLES } from "../models/User.js";
import { requireAdmin, requireAuth } from "../middleware/auth.js";
const router = Router();
/* The back office of the wiki: who holds an account, and what each of them may
do. Everything here takes an admin — `requireAdmin` reads the role off the
account, so a session outlives its own promotion the moment it is undone. */
/** The shape the dashboard is given for one account: the public fields, plus
when it was opened. */
function accountFor(user) {
return { ...user.toPublic(), createdAt: user.createdAt };
}
// GET /api/admin/users — every account on the wiki, newest first. The pictures
// travel along as data URLs, which is what the list needs to look like the rest
// of the wiki; the wiki holds a handful of accounts, not thousands.
router.get("/users", requireAuth, requireAdmin, async (req, res, next) => {
try {
const users = await User.find().sort({ createdAt: -1 });
res.json({ accounts: users.map(accountFor) });
} catch (err) {
next(err);
}
});
// PUT /api/admin/users/:id/role — hand the admin role to an account, or take it
// back. An admin's own role is the one they cannot change: whoever else is
// already an admin does that, so the wiki is never left without one.
router.put("/users/:id/role", requireAuth, requireAdmin, async (req, res, next) => {
try {
const role = String(req.body?.role ?? "");
if (!ROLES.includes(role)) {
return res.status(400).json({ message: `Role is one of: ${ROLES.join(", ")}.` });
}
const account = await User.findById(req.params.id);
if (!account) return res.status(404).json({ message: "No such account." });
if (role !== "admin" && account._id.equals(req.user.id)) {
return res
.status(400)
.json({ message: "Another admin has to step you down from the role." });
}
if (account.role === role) return res.json({ account: accountFor(account) });
account.role = role;
await account.save();
res.json({ account: accountFor(account) });
} catch (err) {
next(err);
}
});
// DELETE /api/admin/users/:id — close an account for good. An admin's own
// account is the one they cannot close, the same guard as the role: somebody
// else has to do it, so the wiki is never left without a way back in. The
// articles outlive the account — none of them ever named an author.
router.delete("/users/:id", requireAuth, requireAdmin, async (req, res, next) => {
try {
const account = await User.findById(req.params.id);
if (!account) return res.status(404).json({ message: "No such account." });
if (account._id.equals(req.user.id)) {
return res
.status(400)
.json({ message: "Another admin has to close your own account." });
}
await account.deleteOne();
res.json({ message: "Account deleted." });
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,361 @@
import { Router } from "express";
import Article from "../models/Article.js";
import { requireAuth } from "../middleware/auth.js";
const router = Router();
// Reading the wiki is open to everyone; the write endpoints at the bottom of
// this file take an account — see routes/auth.js for how one is obtained.
// Convert a title (or any string) into a URL-safe slug
export function slugify(str) {
return String(str)
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
// Make a slug unique by appending -2, -3, ... if needed.
// Optionally pass `excludeSlug` when the slug is unchanged during updates.
async function uniqueSlug(base, excludeSlug = null) {
const root = slugify(base) || "article";
let candidate = root;
let n = 1;
// eslint-disable-next-line no-constant-condition
while (true) {
const existing = await Article.findOne({ slug: candidate }).select("slug").lean();
if (!existing || existing.slug === excludeSlug) return candidate;
candidate = `${root}-${++n}`;
}
}
// "FR " -> "fr"; whatever is stored on an article is what queries see.
function languageCode(value) {
const code = String(value ?? "en").trim().toLowerCase();
return code || "en";
}
// The language versions of one article: every document sharing its identity —
// the canonical (original) version's slug. Translations carry that slug as
// `baseSlug`; the canonical document answers to it as its own `slug`.
async function versionsOf(identity) {
// not lean: hydration fills in the language default for older documents
return Article.find({ $or: [{ slug: identity }, { baseSlug: identity }] })
.select("slug title language updatedAt")
.sort({ language: 1 });
}
/**
* Resolve one article by its identity, optionally a specific language:
* without `lang` the canonical version answers (or the document whose slug
* was addressed, which also covers older `/slug-fr` addresses); with `lang`
* the version written in that language — `/wiki/fr/euler` on the front end.
*/
async function resolveVersion(slug, lang) {
const base = await Article.findOne({ slug });
if (!lang) return base;
const wanted = languageCode(lang);
if (base && languageCode(base.language) === wanted) return base;
const identity = base?.baseSlug || slug;
// a language address with no version in that language answers honestly: 404
return Article.findOne({ baseSlug: identity, language: wanted });
}
// GET /api/articles?q=&tag=&lang= - list articles (without content body).
// One entry per article regardless of how many languages it exists in: the
// canonical version answers with every language version attached, and with
// ?lang= the version written in that language answers instead.
router.get("/", async (req, res, next) => {
try {
const { q, tag, lang } = req.query;
const filter = {};
if (typeof q === "string" && q.trim()) {
const safe = q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const rx = new RegExp(safe, "i");
filter.$or = [{ title: rx }, { tags: rx }];
}
if (typeof tag === "string" && tag.trim()) {
filter.tags = tag.trim().toLowerCase();
}
const wantedLang = typeof lang === "string" && lang.trim() ? languageCode(lang) : "";
if (wantedLang) {
// articles written before the language field existed are English
filter.language = wantedLang === "en" ? { $in: ["en", null] } : wantedLang;
}
const matched = await Article.find(filter).select("-content").sort({ updatedAt: -1 });
if (!matched.length) return res.json([]);
// pull in the versions of every matched article so each can be counted
// as one article and carry the full list of its languages
const identities = [...new Set(matched.map((a) => a.baseSlug || a.slug))];
const groupDocs = await Article.find({
$or: [{ slug: { $in: identities } }, { baseSlug: { $in: identities } }],
})
.select("-content")
.sort({ updatedAt: -1 }); // not lean: hydration fills in old defaults
const versionsByIdentity = new Map(identities.map((id) => [id, []]));
for (const a of groupDocs) {
versionsByIdentity.get(a.baseSlug || a.slug)?.push(a);
}
const matchedSlugs = new Set(matched.map((a) => a.slug));
const entries = identities.map((id) => {
const group = versionsByIdentity.get(id) ?? [];
const asVersion = (a) => ({ ...a.toObject(), versions: group });
if (wantedLang) {
const inWanted = group.find((a) => languageCode(a.language) === wantedLang);
if (inWanted) return asVersion(inWanted);
}
const rep = group.find((a) => !a.baseSlug) ?? group.find((a) => matchedSlugs.has(a.slug)) ?? group[0];
return asVersion(rep);
});
entries.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
res.json(entries);
} catch (err) {
next(err);
}
});
// GET /api/articles/random - one random article. An article counts once no
// matter how many languages it exists in, so versions ride along with their
// canonical document; in a shelf of orphans any version stands in for itself.
router.get("/random", async (req, res, next) => {
try {
const [sampled] = await Article.aggregate([
{ $match: { baseSlug: null } },
{ $sample: { size: 1 } },
]);
const [article] = sampled
? [sampled]
: await Article.aggregate([{ $match: { baseSlug: { $type: "string" } } }, { $sample: { size: 1 } }]);
if (!article) return res.status(404).json({ message: "No articles yet" });
res.json(article);
} catch (err) {
next(err);
}
});
// Links inside article content, as the graph sees them: wiki links
// ([[Target]], with optional #section and |display) and markdown links
// pointing at /wiki/<slug>. Global so String#matchAll resumes per scan.
const WIKILINK_RX = /\[\[([^\[\]|#]+)(?:#[^\[\]|]*)?(?:\|[^\[\]]*)?\]\]/g;
const WIKI_URL_RX = /\]\((?:https?:\/\/[^/\s)]+)?\/?wiki\/([A-Za-z0-9][A-Za-z0-9_-]*)/g;
// GET /api/articles/graph - the wiki's link map for the graph view: one node
// per article — versions of the same article share a node — and one undirected
// edge per linked pair. Code spans never draw links; self-links and links to
// articles that don't exist drop out, and repeated links between the same pair
// collapse into a single edge.
router.get("/graph", async (req, res, next) => {
try {
const articles = await Article.find()
.select("slug baseSlug title tags content")
.lean();
const identityOf = (a) => a.baseSlug || a.slug;
const known = new Set(articles.map(identityOf));
const degree = new Map(articles.map((a) => [identityOf(a), 0]));
const edges = [];
const seen = new Set();
const addEdge = (from, to) => {
if (!to || from === to || !known.has(to)) return;
const key = from < to ? `${from}\u0000${to}` : `${to}\u0000${from}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({ source: from, target: to });
degree.set(from, degree.get(from) + 1);
degree.set(to, degree.get(to) + 1);
};
for (const article of articles) {
const content = (article.content ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`\n]*`/g, " ");
for (const m of content.matchAll(WIKILINK_RX)) {
addEdge(identityOf(article), slugify(m[1]));
}
for (const m of content.matchAll(WIKI_URL_RX)) {
addEdge(identityOf(article), m[1].toLowerCase());
}
}
// the node speaks with its canonical article's title, if it still has one
const nodeByIdentity = new Map();
for (const article of articles) {
const identity = identityOf(article);
const node = nodeByIdentity.get(identity) ?? {
slug: identity,
title: article.title,
tags: [],
links: degree.get(identity) ?? 0,
};
if (!article.baseSlug) node.title = article.title;
for (const t of article.tags ?? []) if (!node.tags.includes(t)) node.tags.push(t);
nodeByIdentity.set(identity, node);
}
res.json({ nodes: [...nodeByIdentity.values()], edges });
} catch (err) {
next(err);
}
});
// GET /api/articles/slug/:slug - get one article by slug, together with the
// language versions the same article exists in (including itself). With
// ?lang=fr the version written in that language answers, falling back to the
// canonical one when it exists in no such language.
router.get("/slug/:slug", async (req, res, next) => {
try {
const lang = typeof req.query.lang === "string" ? req.query.lang.trim() : "";
const article = await resolveVersion(req.params.slug, lang);
if (!article) return res.status(404).json({ message: "Article not found" });
const versions = await versionsOf(article.baseSlug || article.slug);
res.json({ ...article.toObject(), versions });
} catch (err) {
next(err);
}
});
// POST /api/articles - create an article (sign-in required; reading is public,
// writing the wiki takes an account). Passing `baseSlug` makes the new document
// another language version of an existing article rather than a fresh one: it
// joins the original's version group and takes a slug beside it (slug-fr, …).
router.post("/", requireAuth, async (req, res, next) => {
try {
const { title, content = "", tags = [], baseSlug } = req.body || {};
const language = languageCode(req.body?.language);
if (!title || !String(title).trim()) {
return res.status(400).json({ message: "Title is required" });
}
let identity = null;
if (baseSlug) {
const base = await Article.findOne({ slug: slugify(baseSlug) });
if (!base) {
return res.status(400).json({ message: "The article being translated does not exist" });
}
identity = base.baseSlug || base.slug;
} else {
// Two articles must not share a name in different languages: when the
// slug is taken by an article written in another language, the document
// joins it as a version rather than standing beside it as a twin. (The
// same name in the same language stays the old `-2` suffixed sibling.)
const byName = await Article.findOne({
slug: slugify(req.body.slug || title),
});
if (byName && languageCode(byName.language) !== language) {
identity = byName.baseSlug || byName.slug;
}
}
if (identity) {
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
language,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${language}` });
}
}
const requested = req.body.slug
? slugify(req.body.slug)
: identity
? `${identity}-${language}` // versions sit side by side: slug-fr, slug-de, ...
: slugify(title);
const slug = await uniqueSlug(requested);
const article = await Article.create({
title,
content,
tags,
language,
slug,
baseSlug: identity,
});
res.status(201).json(article);
} catch (err) {
next(err);
}
});
// PUT /api/articles/slug/:slug - update an article (sign-in required)
router.put("/slug/:slug", requireAuth, async (req, res, next) => {
try {
const { title, content, tags, language } = req.body || {};
if (title !== undefined && !String(title).trim()) {
return res.status(400).json({ message: "Title is required" });
}
const article = await Article.findOne({ slug: req.params.slug });
if (!article) return res.status(404).json({ message: "Article not found" });
const update = {};
if (title !== undefined) update.title = title;
if (content !== undefined) update.content = content;
if (tags !== undefined) update.tags = tags;
// Moving this version to another language must not collide with a
// version the article already has in that language.
if (language !== undefined) {
const code = languageCode(language);
if (code !== article.language) {
const identity = article.baseSlug || article.slug;
const clash = await Article.findOne({
$or: [{ slug: identity }, { baseSlug: identity }],
slug: { $ne: article.slug },
language: code,
});
if (clash) {
return res
.status(400)
.json({ message: `This article already has a version in ${code}` });
}
update.language = code;
}
}
// Re-slug when the title changes and no explicit slug was provided
if (title !== undefined) {
const requested = req.body.slug ? slugify(req.body.slug) : slugify(title);
if (requested && requested !== article.slug) {
update.slug = await uniqueSlug(requested, article.slug);
}
}
const updated = await Article.findOneAndUpdate(
{ slug: req.params.slug },
update,
{ new: true, runValidators: true }
);
// The version group's identity is the canonical article's slug: when the
// canonical is renamed, its translations follow it to the new slug.
if (updated && !article.baseSlug && update.slug) {
await Article.updateMany({ baseSlug: article.slug }, { baseSlug: update.slug });
}
res.json(updated);
} catch (err) {
next(err);
}
});
// DELETE /api/articles/slug/:slug - delete an article (sign-in required).
// Deleting the canonical version leaves its translations readable: they still
// recognise each other through the baseSlug they share.
router.delete("/slug/:slug", requireAuth, async (req, res, next) => {
try {
const deleted = await Article.findOneAndDelete({ slug: req.params.slug });
if (!deleted) return res.status(404).json({ message: "Article not found" });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

189
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,189 @@
import { Router } from "express";
import User, {
SUPPORTED_LOCALES,
USERNAME_REGEX,
USERNAME_MESSAGE,
PASSWORD_MIN,
PASSWORD_MESSAGE,
credentialProblem,
} from "../models/User.js";
import { requireAuth, signToken } from "../middleware/auth.js";
const router = Router();
/* A profile picture arrives as a data URL the frontend shrank. The cap sits
under the JSON body limit in index.js: anything that slips past the
frontend's own resizing still cannot fill a request whole. */
const AVATAR_MAX_CHARS = 1_000_000;
const AVATAR_REGEX = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/;
/* Register and login share a shape: a token for whoever the account turns out
to be, plus the few public fields the frontend displays. */
function sessionFor(user) {
return { token: signToken(user), user: user.toPublic() };
}
function readCredentials(body) {
return {
username: String(body?.username ?? "").trim().toLowerCase(),
// A password is taken exactly as typed — only the username is normalised.
password: String(body?.password ?? ""),
};
}
// POST /api/auth/register — open an account and sign straight into it
router.post("/register", async (req, res, next) => {
try {
const credentials = readCredentials(req.body);
const problem = credentialProblem(credentials);
if (problem) return res.status(400).json({ message: problem });
const user = new User({ username: credentials.username });
/* The wiki's very first account is its admin: somebody has to be able to
open the dashboard and hand the role to anyone else. (Two registrations
racing on an empty wiki could both see it empty; the name is still decided
by the unique index, and another admin can undo the extra role at once.) */
if (await User.hasNone()) user.role = "admin";
await user.setPassword(credentials.password);
try {
await user.save();
} catch (err) {
// Two registrations for the same name at once: the unique index decides.
if (err?.code === 11000) {
return res.status(409).json({ message: "That username is taken." });
}
throw err;
}
res.status(201).json(sessionFor(user));
} catch (err) {
next(err);
}
});
// POST /api/auth/login — swap a username and password for a session token
router.post("/login", async (req, res, next) => {
try {
const { username, password } = readCredentials(req.body);
const user = await User.findOne({ username }).select("+passwordHash");
// Wrong name and wrong password answer alike, so the form gives nothing
// away about which accounts exist.
if (!user || !(await user.checkPassword(password))) {
return res.status(401).json({ message: "Wrong username or password." });
}
res.json(sessionFor(user));
} catch (err) {
next(err);
}
});
// GET /api/auth/me — who a token belongs to; the frontend asks this on load
router.get("/me", requireAuth, async (req, res, next) => {
try {
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/me — change the username on the account the token belongs to
router.put("/me", requireAuth, async (req, res, next) => {
try {
const username = String(req.body?.username ?? "").trim().toLowerCase();
if (!USERNAME_REGEX.test(username)) {
return res.status(400).json({ message: USERNAME_MESSAGE });
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
if (user.username === username) return res.json({ user: user.toPublic() });
user.username = username;
try {
await user.save();
} catch (err) {
// Someone else claimed the name while this was in flight.
if (err?.code === 11000) {
return res.status(409).json({ message: "That username is taken." });
}
throw err;
}
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/password — swap the current password for a new one. The
// current one is asked for, so a stolen token alone cannot lock its owner out.
router.put("/password", requireAuth, async (req, res, next) => {
try {
const currentPassword = String(req.body?.currentPassword ?? "");
const newPassword = String(req.body?.newPassword ?? "");
if (newPassword.length < PASSWORD_MIN) {
return res.status(400).json({ message: PASSWORD_MESSAGE });
}
if (newPassword.length > 200) {
return res.status(400).json({ message: "Password is too long." });
}
const user = await User.findById(req.user.id).select("+passwordHash");
if (!user) return res.status(401).json({ message: "No such account any more." });
if (!(await user.checkPassword(currentPassword))) {
return res.status(401).json({ message: "That is not your current password." });
}
await user.setPassword(newPassword);
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/locale — the interface language saved on the account, so the
// choice follows the person across devices; "" hands the decision back to the
// browser. Only the supported languages are accepted.
router.put("/locale", requireAuth, async (req, res, next) => {
try {
const locale = String(req.body?.locale ?? "").trim().toLowerCase();
if (locale && !SUPPORTED_LOCALES.includes(locale)) {
return res.status(400).json({ message: `Language is one of: ${SUPPORTED_LOCALES.join(", ")}.` });
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
user.locale = locale;
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
// PUT /api/auth/avatar — a new profile picture as a data URL; "" removes it
router.put("/avatar", requireAuth, async (req, res, next) => {
try {
const avatar = String(req.body?.avatar ?? "");
if (avatar) {
if (avatar.length > AVATAR_MAX_CHARS) {
return res.status(413).json({ message: "That picture is too large." });
}
if (!AVATAR_REGEX.test(avatar)) {
return res.status(400).json({ message: "Send a PNG, JPEG, WebP or GIF image." });
}
}
const user = await User.findById(req.user.id);
if (!user) return res.status(401).json({ message: "No such account any more." });
user.avatar = avatar;
await user.save();
res.json({ user: user.toPublic() });
} catch (err) {
next(err);
}
});
export default router;

View File

@@ -0,0 +1,62 @@
import { Router } from "express";
import Item from "../models/Item.js";
const router = Router();
// GET /api/items - list all items
router.get("/", async (req, res, next) => {
try {
const items = await Item.find().sort({ createdAt: -1 });
res.json(items);
} catch (err) {
next(err);
}
});
// GET /api/items/:id - get one item
router.get("/:id", async (req, res, next) => {
try {
const item = await Item.findById(req.params.id);
if (!item) return res.status(404).json({ message: "Item not found" });
res.json(item);
} catch (err) {
next(err);
}
});
// POST /api/items - create an item
router.post("/", async (req, res, next) => {
try {
const item = await Item.create(req.body);
res.status(201).json(item);
} catch (err) {
next(err);
}
});
// PUT /api/items/:id - update an item
router.put("/:id", async (req, res, next) => {
try {
const item = await Item.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!item) return res.status(404).json({ message: "Item not found" });
res.json(item);
} catch (err) {
next(err);
}
});
// DELETE /api/items/:id - delete an item
router.delete("/:id", async (req, res, next) => {
try {
const item = await Item.findByIdAndDelete(req.params.id);
if (!item) return res.status(404).json({ message: "Item not found" });
res.status(204).send();
} catch (err) {
next(err);
}
});
export default router;

410
backend/src/seed.js Normal file
View File

@@ -0,0 +1,410 @@
import "dotenv/config";
import mongoose from "mongoose";
import { connectDB, disconnectDB } from "./config/db.js";
import Article from "./models/Article.js";
const articles = [
{
slug: "pythagorean-theorem",
title: "Pythagorean theorem",
tags: ["geometry", "euclidean", "triangles"],
content: `# Pythagorean theorem
In Euclidean geometry, the **Pythagorean theorem** states that in a right-angled triangle, the area of the square on the hypotenuse is equal to the sum of the areas of the squares on the other two sides:
$$
a^2 + b^2 = c^2
$$
where $c$ denotes the length of the hypotenuse and $a$ and $b$ the lengths of the other two sides.
## Examples
- A $(3, 4, 5)$ triangle: $3^2 + 4^2 = 9 + 16 = 25 = 5^2$ ✓
- A $(5, 12, 13)$ triangle: $5^2 + 12^2 = 25 + 144 = 169 = 13^2$ ✓
## A proof by rearrangement
Consider a square of side $a+b$. Its area can be computed two ways:
$$
(a+b)^2 = 4 \\cdot \\frac{1}{2}ab + c^2
$$
Expanding the left side gives $a^2 + 2ab + b^2 = 2ab + c^2$, and subtracting $2ab$ from both sides yields $a^2 + b^2 = c^2$. $\\blacksquare$
## Generalizations
| Theorem | Setting | Formula |
| --- | --- | --- |
| Law of cosines | Euclidean | $c^2 = a^2 + b^2 - 2ab\\cos\\gamma$ |
| de Gua's theorem | 3D (tetrahedron) | $V_0^2 = V_1^2 + V_2^2 + V_3^2$ |
> The theorem is named after Pythagoras of Samos (c. 570 – c. 495 BC), although Babylonian tablets such as Plimpton 322 predate him by a millennium.
## Distance formula
The theorem underpins the Euclidean distance between two points $P_1=(x_1, y_1)$ and $P_2=(x_2, y_2)$:
$$
d = \\sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}
$$
`,
},
{
slug: "eulers-identity",
title: "Euler's identity",
tags: ["complex analysis", "constants", "analysis"],
content: `# Euler's identity
**Euler's identity** is the equality
$$
e^{i\\pi} + 1 = 0
$$
It is a special case of Euler's formula $e^{i\\theta} = \\cos\\theta + i\\sin\\theta$ evaluated at $\\theta = \\pi$.
## Why it is celebrated
The identity links five fundamental constants:
| Constant | Significance |
| --- | --- |
| $e$ | base of the natural logarithm |
| $i$ | the imaginary unit, $i^2 = -1$ |
| $\\pi$ | ratio of circumference to diameter |
| $1$ | multiplicative identity |
| $0$ | additive identity |
...using only the operations of exponentiation, multiplication (implicit), and addition.
## Derivation from Taylor series
These series are Taylor expansions at $0$, assembled from [[Derivative|derivatives]]:
$$
e^x = \\sum_{n=0}^{\\infty} \\frac{x^n}{n!}, \\quad
\\cos x = \\sum_{n=0}^{\\infty} \\frac{(-1)^n x^{2n}}{(2n)!}, \\quad
\\sin x = \\sum_{n=0}^{\\infty} \\frac{(-1)^n x^{2n+1}}{(2n+1)!}
$$
substituting $x = i\\theta$ and separating real and imaginary parts gives Euler's formula. Setting $\\theta = \\pi$:
$$
e^{i\\pi} = \\cos\\pi + i\\sin\\pi = -1 + 0i = -1
$$
## General case
For any integer $k$, the $n$-th roots of unity satisfy
$$
z_k = e^{2\\pi i k / n}, \\qquad k = 0, 1, \\dots, n-1
$$
and Euler's identity is the case $n = 2$, $k = 1$. Split the fifth roots ($n = 5$) and the [[Golden ratio|golden ratio]] turns up: $\\cos 72^\\circ = (\\sqrt{5} - 1)/4$.
> "Like a Shakespearean sonnet that captures the very essence of love, or a painting that brings out the beauty of the human form, Euler's equation reaches down into the very depths of existence." — Keith Devlin
`,
},
{
slug: "derivative",
title: "Derivative",
tags: ["calculus", "analysis", "foundations"],
content: `# Derivative
In mathematics, the **derivative** of a function of one variable at a point is the rate of change of the function near that point — the slope of the tangent line to the graph of the function at that point.
## Definition
The derivative of $f$ at $x$ is defined as the limit of the difference quotient:
$$
f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}
$$
if this limit exists. An equivalent formulation:
$$
f'(a) = \\lim_{x \\to a} \\frac{f(x) - f(a)}{x - a}
$$
## Basic rules
Let $u$ and $v$ be differentiable functions. Then:
| Rule | Formula |
| --- | --- |
| Sum | $(u+v)' = u' + v'$ |
| Product | $(uv)' = u'v + uv'$ |
| Quotient | $\\left(\\frac{u}{v}\\right)' = \\frac{u'v - uv'}{v^2}$ |
| Chain | $(u \\circ v)'(x) = u'(v(x)) \\cdot v'(x)$ |
## Common derivatives
$$
\\frac{d}{dx} x^n = n x^{n-1}, \\qquad
\\frac{d}{dx} e^x = e^x, \\qquad
\\frac{d}{dx} \\ln x = \\frac{1}{x}
$$
$$
\\frac{d}{dx} \\sin x = \\cos x, \\qquad
\\frac{d}{dx} \\cos x = -\\sin x
$$
## Example
For $f(x) = x^3$, the difference quotient is
$$
\\frac{(x+h)^3 - x^3}{h} = \\frac{3x^2h + 3xh^2 + h^3}{h} = 3x^2 + 3xh + h^2 \\xrightarrow{h \\to 0} 3x^2
$$
so $f'(x) = 3x^2$.
## Numerical check
\`\`\`python
def derivative(f, x, h=1e-8):
return (f(x + h) - f(x)) / h
f = lambda x: x**3
print(derivative(f, 2.0)) # ~12.0
\`\`\`
## Related concepts
- The **integral** is, roughly, the inverse operation (see [[Fundamental theorem of calculus]]).
- A function whose derivative exists everywhere is called *differentiable*; differentiability implies *continuity*, but not conversely (e.g. $f(x) = |x|$ at $x = 0$).
`,
},
{
slug: "bayes-theorem",
title: "Bayes' theorem",
tags: ["probability", "statistics", "machine learning"],
content: `# Bayes' theorem
**Bayes' theorem** describes how the probability of an event should be updated given prior knowledge related to the event. It is the foundation of Bayesian statistics.
## Statement
For events $A$ and $B$ with $P(B) \\neq 0$:
$$
P(A \\mid B) = \\frac{P(B \\mid A)\\, P(A)}{P(B)}
$$
In terms of a *hypothesis* $H$ and *evidence* $E$:
$$
\\underbrace{P(H \\mid E)}_{\\text{posterior}} =
\\frac{\\overbrace{P(E \\mid H)}^{\\text{likelihood}} \\cdot \\overbrace{P(H)}^{\\text{prior}}}
{\\underbrace{P(E)}_{\\text{evidence}}}
$$
## The law of total probability
The denominator can be expanded when $H_1, \\dots, H_n$ partition the sample space:
$$
P(E) = \\sum_{i=1}^{n} P(E \\mid H_i) P(H_i)
$$
## Example: medical testing
A disease affects 1% of the population ($P(D) = 0.01$). A test is 99% sensitive and 95% specific. What is the probability that a positive test result means the patient has the disease?
$$
P(D \\mid +) = \\frac{P(+ \\mid D) P(D)}{P(+ \\mid D)P(D) + P(+ \\mid D^c)P(D^c)}
= \\frac{0.99 \\cdot 0.01}{0.99 \\cdot 0.01 + 0.05 \\cdot 0.99} \\approx 0.167
$$
Even a positive result implies only a **16.7%** chance of disease — the base rate dominates. This is the *base rate fallacy*.
## Conjugate priors
For a Bernoulli likelihood with $\\mathrm{Beta}(\\alpha, \\beta)$ prior:
$$
\\theta \\mid \\text{data} \\sim \\mathrm{Beta}\\!\\left(\\alpha + \\sum_i x_i, \\; \\beta + n - \\sum_i x_i\\right)
$$
the posterior remains in the same family — a computationally convenient property. When the data are noisy measurements rather than counts, the likelihood is usually a [[Normal distribution]], and the same machinery keeps the posterior normal.
`,
},
{
slug: "golden-ratio",
title: "Golden ratio",
tags: ["number theory", "geometry", "sequences"],
content: `# Golden ratio
The **golden ratio** $\\varphi$ (phi) is the positive number satisfying
$$
\\varphi = 1 + \\frac{1}{\\varphi}
$$
which gives the closed form
$$
\\varphi = \\frac{1 + \\sqrt{5}}{2} = 1.6180339887\\ldots
$$
The $\\sqrt{5}$ is no accident — by the [[Pythagorean theorem]] it is the hypotenuse of a right triangle with legs $1$ and $2$.
## Geometric definition
Two quantities $a$ and $b$ (with $a > b > 0$) are in the golden ratio when
$$
\\frac{a+b}{a} = \\frac{a}{b} = \\varphi
$$
## Connection to Fibonacci
The ratio of consecutive Fibonacci numbers converges to $\\varphi$:
$$
\\lim_{n \\to \\infty} \\frac{F_{n+1}}{F_n} = \\varphi
$$
Using Binet's formula,
$$
F_n = \\frac{\\varphi^n - \\psi^n}{\\sqrt{5}}, \\qquad \\psi = \\frac{1 - \\sqrt{5}}{2} = -\\frac{1}{\\varphi}
$$
## Properties
- $\\varphi^2 = \\varphi + 1$, so $\\varphi^3 = 2\\varphi + 1$, etc.
- $\\dfrac{1}{\\varphi} = \\varphi - 1 = 0.6180\\ldots$
- Continued fraction: $\\varphi = 1 + \\cfrac{1}{1 + \\cfrac{1}{1 + \\cfrac{1}{\\ddots}}}$ — the "most irrational" number, since all partial quotients are 1.
## In geometry
The diagonal of a regular pentagon is $\\varphi$ times its side. In a regular pentagon with side $s$:
$$
d = \\varphi s
$$
The *golden spiral* is formed by quarter-circles inscribed in golden rectangles.
> Beware: many claimed appearances of $\\varphi$ in art and nature (the Parthenon, the nautilus shell) are folklore rather than measurement.
`,
},
{
slug: "fundamental-theorem-of-calculus",
title: "Fundamental theorem of calculus",
tags: ["calculus", "analysis", "foundations"],
content: `# Fundamental theorem of calculus
The **fundamental theorem of calculus** (FTC) links the two central operations of calculus: [[Derivative|differentiation]] and integration.
## Part I
If $f$ is continuous on $[a, b]$ and
$$
F(x) = \\int_a^x f(t)\\, dt
$$
then $F$ is differentiable on $(a, b)$ and
$$
F'(x) = f(x)
$$
## Part II
If $f$ is continuous on $[a, b]$ and $G$ is any antiderivative of $f$, then
$$
\\int_a^b f(x)\\, dx = G(b) - G(a)
$$
## Example
Compute $\\displaystyle\\int_0^1 x^2\\, dx$:
$$
\\int_0^1 x^2\\, dx = \\left[ \\frac{x^3}{3} \\right]_0^1 = \\frac{1}{3} - 0 = \\frac{1}{3}
$$
## Why it matters
- It turns *area computations* into *anti-differentiation* — a purely algebraic task.
- Part I guarantees antiderivatives exist for every continuous function.
- In numerical analysis, the error of quadrature rules is typically expressed through derivatives of the integrand — e.g. the trapezoidal rule error $\\displaystyle \\frac{(b-a)^3}{12} f''(\\xi)$.
## Sketch of the proof of Part I
For $h > 0$:
$$
\\frac{F(x+h) - F(x)}{h} = \\frac{1}{h}\\int_x^{x+h} f(t)\\,dt
$$
Since $f$ is continuous, the average value of $f$ over $[x, x+h]$ lies between $\\min_{[x,x+h]} f$ and $\\max_{[x,x+h]} f$, both of which tend to $f(x)$ as $h \\to 0$. $\\blacksquare$
`,
},
{
slug: "normal-distribution",
title: "Normal distribution",
tags: ["probability", "statistics", "distributions"],
content: `# Normal distribution
The **normal distribution** (or *Gaussian*) with mean $\\mu$ and variance $\\sigma^2$ has density
$$
f(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}} \\, e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}
$$
## Total probability
A density must integrate to one — the [[Fundamental theorem of calculus]] at work, with the Gaussian integral $\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}$ supplying the normalising constant.
## In Bayesian inference
For noisy measurements the normal is the standard likelihood, and the conjugate-prior machinery of [[Bayes' theorem]] keeps the posterior normal whenever the prior is:
$$
\\mu \\mid \\text{data} \\sim \\mathcal{N}\\!\\left(\\frac{n\\bar{x}/\\sigma^2 + \\mu_0/\\tau^2}{n/\\sigma^2 + 1/\\tau^2},\\; \\left(\\frac{n}{\\sigma^2} + \\frac{1}{\\tau^2}\\right)^{-1}\\right)
$$
## Why it is everywhere
The central limit theorem explains the ubiquity: sums of many small independent effects drift toward the bell curve, whatever the individual effects look like.
`,
},
];
async function seed() {
await connectDB();
let created = 0;
let updated = 0;
for (const a of articles) {
const existing = await Article.findOne({ slug: a.slug });
if (existing) {
// only fill content if article is empty, never clobber user edits
if (!existing.content) {
Object.assign(existing, a);
await existing.save();
updated++;
}
continue;
}
await Article.create(a);
created++;
}
console.log(`Seed complete: ${created} created, ${updated} filled, ${articles.length - created - updated} skipped`);
await disconnectDB();
}
seed().catch(async (err) => {
console.error("Seed failed:", err.message);
await mongoose.disconnect().catch(() => {});
process.exit(1);
});