rm server/
This commit is contained in:
parent
37d4dfebcf
commit
aef1788747
12 changed files with 2 additions and 1235 deletions
|
@ -1,8 +1,8 @@
|
|||
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
|
||||
|
||||
Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is completely functional.
|
||||
Drift is a self-hostable Gist clone. It's in beta, but is completely functional.
|
||||
|
||||
You can try a demo at https://drift.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time.
|
||||
You can try a demo at https://drift.lol. The demo is built on main but has no database, so files and accounts can be wiped at any time.
|
||||
|
||||
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
|
||||
<hr />
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import * as request from "supertest"
|
||||
import { app } from "../app"
|
||||
|
||||
describe("GET /health", () => {
|
||||
it("should return 200 and a status up", (done) => {
|
||||
request(app)
|
||||
.get(`/health`)
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
.end((err, res) => {
|
||||
if (err) return done(err)
|
||||
expect(res.body).toMatchObject({ status: "UP" })
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,64 +0,0 @@
|
|||
import { config } from "../config"
|
||||
|
||||
describe("Config", () => {
|
||||
it("should build a valid development config when no environment is set", () => {
|
||||
const emptyEnv = {}
|
||||
const result = config(emptyEnv)
|
||||
|
||||
expect(result).toHaveProperty("is_production", false)
|
||||
expect(result).toHaveProperty("port")
|
||||
expect(result).toHaveProperty("jwt_secret")
|
||||
expect(result).toHaveProperty("drift_home")
|
||||
expect(result).toHaveProperty("memory_db")
|
||||
expect(result).toHaveProperty("enable_admin")
|
||||
expect(result).toHaveProperty("secret_key")
|
||||
expect(result).toHaveProperty("registration_password")
|
||||
expect(result).toHaveProperty("welcome_content")
|
||||
expect(result).toHaveProperty("welcome_title")
|
||||
})
|
||||
|
||||
it("should fail when building a prod environment without SECRET_KEY", () => {
|
||||
expect(() => config({ NODE_ENV: "production" })).toThrow(
|
||||
new Error("Missing environment variable: SECRET_KEY")
|
||||
)
|
||||
})
|
||||
|
||||
it("should build a prod config with a SECRET_KEY", () => {
|
||||
const result = config({ NODE_ENV: "production", SECRET_KEY: "secret" })
|
||||
|
||||
expect(result).toHaveProperty("is_production", true)
|
||||
expect(result).toHaveProperty("secret_key", "secret")
|
||||
})
|
||||
|
||||
describe("jwt_secret", () => {
|
||||
it("should use default jwt_secret when environment is blank string", () => {
|
||||
const result = config({ JWT_SECRET: "" })
|
||||
|
||||
expect(result).toHaveProperty("is_production", false)
|
||||
expect(result).toHaveProperty("jwt_secret", "myjwtsecret")
|
||||
})
|
||||
})
|
||||
|
||||
describe("booleans", () => {
|
||||
it("should parse 'true' as true", () => {
|
||||
const result = config({ MEMORY_DB: "true" })
|
||||
|
||||
expect(result).toHaveProperty("memory_db", true)
|
||||
})
|
||||
it("should parse 'false' as false", () => {
|
||||
const result = config({ MEMORY_DB: "false" })
|
||||
|
||||
expect(result).toHaveProperty("memory_db", false)
|
||||
})
|
||||
it("should fail when it is not parseable", () => {
|
||||
expect(() => config({ MEMORY_DB: "foo" })).toThrow(
|
||||
new Error("Invalid boolean value: foo")
|
||||
)
|
||||
})
|
||||
it("should default to false when the string is empty", () => {
|
||||
const result = config({ MEMORY_DB: "" })
|
||||
|
||||
expect(result).toHaveProperty("memory_db", false)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,17 +0,0 @@
|
|||
import getHtmlFromFile from "@lib/get-html-from-drift-file"
|
||||
|
||||
describe("get-html-from-drift-file", () => {
|
||||
it("should not wrap markdown in code blocks", () => {
|
||||
const markdown = `## My Markdown`
|
||||
const html = getHtmlFromFile({ content: markdown, title: "my-markdown.md" })
|
||||
// the string is <h2><a href=\"#my-markdown\" id=\"my-markdown\" style=\"color:inherit\">My Markdown</a></h2>,
|
||||
// but we dont wan't to be too strict in case markup changes
|
||||
expect(html).toMatch(/<h2><a.*<\/a><\/h2>/)
|
||||
})
|
||||
|
||||
it("should wrap code in code blocks", () => {
|
||||
const code = `const foo = "bar"`
|
||||
const html = getHtmlFromFile({ content: code, title: "my-code.js" })
|
||||
expect(html).toMatch(/<pre><code class="prism-code language-js">/)
|
||||
})
|
||||
})
|
|
@ -1,199 +0,0 @@
|
|||
import isAdmin, { UserJwtRequest } from "@lib/middleware/is-admin";
|
||||
import { Post } from "@lib/models/Post";
|
||||
import { User } from "@lib/models/User";
|
||||
import { File } from "@lib/models/File";
|
||||
import { Router } from "express";
|
||||
import { celebrate, Joi } from "celebrate";
|
||||
|
||||
export const admin = Router();
|
||||
|
||||
admin.use(isAdmin);
|
||||
|
||||
admin.get("/is-admin", async (req, res) => {
|
||||
return res.json({
|
||||
isAdmin: true,
|
||||
});
|
||||
});
|
||||
|
||||
admin.get("/users", async (req, res, next) => {
|
||||
try {
|
||||
const users = await User.findAll({
|
||||
attributes: {
|
||||
exclude: ["password"],
|
||||
include: ["id", "username", "createdAt", "updatedAt"],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Post,
|
||||
as: "posts",
|
||||
attributes: ["id"],
|
||||
},
|
||||
],
|
||||
});
|
||||
res.json(users);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
admin.post(
|
||||
"/users/toggle-role",
|
||||
celebrate({
|
||||
body: {
|
||||
id: Joi.string().required(),
|
||||
role: Joi.string().required().allow("user", "admin"),
|
||||
},
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const { id, role } = req.body;
|
||||
if (req.user?.id === id) {
|
||||
return res.status(400).json({
|
||||
error: "You can't change your own role",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await user.update({
|
||||
role,
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
admin.delete("/users/:id", async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.params.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
// TODO: verify CASCADE is removing files + posts
|
||||
await user.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
admin.delete("/posts/:id", async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id);
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
error: "Post not found",
|
||||
});
|
||||
}
|
||||
await post.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
admin.get("/posts", async (req, res, next) => {
|
||||
try {
|
||||
const posts = await Post.findAll({
|
||||
attributes: {
|
||||
exclude: ["content"],
|
||||
include: ["id", "title", "visibility", "createdAt"],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "createdAt", "html"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id", "username"],
|
||||
},
|
||||
],
|
||||
});
|
||||
res.json(posts);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
admin.get("/post/:id", async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
attributes: {
|
||||
exclude: ["content"],
|
||||
include: ["id", "title", "visibility", "createdAt"],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "sha", "createdAt", "updatedAt", "html"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id", "username"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
message: "Post not found",
|
||||
});
|
||||
}
|
||||
|
||||
res.json(post);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
admin.delete("/post/:id", async (req, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({
|
||||
message: "Post not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (post.files?.length)
|
||||
await Promise.all(post.files.map((file) => file.destroy()));
|
||||
await post.destroy({ force: true });
|
||||
res.json({
|
||||
message: "Post deleted",
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
|
@ -1,232 +0,0 @@
|
|||
import { Router } from "express"
|
||||
import { genSalt, hash, compare } from "bcryptjs"
|
||||
import { User } from "@lib/models/User"
|
||||
import { AuthToken } from "@lib/models/AuthToken"
|
||||
import { sign, verify } from "jsonwebtoken"
|
||||
import config from "@lib/config"
|
||||
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
|
||||
import { celebrate, Joi } from "celebrate"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
|
||||
const NO_EMPTY_SPACE_REGEX = /^\S*$/
|
||||
|
||||
// we require a server password if the password is set and we're in production
|
||||
export const requiresServerPassword =
|
||||
config.registration_password.length > 0 && config.is_production
|
||||
if (requiresServerPassword) console.log(`Registration password enabled.`)
|
||||
|
||||
export const auth = Router()
|
||||
|
||||
const validateAuthPayload = (
|
||||
username: string,
|
||||
password: string,
|
||||
serverPassword?: string
|
||||
): void => {
|
||||
if (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6) {
|
||||
throw new Error("Authentication data does not fulfill requirements")
|
||||
}
|
||||
|
||||
if (requiresServerPassword) {
|
||||
if (!serverPassword || config.registration_password !== serverPassword) {
|
||||
throw new Error(
|
||||
"Server password is incorrect. Please contact the server administrator."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth.post(
|
||||
"/signup",
|
||||
celebrate({
|
||||
body: {
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
serverPassword: Joi.string().required().allow("", null)
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
validateAuthPayload(
|
||||
req.body.username,
|
||||
req.body.password,
|
||||
req.body.serverPassword
|
||||
)
|
||||
const username = req.body.username.toLowerCase()
|
||||
|
||||
const existingUser = await User.findOne({
|
||||
where: { username: username }
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error("Username already exists")
|
||||
}
|
||||
|
||||
const salt = await genSalt(10)
|
||||
const { count } = await User.findAndCountAll()
|
||||
|
||||
const user = {
|
||||
username: username as string,
|
||||
password: await hash(req.body.password, salt),
|
||||
role: config.enable_admin && count === 0 ? "admin" : "user"
|
||||
}
|
||||
|
||||
const created_user = await User.create(user)
|
||||
|
||||
const token = generateAccessToken(created_user)
|
||||
|
||||
res.status(201).json({ token: token, userId: created_user.id })
|
||||
} catch (e) {
|
||||
res.status(401).json({
|
||||
error: {
|
||||
message: e.message
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
auth.post(
|
||||
"/signin",
|
||||
celebrate({
|
||||
body: {
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
serverPassword: Joi.string().required().allow("", null)
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const error = "User does not exist or password is incorrect"
|
||||
const errorToThrow = new Error(error)
|
||||
try {
|
||||
if (!req.body.username || !req.body.password) {
|
||||
throw errorToThrow
|
||||
}
|
||||
|
||||
const username = req.body.username.toLowerCase()
|
||||
const user = await User.findOne({ where: { username: username } })
|
||||
if (!user) {
|
||||
throw errorToThrow
|
||||
}
|
||||
|
||||
const password_valid = await compare(req.body.password, user.password)
|
||||
if (password_valid) {
|
||||
const token = generateAccessToken(user)
|
||||
res.status(200).json({ token: token, userId: user.id })
|
||||
} else {
|
||||
throw errorToThrow
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(401).json({
|
||||
error: {
|
||||
message: error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
auth.get("/requires-passcode", async (req, res, next) => {
|
||||
if (requiresServerPassword) {
|
||||
res.status(200).json({ requiresPasscode: true })
|
||||
} else {
|
||||
res.status(200).json({ requiresPasscode: false })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates an access token, stores it in AuthToken table, and returns it
|
||||
*/
|
||||
function generateAccessToken(user: User) {
|
||||
const token = sign({ id: user.id }, config.jwt_secret, { expiresIn: "2d" })
|
||||
const authToken = new AuthToken({
|
||||
userId: user.id,
|
||||
token: token
|
||||
})
|
||||
authToken.save()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
auth.get("/verify-token", jwt, async (req, res, next) => {
|
||||
try {
|
||||
res.status(200).json({
|
||||
message: "You are authenticated"
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
auth.post("/signout", secretKey, async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers["authorization"]
|
||||
const token = authHeader?.split(" ")[1]
|
||||
let reason = ""
|
||||
if (token == null) return res.sendStatus(401)
|
||||
|
||||
verify(token, config.jwt_secret, async (err: any, user: any) => {
|
||||
if (err) {
|
||||
reason = "Token expired"
|
||||
} else if (user) {
|
||||
reason = "User signed out"
|
||||
} else {
|
||||
reason = "Unknown"
|
||||
}
|
||||
|
||||
// find and destroy the AuthToken + set the reason
|
||||
const authToken = await AuthToken.findOne({ where: { token: token } })
|
||||
if (authToken == null) {
|
||||
res.sendStatus(401)
|
||||
} else {
|
||||
authToken.expiredReason = reason
|
||||
authToken.save()
|
||||
authToken.destroy()
|
||||
}
|
||||
|
||||
req.headers["authorization"] = ""
|
||||
res.status(201).json({
|
||||
message: "You are now logged out",
|
||||
token,
|
||||
reason
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
auth.put(
|
||||
"/change-password",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
oldPassword: Joi.string().required().min(6).max(128),
|
||||
newPassword: Joi.string().required().min(6).max(128)
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const user = await User.findOne({ where: { id: req.user?.id } })
|
||||
if (!user) {
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const password_valid = await compare(req.body.oldPassword, user.password)
|
||||
if (!password_valid) {
|
||||
res.status(401).json({
|
||||
error: "Old password is incorrect"
|
||||
})
|
||||
}
|
||||
|
||||
const salt = await genSalt(10)
|
||||
user.password = await hash(req.body.newPassword, salt)
|
||||
user.save()
|
||||
|
||||
res.status(200).json({
|
||||
message: "Password changed"
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
)
|
|
@ -1,97 +0,0 @@
|
|||
import { celebrate, Joi } from "celebrate"
|
||||
import { Router } from "express"
|
||||
import { File } from "@lib/models/File"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
import jwt from "@lib/middleware/jwt"
|
||||
import getHtmlFromFile from "@lib/get-html-from-drift-file"
|
||||
|
||||
export const files = Router()
|
||||
|
||||
files.post(
|
||||
"/html",
|
||||
jwt,
|
||||
// celebrate({
|
||||
// body: Joi.object().keys({
|
||||
// content: Joi.string().required().allow(""),
|
||||
// title: Joi.string().required().allow(""),
|
||||
// })
|
||||
// }),
|
||||
async (req, res, next) => {
|
||||
const { content, title } = req.body
|
||||
const renderedHtml = getHtmlFromFile({
|
||||
content,
|
||||
title
|
||||
})
|
||||
|
||||
res.setHeader("Content-Type", "text/plain")
|
||||
// res.setHeader("Cache-Control", "public, max-age=4800")
|
||||
res.status(200).write(renderedHtml)
|
||||
res.end()
|
||||
}
|
||||
)
|
||||
|
||||
files.get(
|
||||
"/raw/:id",
|
||||
celebrate({
|
||||
params: {
|
||||
id: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
secretKey,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const file = await File.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
},
|
||||
attributes: ["title", "content"]
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: "File not found" })
|
||||
}
|
||||
|
||||
// TODO: JWT-checkraw files
|
||||
if (file?.post?.visibility === "private") {
|
||||
// jwt(req as UserJwtRequest, res, () => {
|
||||
// res.json(file);
|
||||
// })
|
||||
res.json(file)
|
||||
} else {
|
||||
res.json(file)
|
||||
}
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
files.get(
|
||||
"/html/:id",
|
||||
celebrate({
|
||||
params: {
|
||||
id: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const file = await File.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
},
|
||||
attributes: ["html"]
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: "File not found" })
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "text/plain")
|
||||
res.setHeader("Cache-Control", "public, max-age=4800")
|
||||
res.status(200).write(file.html)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
|
@ -1,9 +0,0 @@
|
|||
import { Router } from "express"
|
||||
|
||||
export const health = Router()
|
||||
|
||||
health.get("/", async (req, res) => {
|
||||
return res.json({
|
||||
status: "UP"
|
||||
})
|
||||
})
|
|
@ -1,6 +0,0 @@
|
|||
export { auth } from "./auth"
|
||||
export { posts } from "./posts"
|
||||
export { user } from "./user"
|
||||
export { files } from "./files"
|
||||
export { admin } from "./admin"
|
||||
export { health } from "./health"
|
|
@ -1,512 +0,0 @@
|
|||
import { Router } from "express"
|
||||
import { celebrate, Joi } from "celebrate"
|
||||
import { File } from "@lib/models/File"
|
||||
import { Post } from "@lib/models/Post"
|
||||
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
|
||||
import * as crypto from "crypto"
|
||||
import { User } from "@lib/models/User"
|
||||
import secretKey from "@lib/middleware/secret-key"
|
||||
import { Op } from "sequelize"
|
||||
import { PostAuthor } from "@lib/models/PostAuthor"
|
||||
import getHtmlFromFile from "@lib/get-html-from-drift-file"
|
||||
import { getGist, createPostFromGist } from "@lib/gist"
|
||||
|
||||
export const posts = Router()
|
||||
|
||||
const postVisibilitySchema = (value: string) => {
|
||||
if (
|
||||
value === "public" ||
|
||||
value === "private" ||
|
||||
value === "unlisted" ||
|
||||
value === "protected"
|
||||
) {
|
||||
return value
|
||||
} else {
|
||||
throw new Error("Invalid post visibility")
|
||||
}
|
||||
}
|
||||
|
||||
posts.post(
|
||||
"/create",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
title: Joi.string().required(),
|
||||
description: Joi.string().optional().min(0).max(256),
|
||||
files: Joi.any().required(),
|
||||
visibility: Joi.string()
|
||||
.custom(postVisibilitySchema, "valid visibility")
|
||||
.required(),
|
||||
userId: Joi.string().required(),
|
||||
password: Joi.string().optional(),
|
||||
// expiresAt, allow to be null
|
||||
expiresAt: Joi.date().optional().allow(null, ""),
|
||||
parentId: Joi.string().optional().allow(null, "")
|
||||
}
|
||||
}),
|
||||
async (req, res) => {
|
||||
try {
|
||||
// check if all files have titles
|
||||
const files = req.body.files as File[]
|
||||
const fileTitles = files.map((file) => file.title)
|
||||
const missingTitles = fileTitles.filter((title) => title === "")
|
||||
if (missingTitles.length > 0) {
|
||||
throw new Error("All files must have a title")
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error("You must submit at least one file")
|
||||
}
|
||||
|
||||
let hashedPassword: string = ""
|
||||
if (req.body.visibility === "protected") {
|
||||
hashedPassword = crypto
|
||||
.createHash("sha256")
|
||||
.update(req.body.password)
|
||||
.digest("hex")
|
||||
}
|
||||
|
||||
const newPost = new Post({
|
||||
title: req.body.title,
|
||||
description: req.body.description,
|
||||
visibility: req.body.visibility,
|
||||
password: hashedPassword,
|
||||
expiresAt: req.body.expiresAt
|
||||
})
|
||||
|
||||
await newPost.save()
|
||||
await newPost.$add("users", req.body.userId)
|
||||
const newFiles = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const html = getHtmlFromFile(file)
|
||||
const newFile = new File({
|
||||
title: file.title || "",
|
||||
content: file.content,
|
||||
sha: crypto
|
||||
.createHash("sha256")
|
||||
.update(file.content)
|
||||
.digest("hex")
|
||||
.toString(),
|
||||
html: html || "",
|
||||
userId: req.body.userId,
|
||||
postId: newPost.id
|
||||
})
|
||||
await newFile.save()
|
||||
return newFile
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(
|
||||
newFiles.map(async (file) => {
|
||||
await newPost.$add("files", file.id)
|
||||
await newPost.save()
|
||||
})
|
||||
)
|
||||
if (req.body.parentId) {
|
||||
// const parentPost = await Post.findOne({
|
||||
// where: { id: req.body.parentId }
|
||||
// })
|
||||
// if (parentPost) {
|
||||
// await parentPost.$add("children", newPost.id)
|
||||
// await parentPost.save()
|
||||
// }
|
||||
const parentPost = await Post.findByPk(req.body.parentId)
|
||||
if (parentPost) {
|
||||
newPost.$set("parent", req.body.parentId)
|
||||
await newPost.save()
|
||||
} else {
|
||||
throw new Error("Parent post not found")
|
||||
}
|
||||
}
|
||||
|
||||
res.json(newPost)
|
||||
} catch (e) {
|
||||
res.status(400).json(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
posts.get("/", secretKey, async (req, res, next) => {
|
||||
try {
|
||||
const posts = await Post.findAll({
|
||||
attributes: ["id", "title", "description", "visibility", "createdAt"]
|
||||
})
|
||||
res.json(posts)
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
posts.get("/mine", jwt, async (req: UserJwtRequest, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const page = parseInt(req.headers["x-page"]?.toString() || "1")
|
||||
|
||||
try {
|
||||
const user = await User.findByPk(req.user.id, {
|
||||
include: [
|
||||
{
|
||||
model: Post,
|
||||
as: "posts",
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "createdAt"]
|
||||
},
|
||||
{
|
||||
model: Post,
|
||||
as: "parent",
|
||||
attributes: ["id", "title", "visibility"]
|
||||
}
|
||||
],
|
||||
attributes: [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"visibility",
|
||||
"createdAt",
|
||||
"expiresAt"
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" })
|
||||
}
|
||||
|
||||
const userPosts = user.posts
|
||||
const sorted = userPosts?.sort((a, b) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
|
||||
const paginated = sorted?.slice((page - 1) * 10, page * 10)
|
||||
|
||||
const hasMore =
|
||||
paginated && sorted ? paginated.length < sorted.length : false
|
||||
|
||||
return res.json({
|
||||
posts: paginated,
|
||||
hasMore
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
posts.get(
|
||||
"/search",
|
||||
jwt,
|
||||
celebrate({
|
||||
query: {
|
||||
q: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
const { q } = req.query
|
||||
if (typeof q !== "string") {
|
||||
return res.status(400).json({ error: "Invalid query" })
|
||||
}
|
||||
|
||||
try {
|
||||
const posts = await Post.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ title: { [Op.like]: `%${q}%` } },
|
||||
{ description: { [Op.like]: `%${q}%` } },
|
||||
{ "$files.title$": { [Op.like]: `%${q}%` } },
|
||||
{ "$files.content$": { [Op.like]: `%${q}%` } }
|
||||
],
|
||||
[Op.and]: [{ "$users.id$": req.user?.id || "" }]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id", "username"]
|
||||
},
|
||||
{
|
||||
model: Post,
|
||||
as: "parent",
|
||||
attributes: ["id", "title", "visibility"]
|
||||
}
|
||||
],
|
||||
attributes: [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"visibility",
|
||||
"createdAt",
|
||||
"deletedAt"
|
||||
],
|
||||
order: [["createdAt", "DESC"]]
|
||||
})
|
||||
|
||||
res.json(posts)
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const fullPostSequelizeOptions = {
|
||||
include: [
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"]
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id", "username"]
|
||||
},
|
||||
{
|
||||
model: Post,
|
||||
as: "parent",
|
||||
attributes: ["id", "title", "visibility", "createdAt"]
|
||||
}
|
||||
],
|
||||
attributes: [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"visibility",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"deletedAt",
|
||||
"expiresAt"
|
||||
]
|
||||
}
|
||||
|
||||
posts.get(
|
||||
"/authenticate",
|
||||
celebrate({
|
||||
query: {
|
||||
id: Joi.string().required(),
|
||||
password: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
const { id, password } = req.query
|
||||
|
||||
const post = await Post.findByPk(id?.toString(), {
|
||||
...fullPostSequelizeOptions,
|
||||
attributes: [...fullPostSequelizeOptions.attributes, "password"]
|
||||
})
|
||||
|
||||
const hash = crypto
|
||||
.createHash("sha256")
|
||||
.update(password?.toString() || "")
|
||||
.digest("hex")
|
||||
.toString()
|
||||
|
||||
if (hash !== post?.password) {
|
||||
return res.status(400).json({ error: "Incorrect password." })
|
||||
}
|
||||
|
||||
res.json(post)
|
||||
}
|
||||
)
|
||||
|
||||
posts.get(
|
||||
"/:id",
|
||||
secretKey,
|
||||
celebrate({
|
||||
params: {
|
||||
id: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
const isUserAuthor = (post: Post) => {
|
||||
return (
|
||||
req.user?.id &&
|
||||
post.users?.map((user) => user.id).includes(req.user?.id)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id, fullPostSequelizeOptions)
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post not found" })
|
||||
}
|
||||
|
||||
// if public or unlisted, cache
|
||||
if (post.visibility === "public" || post.visibility === "unlisted") {
|
||||
res.set("Cache-Control", "public, max-age=4800")
|
||||
}
|
||||
|
||||
if (post.visibility === "public" || post?.visibility === "unlisted") {
|
||||
res.json(post)
|
||||
} else if (post.visibility === "private") {
|
||||
jwt(req as UserJwtRequest, res, () => {
|
||||
if (isUserAuthor(post)) {
|
||||
res.json(post)
|
||||
} else {
|
||||
res.status(403).send()
|
||||
}
|
||||
})
|
||||
} else if (post.visibility === "protected") {
|
||||
// The client ensures to not send the post to the client.
|
||||
// See client/pages/post/[id].tsx::getServerSideProps
|
||||
res.json(post)
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
posts.delete("/:id", jwt, async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const post = await Post.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id"]
|
||||
},
|
||||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id"]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post not found" })
|
||||
}
|
||||
|
||||
if (req.user?.id !== post.users![0].id) {
|
||||
return res.status(403).json({ error: "Forbidden" })
|
||||
}
|
||||
if (post.files?.length)
|
||||
await Promise.all(post.files.map((file) => file.destroy()))
|
||||
|
||||
const postAuthor = await PostAuthor.findOne({
|
||||
where: {
|
||||
postId: post.id
|
||||
}
|
||||
})
|
||||
if (postAuthor) await postAuthor.destroy()
|
||||
await post.destroy()
|
||||
res.json({ message: "Post deleted" })
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
posts.put(
|
||||
"/:id",
|
||||
jwt,
|
||||
celebrate({
|
||||
params: {
|
||||
id: Joi.string().required()
|
||||
},
|
||||
body: {
|
||||
visibility: Joi.string()
|
||||
.custom(postVisibilitySchema, "valid visibility")
|
||||
.required(),
|
||||
password: Joi.string().optional()
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const isUserAuthor = (post: Post) => {
|
||||
return (
|
||||
req.user?.id &&
|
||||
post.users?.map((user) => user.id).includes(req.user?.id)
|
||||
)
|
||||
}
|
||||
|
||||
const { visibility, password } = req.body
|
||||
|
||||
let hashedPassword: string = ""
|
||||
if (visibility === "protected") {
|
||||
hashedPassword = crypto
|
||||
.createHash("sha256")
|
||||
.update(password)
|
||||
.digest("hex")
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const post = await Post.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "users",
|
||||
attributes: ["id"]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: "Post not found" })
|
||||
}
|
||||
|
||||
if (!isUserAuthor(post)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "This post does not belong to you" })
|
||||
}
|
||||
|
||||
await Post.update(
|
||||
{ password: hashedPassword, visibility },
|
||||
{ where: { id } }
|
||||
)
|
||||
|
||||
res.json({ id, visibility })
|
||||
} catch (e) {
|
||||
res.status(400).json(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
posts.post(
|
||||
"/import/gist/id/:id",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
visibility: Joi.string()
|
||||
.custom(postVisibilitySchema, "valid visibility")
|
||||
.required(),
|
||||
password: Joi.string().optional(),
|
||||
expiresAt: Joi.date().optional().allow(null, "")
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { visibility, password, expiresAt } = req.body
|
||||
const gist = await getGist(id)
|
||||
|
||||
let hashedPassword: string = ""
|
||||
if (visibility === "protected") {
|
||||
hashedPassword = crypto
|
||||
.createHash("sha256")
|
||||
.update(password)
|
||||
.digest("hex")
|
||||
}
|
||||
const newFile = await createPostFromGist(
|
||||
{
|
||||
userId: req.user!.id,
|
||||
visibility,
|
||||
password: hashedPassword,
|
||||
expiresAt
|
||||
},
|
||||
gist
|
||||
)
|
||||
return res.json(newFile)
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.toString() })
|
||||
}
|
||||
}
|
||||
)
|
|
@ -1,77 +0,0 @@
|
|||
import { Router } from "express"
|
||||
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
|
||||
import { User } from "@lib/models/User"
|
||||
import { celebrate, Joi } from "celebrate"
|
||||
|
||||
export const user = Router()
|
||||
|
||||
user.get("/self", jwt, async (req: UserJwtRequest, res, next) => {
|
||||
const error = () =>
|
||||
res.status(401).json({
|
||||
message: "Unauthorized"
|
||||
})
|
||||
|
||||
try {
|
||||
if (!req.user) {
|
||||
return error()
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user?.id, {
|
||||
attributes: {
|
||||
exclude: ["password"]
|
||||
}
|
||||
})
|
||||
if (!user) {
|
||||
return error()
|
||||
}
|
||||
res.json(user)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
user.put(
|
||||
"/profile",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
displayName: Joi.string().optional().allow(""),
|
||||
bio: Joi.string().optional().allow(""),
|
||||
email: Joi.string().optional().email().allow("")
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
const error = () =>
|
||||
res.status(401).json({
|
||||
message: "Unauthorized"
|
||||
})
|
||||
|
||||
try {
|
||||
if (!req.user) {
|
||||
return error()
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.user?.id)
|
||||
if (!user) {
|
||||
return error()
|
||||
}
|
||||
|
||||
const { displayName, bio, email } = req.body
|
||||
const toUpdate = {} as any
|
||||
if (displayName) {
|
||||
toUpdate.displayName = displayName
|
||||
}
|
||||
if (bio) {
|
||||
toUpdate.bio = bio
|
||||
}
|
||||
if (email) {
|
||||
toUpdate.email = email
|
||||
}
|
||||
|
||||
await user.update(toUpdate)
|
||||
res.json(user)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
|
@ -1,4 +0,0 @@
|
|||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
|
Loading…
Reference in a new issue