First commit
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user