First commit
This commit is contained in:
4
backend/.env.example
Normal file
4
backend/.env.example
Normal 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
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
1279
backend/package-lock.json
generated
Normal file
1279
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
backend/package.json
Normal file
21
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
15
backend/src/config/auth.js
Normal file
15
backend/src/config/auth.js
Normal 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
11
backend/src/config/db.js
Normal 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
72
backend/src/index.js
Normal 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);
|
||||
});
|
||||
}
|
||||
60
backend/src/middleware/auth.js
Normal file
60
backend/src/middleware/auth.js
Normal 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);
|
||||
}
|
||||
}
|
||||
52
backend/src/models/Article.js
Normal file
52
backend/src/models/Article.js
Normal 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;
|
||||
21
backend/src/models/Item.js
Normal file
21
backend/src/models/Item.js
Normal 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
124
backend/src/models/User.js
Normal 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;
|
||||
79
backend/src/routes/admin.js
Normal file
79
backend/src/routes/admin.js
Normal 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;
|
||||
361
backend/src/routes/articles.js
Normal file
361
backend/src/routes/articles.js
Normal 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
189
backend/src/routes/auth.js
Normal 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;
|
||||
62
backend/src/routes/items.js
Normal file
62
backend/src/routes/items.js
Normal 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
410
backend/src/seed.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user