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

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;