add (messy) file upload
This commit is contained in:
parent
522ff0121f
commit
7089b6f39e
17 changed files with 159 additions and 32 deletions
5
index.js
5
index.js
|
@ -23,9 +23,10 @@ app.use((req, _, next) => {
|
|||
|
||||
// load context
|
||||
const ctx = {};
|
||||
ctx.db = await load("./server/database.js", log);
|
||||
ctx.sessions = await load("./server/sessions.js", log);
|
||||
ctx.log = log;
|
||||
ctx.db = await load("./server/database.js", log);
|
||||
ctx.sessions = await load("./server/sessions.js");
|
||||
ctx.files = await load("./server/files.js", ctx, { maxSize: 1000 * 1000 * 20 });
|
||||
|
||||
// load routes
|
||||
for(let i of await fs.readdir("routes")) {
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"type": "module",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"busboy": "^1.3.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.2",
|
||||
"express-handlebars": "^6.0.2",
|
||||
|
|
|
@ -2,6 +2,7 @@ lockfileVersion: 5.3
|
|||
|
||||
specifiers:
|
||||
'@types/express': ^4.17.13
|
||||
busboy: ^1.3.0
|
||||
cookie-parser: ^1.4.6
|
||||
express: ^4.17.2
|
||||
express-handlebars: ^6.0.2
|
||||
|
@ -11,6 +12,7 @@ specifiers:
|
|||
sqlite3: ^5.0.2
|
||||
|
||||
dependencies:
|
||||
busboy: 1.3.0
|
||||
cookie-parser: 1.4.6
|
||||
express: 4.17.2
|
||||
express-handlebars: 6.0.2
|
||||
|
@ -212,6 +214,13 @@ packages:
|
|||
ieee754: 1.2.1
|
||||
dev: false
|
||||
|
||||
/busboy/1.3.0:
|
||||
resolution: {integrity: sha512-ytF8pdwEKCNwl0K9PSwmv+yPcicy+ef+YNAw+L0FTfyBLzCWhp5V3jEfau2kb5A0JD0TkOPrdtdCKLoAHlMu1A==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
dev: false
|
||||
|
||||
/bytes/3.1.1:
|
||||
resolution: {integrity: sha512-dWe4nWO/ruEOY7HkUJ5gFt1DCFV9zPRoJr8pV0/ASQermOZjtq8jMjOprC0Kd10GLN+l7xaUPvxzJFWtxGu8Fg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
@ -1444,6 +1453,11 @@ packages:
|
|||
engines: {node: '>= 0.6'}
|
||||
dev: false
|
||||
|
||||
/streamsearch/1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
dev: false
|
||||
|
||||
/string-width/1.0.2:
|
||||
resolution: {integrity: sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
|
@ -27,3 +27,7 @@ p {
|
|||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/* the main css stylesheet for everything */
|
||||
@import "/static/form.css";
|
||||
@import "/static/inline.css";
|
||||
@import "/static/content.css";
|
||||
@import "/static/misc.css";
|
||||
|
||||
:root {
|
||||
|
@ -31,7 +31,8 @@ body {
|
|||
header, .center {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2em;
|
||||
padding: 0 2rem;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
|
|
@ -29,6 +29,7 @@ export default (app, { log, db, sessions }) => {
|
|||
const hash = genHash(password + salt);
|
||||
const userId = await db("users").insert({ username, createdAt: new Date() });
|
||||
await db("passwords").insert({ userId, password: hash, salt });
|
||||
await db("log").insert({ createdAt: new Date(), type: "user.new", creator: userId });
|
||||
|
||||
// celebrate with cookies!
|
||||
log.info(`new user! welcome, ${username}!`);
|
||||
|
|
|
@ -27,6 +27,7 @@ export default (app, { log, db, sessions }) => {
|
|||
const sess = sessions.from(req);
|
||||
if(!sess) return res.redirect("/login");
|
||||
await db("users").update({ about: req.body.about }).where("userId", sess);
|
||||
await db("log").insert({ createdAt: new Date(), type: "user.about", creator: sess, data: req.body.about });
|
||||
log.debug(`update ${req.cookies.username}'s about`);
|
||||
res.redirect("/config");
|
||||
}
|
||||
|
@ -79,6 +80,7 @@ export default (app, { log, db, sessions }) => {
|
|||
// update user
|
||||
await db("users").delete().where("userId", sess);
|
||||
await db("passwords").delete().where("userId", sess);
|
||||
await db("log").insert({ createdAt: new Date(), type: "user.death", creator: sess });
|
||||
|
||||
// cry in the corner (but with cookies...)
|
||||
log.info(`farewell, ${req.cookies.username}`);
|
||||
|
|
|
@ -1,26 +1,10 @@
|
|||
import { render, getUser, getPost } from "../server/posts.js";
|
||||
|
||||
export default (app, { log, db, sessions }) => {
|
||||
app.post("/create", create);
|
||||
export default (app, { db, files }) => {
|
||||
app.get("/post/:id", routePost, cantFind);
|
||||
app.get("/raw/:id", routePostRaw, cantFind);
|
||||
|
||||
async function create(req, res) {
|
||||
const userId = sessions.get(req.cookies.session);
|
||||
if(!userId) return res.redirect("/login");
|
||||
const post = req.body;
|
||||
|
||||
const [id] = await db("posts").insert({
|
||||
createdAt: new Date(),
|
||||
title: post.title || "unnamed",
|
||||
body: post.body || "",
|
||||
author: userId,
|
||||
});
|
||||
|
||||
log.info(`created post #${id}, by ${req.cookies.username}`);
|
||||
log.debug(`redirecting to info /post/${id}...`);
|
||||
res.redirect(`/post/${id}`);
|
||||
}
|
||||
app.get("/media/:hash", routeMedia, cantFind);
|
||||
app.get("/media/:hash/*", routeMedia, cantFind);
|
||||
|
||||
async function routePost(req, res, next) {
|
||||
const post = await getPost(db, req.params.id);
|
||||
|
@ -34,6 +18,12 @@ export default (app, { log, db, sessions }) => {
|
|||
res.contentType("text/markdown").send(post.body);
|
||||
}
|
||||
|
||||
async function routeMedia(req, res, next) {
|
||||
const file = await files.get(req.params.hash).catch(() => null);
|
||||
if(!file) return next();
|
||||
file.pipe(res);
|
||||
}
|
||||
|
||||
async function cantFind(_, res) {
|
||||
res.render("404.html", { title: "bantiose::404" });
|
||||
}
|
50
routes/create.js
Normal file
50
routes/create.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import busboy from "busboy";
|
||||
|
||||
// TODO: this code is messy! redo it
|
||||
async function parse(req, files) {
|
||||
const bb = busboy({ headers: req.headers, limits: { files: 1 } });
|
||||
return new Promise((res) => {
|
||||
const post = {};
|
||||
let hasFile = false;
|
||||
bb.on("field", (key, val) => post[key] = val);
|
||||
bb.on("file", async (_name, file, info) => {
|
||||
hasFile = true;
|
||||
res({
|
||||
post,
|
||||
file: {
|
||||
attachHash: await files.insert(file),
|
||||
attachName: info.filename,
|
||||
attachType: info.mimeType,
|
||||
},
|
||||
});
|
||||
});
|
||||
bb.on("close", () => {
|
||||
if(!hasFile) res({ post });
|
||||
});
|
||||
req.pipe(bb);
|
||||
});
|
||||
}
|
||||
|
||||
export default (app, { log, db, files, sessions }) => {
|
||||
app.post("/create", create);
|
||||
|
||||
async function create(req, res) {
|
||||
const userId = sessions.get(req.cookies.session);
|
||||
if(!userId) return res.redirect("/login");
|
||||
const { post, file } = await parse(req, files);
|
||||
|
||||
const [id] = await db("posts").insert({
|
||||
createdAt: new Date(),
|
||||
title: post.title || "unnamed",
|
||||
body: post.body || "",
|
||||
author: userId,
|
||||
...(file ?? {})
|
||||
});
|
||||
await db("log").insert({ createdAt: new Date(), type: "post.new", creator: userId, data: id });
|
||||
|
||||
log.info(`created post #${id}, by ${req.cookies.username}`);
|
||||
log.debug(`redirecting to info /post/${id}...`);
|
||||
res.redirect(`/post/${id}`);
|
||||
}
|
||||
};
|
||||
|
|
@ -25,11 +25,9 @@ async function init(schema, log) {
|
|||
table.integer("author").unsigned();
|
||||
table.string("title");
|
||||
table.string("body");
|
||||
}).createTable("media", table => {
|
||||
// for the file store
|
||||
table.string("filename");
|
||||
table.string("hash");
|
||||
table.integer("filesize").unsigned();
|
||||
table.string("attachHash");
|
||||
table.string("attachName");
|
||||
table.string("attachType");
|
||||
}).createTable("log", table => {
|
||||
// log of everything that happens
|
||||
table.increments("auditId");
|
||||
|
@ -42,7 +40,6 @@ async function init(schema, log) {
|
|||
|
||||
export default async (log) => {
|
||||
if(!fs.existsSync(".data")) fs.mkdirSync(".data");
|
||||
if(!fs.existsSync(".data/files")) fs.mkdirSync(".data/files");
|
||||
|
||||
const db = knex({
|
||||
useNullAsDefault: true,
|
||||
|
|
52
server/files.js
Normal file
52
server/files.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
class Filestore {
|
||||
where = path.join(process.cwd(), ".data");
|
||||
|
||||
constructor(log, db, options) {
|
||||
this.log = log;
|
||||
this.db = db;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async insert(stream) {
|
||||
let hash = crypto.createHash("sha256");
|
||||
let size = 0;
|
||||
const tmpName = path.join(this.where, "tmp", crypto.randomUUID());
|
||||
const tmpFd = await fs.open(tmpName, "w");
|
||||
const tmp = tmpFd.createWriteStream(tmpName);
|
||||
return new Promise((res, rej) => {
|
||||
stream.on("data", (data) => {
|
||||
size += data.length;
|
||||
if(size >= this.options.maxSize) rej("file too big");
|
||||
hash.update(data);
|
||||
tmp.write(data);
|
||||
});
|
||||
stream.on("end", async () => {
|
||||
const h = hash.digest();
|
||||
await tmpFd.close();
|
||||
await fs.rename(tmpName, path.join(this.where, "files", h.toString("hex")));
|
||||
this.log.info("saved file with hash " + h.toString("hex"));
|
||||
res(h);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async get(hash) {
|
||||
const fd = await fs.open(path.join(this.where, "files", hash));
|
||||
return fd.createReadStream();
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(where) {
|
||||
return fs.access(where).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
export default async function(ctx, options) {
|
||||
if(!await exists(".data/files")) await fs.mkdir(".data/files");
|
||||
if(!await exists(".data/tmp")) await fs.mkdir(".data/tmp");
|
||||
return new Filestore(ctx.log, ctx.db, options);
|
||||
}
|
||||
|
|
@ -26,8 +26,18 @@ export function render(post, author, trim = false) {
|
|||
time: date,
|
||||
timefmt: format(date),
|
||||
author: author?.username ?? "unknown...",
|
||||
attachment: attachment(post),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: add support for other content types
|
||||
export function attachment(post) {
|
||||
switch(post.attachType?.split("/")[0]) {
|
||||
case "image":
|
||||
return `<img src="/media/${post.attachHash.toString("hex")}/${escape(post.attachName)}" alt="main image" class="attachment" />`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const units = [
|
||||
|
|
|
@ -26,5 +26,5 @@ export class Sessions extends Map {
|
|||
}
|
||||
|
||||
// the default session manager
|
||||
export default () => new Sessions(1000 * 30);
|
||||
export default () => new Sessions();
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<header>
|
||||
<h1>new post</h1>
|
||||
</header>
|
||||
<form method="POST" action="/create" class="center">
|
||||
<form method="POST" action="/create" class="center" enctype="multipart/form-data">
|
||||
<label for="title">title</label><br />
|
||||
<input required name="title" type="text" placeholder="sample text here..." /><br />
|
||||
<label for="body">content</label><br />
|
||||
<textarea name="body" placeholder="wow look cool even more sample text" /></textarea><br />
|
||||
<label for="attachment">attachment</label><br />
|
||||
<input type="file" name="attachment" /><br />
|
||||
<input type="submit" value="create" />
|
||||
</form>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<header>
|
||||
<h1>welcome to bantiose</h1>
|
||||
{{#user}}
|
||||
<a href="/config">config</a> •
|
||||
<a href="/create">new post</a> •
|
||||
<a href="/config">config</a> •
|
||||
{{/user}}
|
||||
{{^user}}
|
||||
<a href="/login">login</a> •
|
||||
|
|
|
@ -2,4 +2,5 @@
|
|||
<h2><a href="/post/{{id}}">{{title}}</a></h2>
|
||||
<div class="fineprint"><i><time datetime="{{time}}">{{timefmt}}</time> - by {{author}}</i></div>
|
||||
<p>{{{body}}}</p>
|
||||
{{#attachment}}{{{this}}}{{/attachment}}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<main class="center">
|
||||
<h1>{{title}}</h1>
|
||||
<div class="fineprint"><i><time datetime="{{time}}">{{timefmt}}</time> - by {{author}}</i></div>
|
||||
{{#attachment}}{{{this}}}{{/attachment}}
|
||||
<p>{{{body}}}</p>
|
||||
</main>
|
||||
|
|
Loading…
Reference in a new issue