diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/app.js | 31 | ||||
-rw-r--r-- | app/auth.js | 41 | ||||
-rw-r--r-- | app/config.js | 52 | ||||
-rw-r--r-- | app/lib/otp.js | 8 | ||||
-rw-r--r-- | app/lib/pipe.js | 6 | ||||
-rw-r--r-- | app/lib/router.js | 10 | ||||
-rw-r--r-- | app/lib/socket.js | 6 | ||||
-rw-r--r-- | app/lib/static.js | 18 | ||||
-rw-r--r-- | app/note-store.js | 34 | ||||
-rw-r--r-- | app/package.json | 1 | ||||
-rw-r--r-- | app/utils.js | 32 |
11 files changed, 102 insertions, 137 deletions
@@ -1,18 +1,18 @@ -"use strict"; +import fs from "node:fs"; +import http from "node:http"; +import crypto from "node:crypto"; +import {sj, cors} from "./utils.js"; -const fs = require("fs"); -const http = require("http"); -const crypto = require("crypto"); -const {sj, cors} = require("./utils"); +import Router from "./lib/router.js"; +import Static from "./lib/static.js"; +//import Socket from "./lib/Socket.js"; +//import pipe from "./lib/pipe.js"; +//import otp from "./lib/otp.js"; -const Router = require("./lib/router"); -const Static = require("./lib/static"); -//const Socket = require("./lib/socket"); -//const pipe = require("./lib/pipe"); -//const otp = require("./lib/otp"); +import {HOST, PORT} from "./config.js"; -const config = require("./config"); -const {HOST, PORT} = config; +import * as auth from "./auth.js"; +import * as noteStore from "./note-store.js"; if (!fs.existsSync("./logs")) fs.mkdirSync("./logs"); if (!fs.existsSync("./users")) fs.mkdirSync("./users"); @@ -23,13 +23,10 @@ const stat = new Static("./public"); //const wss = new Socket(server); const app = new Router(); -const auth = require("./auth")(config); -const authed = auth.authed; auth.attach(app); +noteStore.attach(app); -const noteStore = require("./note-store"); -noteStore.attach(app, auth); - +const authed = auth.authed; app.get("/user", authed((req, res) => { console.log(Date.now()+" Getting user data for "+req.uid); sj(res, {uid: req.uid, username: auth.getUsername(req.uid)}); diff --git a/app/auth.js b/app/auth.js index cd771ca..7859a24 100644 --- a/app/auth.js +++ b/app/auth.js @@ -1,23 +1,18 @@ -"use strict"; +import {readFile as rf, writeFile as wf} from "node:fs/promises"; +import {readFileSync, rename} from "node:fs"; +import {randomBytes} from "node:crypto"; +import argon2 from "argon2"; -const fs = require("fs"); -const util = require("util"); -const crypto = require("crypto"); -const argon2 = require("argon2"); -const {sj, parseCookies, res204, err400, err401, err500} = require("./utils"); +import {sj, parseCookies, res204, err400, err401, err500} from "./utils.js"; -const rf = util.promisify(fs.readFile); -const wf = util.promisify(fs.writeFile); -const randHex = len => crypto.randomBytes(len).toString("hex"); - -module.exports = config => { +import {DOMAIN, SECURE, auth as authConfig} from "./config.js"; const { TOKEN_RENEW_AGE, TOKEN_MAX_AGE, TOKEN_LENGTH, SESSION_ID_LENGTH, USERNAME_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, - RATE_LIMIT_MAX_LEVEL, RATE_LIMIT_MAX_WAITING, - fingerprintIP} = config.auth; -const {DOMAIN, SECURE} = config; -const exports = {}; + RATE_LIMIT_MAX_LEVEL, RATE_LIMIT_MAX_WAITING, fingerprintIP, +} = authConfig; + +const randHex = len => randomBytes(len).toString("hex"); // Run once immediately, then every interval until not called function debounce(fn, interval=100) { @@ -42,10 +37,10 @@ function debounce(fn, interval=100) { function loadJSONSync(path) { const obj = {}; try { - Object.assign(obj, JSON.parse(fs.readFileSync(path))); + Object.assign(obj, JSON.parse(readFileSync(path))); } catch(e) { console.log(Date.now()+` Error loading ${path}, creating fallback empty set`); - fs.rename(path, path+".bad."+Date.now(), err => {}); + rename(path, path+".bad."+Date.now(), err => {}); } return obj; } @@ -64,9 +59,9 @@ const userIDsFile = "private/userIDs.json"; const userNames = loadJSONSync(userIDsFile), userIDs = {}; for (const [uid, username] of Object.entries(userNames)) userIDs[username.toLowerCase()] = uid; const writeUIDs = debounce(() => wf(userIDsFile, JSON.stringify(userNames))); -const getUID = exports.getUID = username => +export const getUID = username => userIDs.hasOwnProperty(username.toLowerCase()) && userIDs[username.toLowerCase()]; -const getUsername = exports.getUsername = uid => +export const getUsername = uid => userNames.hasOwnProperty(uid) && userNames[uid]; const createUID = username => { let uid; do { uid = randHex(5); } while (getUsername(uid)); @@ -385,7 +380,7 @@ const rateLimit = fn => (req, res, ...rest) => { }, rateLimitTime(rl.level)); } -function authed(fn) { return rateLimit((req, res, ...rest) => { +export function authed(fn) { return rateLimit((req, res, ...rest) => { const token = parseCookies(req)?.token; if (!token) return err401(res); @@ -421,9 +416,9 @@ function authed(fn) { return rateLimit((req, res, ...rest) => { req.uid = uid; req.sessionID = tokenData.sessionID; return fn(req, res, ...rest); -}); } exports.authed = authed; +}); } -exports.attach = (app) => { // TODO make endpoints RESTier? +export const attach = (app) => { // TODO make endpoints RESTier? app.jpost("/login", rateLimit(login)); // {username, password[, keepSession]} -> {success[, msg][, mustChangePassword]} app.post("/logout", rateLimit(logout)); app.jpost("/change-password", rateLimit(changePassword)); // {password, newPassword[, username[, keepSession]]} -> {success[, msg]} @@ -444,5 +439,3 @@ exports.attach = (app) => { // TODO make endpoints RESTier? // - https://github.com/zxcvbn-ts/zxcvbn // - non-login-event user notification system (warnings, must change password...) // - encrypt `private/` on disk using TPM? - -return exports; }; diff --git a/app/config.js b/app/config.js index b7212e3..f137cc4 100644 --- a/app/config.js +++ b/app/config.js @@ -1,34 +1,24 @@ -"use strict"; +import {clamp, deepFreeze} from "./utils.js"; -function deepFreeze(obj) { - for (const name of Reflect.ownKeys(obj)) { - const value = obj[name]; - if ((value && typeof value === "object") || - typeof value === "function") deepFreeze(value); - } return Object.freeze(obj); -} +export const PORT = clamp(+process.env.PORT||8080, 1, 65535); +export const HOST = process.env.HOST||"0.0.0.0"; +export const DOMAIN = "localhost"; +export const SECURE = false; // i.e. served over https -Math.clamp ||= ((x,l,h) => Math.max(l,Math.min(x,h))); - -module.exports = deepFreeze({ - PORT: Math.clamp(+process.env.PORT||8080, 1, 65535), - HOST: process.env.HOST||"0.0.0.0", - DOMAIN: "localhost", - SECURE: false, // i.e. served over https - - auth: { - TOKEN_RENEW_AGE: 15*60, // 15 mins - TOKEN_MAX_AGE: 30*86400, // 30 days - TOKEN_LENGTH: 24, // bytes - SESSION_ID_LENGTH: 12, - USERNAME_MAX_LENGTH: 128, - PASSWORD_MIN_LENGTH: 8, - PASSWORD_MAX_LENGTH: 512, // Avoid DDOS - RATE_LIMIT_MAX_LEVEL: 16, - RATE_LIMIT_MAX_WAITING: 512, - fingerprintIP: req => - //req.headers["cf-connecting-ip"] || - //req.headers["x-forwarded-for"] || - req.socket.remoteAddress, - }, +export const auth = deepFreeze({ + TOKEN_RENEW_AGE: 15*60, // 15 mins + TOKEN_MAX_AGE: 30*86400, // 30 days + TOKEN_LENGTH: 24, // bytes + SESSION_ID_LENGTH: 12, + USERNAME_MAX_LENGTH: 128, + PASSWORD_MIN_LENGTH: 8, + PASSWORD_MAX_LENGTH: 512, // Avoid DDOS + RATE_LIMIT_MAX_LEVEL: 16, + RATE_LIMIT_MAX_WAITING: 512, + fingerprintIP: req => + //req.headers["cf-connecting-ip"] || + //req.headers["x-forwarded-for"] || + req.socket.remoteAddress, }); + +export const NOTE_DIR = "./users"; diff --git a/app/lib/otp.js b/app/lib/otp.js index 751edf4..14763a8 100644 --- a/app/lib/otp.js +++ b/app/lib/otp.js @@ -1,6 +1,4 @@ -"use strict"; - -const { createHmac } = require("crypto"); +import { createHmac } from "node:crypto"; const getHMAC = (k,c) => { const h = createHmac("sha1", k); h.update(c, "hex"); return h.digest("hex"); } @@ -12,7 +10,7 @@ const b32u = s => Uint8Array.from(s.split("").reduce((a,c) => a+b32a.indexOf(c.toUpperCase()).toString(2).padStart(5,0), "") .match(/.{8}/g).map(b => parseInt(b,2))); -module.exports = function totp(secret, { expiry=30, +export default function totp(secret, { expiry=30, now=Math.round(new Date().getTime()/1000), length=6 }={}) { const time = d2h(now/expiry).padStart(16,0); const hmac = getHMAC(b32u(secret), time); @@ -21,7 +19,7 @@ module.exports = function totp(secret, { expiry=30, return otp.padStart(length,0).substr(-length); } -module.exports.check = function check(token, secret, { expiry=30, +export function check(token, secret, { expiry=30, now=Math.round(new Date().getTime()/1000), length=6, window=0 }={}) { const i = Array(window*2+1).fill().map((e,i) => now+(expiry*(i-window))) .findIndex(n => token === this(secret, {now:n, expiry, length})); diff --git a/app/lib/pipe.js b/app/lib/pipe.js index e329b2b..db58e5f 100644 --- a/app/lib/pipe.js +++ b/app/lib/pipe.js @@ -1,8 +1,6 @@ -"use strict"; +import { spawn } from "node:child_process"; -const { spawn } = require("child_process"); - -module.exports = function pipe({ command, flags, stdin="", buffer } = {}) { +export default function pipe({ command, flags, stdin="", buffer } = {}) { return new Promise((resolve, reject) => { const child = spawn(command, flags); let stdout = (buffer?[]:""); diff --git a/app/lib/router.js b/app/lib/router.js index fd24693..186e6ad 100644 --- a/app/lib/router.js +++ b/app/lib/router.js @@ -1,15 +1,13 @@ -"use strict"; // https://github.com/mixu/minimal - -const url = require("url"); +import {parse as parseURL} from "node:url"; const degroup = path => Object.assign(path, path.groups); -class Router { +export default class Router { constructor() { this.routes = []; } route(req, res) { - const pathname = url.parse(req.url).pathname; + const pathname = parseURL(req.url).pathname; return this.routes.some(route => { const isMatch = route.method === req.method && route.re.test(pathname); if (isMatch) route.cb(req, res, degroup(route.re.exec(pathname))); @@ -49,5 +47,3 @@ class Router { Router.prototype[method] = function(re, cb) { this.routes.push({method: method.toUpperCase(), cb, re: (re instanceof RegExp)? re : new RegExp(`^${re}$`)})}); - -module.exports = Router; diff --git a/app/lib/socket.js b/app/lib/socket.js index 88fdd49..9222d00 100644 --- a/app/lib/socket.js +++ b/app/lib/socket.js @@ -1,6 +1,4 @@ -"use strict"; - -const WebSocket = require("ws"); +import WebSocket from "ws"; class Client { constructor(ws) { @@ -27,7 +25,7 @@ class Client { } } -module.exports = class Socket { +export default class Socket { constructor(server) { this.wss = new WebSocket.Server({server}); this.handlers = {}; diff --git a/app/lib/static.js b/app/lib/static.js index ef48ae6..26fc231 100644 --- a/app/lib/static.js +++ b/app/lib/static.js @@ -1,13 +1,13 @@ -"use strict"; - -const Path = require("path"); -const url = require("url"); -const fs = require("fs"); +import {normalize, extname} from "node:path"; +import {parse as parseURL} from "node:url"; +import fs from "node:fs"; const mimeTypes = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", + ".cjs": "text/javascript", + ".mjs": "text/javascript", ".json": "application/json", ".wasm": "application/wasm", ".pdf": "application/pdf", @@ -32,7 +32,7 @@ const mimeTypes = { ".glb": "model/gltf-binary", }; -module.exports = class Static { +export default class Static { constructor(root) { this.root = `${root||"."}`; this.e404 = fs.readFileSync(`${this.root}/404.html`); @@ -46,8 +46,8 @@ module.exports = class Static { return; } - const pathname = url.parse(req.url).pathname; - const sane = Path.normalize(pathname).replace(/^(\.\.\/)+/, ""); + const pathname = parseURL(req.url).pathname; + const sane = normalize(pathname).replace(/^(\.\.\/)+/, ""); let path = `${this.root}${sane}`; //Path.join(__dirname, sane); fs.stat(path, (err, stats) => { @@ -60,7 +60,7 @@ module.exports = class Static { }} if (stats.isDirectory()) path += "/index.html"; - const ext = `${Path.extname(path)}`.toLowerCase(); + const ext = `${extname(path)}`.toLowerCase(); const stream = fs.createReadStream(path); stream.on("error", e => { console.log(e); diff --git a/app/note-store.js b/app/note-store.js index c816efd..eeebf28 100644 --- a/app/note-store.js +++ b/app/note-store.js @@ -1,17 +1,8 @@ -"use strict"; +import {readdir as rd, readFile as rf, writeFile as wf} from "node:fs/promises"; +import {sj, res204, err400, err500} from "./utils.js"; +import {authed} from "./auth.js"; -const fs = require("fs"); -const util = require("util"); -const {sj, res204, err400, err500} = require("./utils"); - -const rd = util.promisify(fs.readdir); -const rf = util.promisify(fs.readFile); -const wf = util.promisify(fs.writeFile); - -const {R_OK,W_OK} = fs.constants; -const exists = s => new Promise(r => fs.access(s, R_OK|W_OK, e => r(!e))); - -const NOTE_DIR = "./users"; +import {NOTE_DIR} from "./config.js"; function genNoteID() { const now = new Date().toISOString(); @@ -19,15 +10,9 @@ function genNoteID() { } async function newNote(req, res) { - let noteID, noteFile; - - do { - noteID = genNoteID(); - noteFile = `${NOTE_DIR}/${req.uid}/${noteID}.md`; - } while (await exists(noteFile)) // TODO increment - + const noteID = genNoteID(); + const noteFile = `${NOTE_DIR}/${req.uid}/${noteID}.md`; console.log(Date.now()+` Creating note ${req.uid}:${noteID}`); - await wf(noteFile, ""); sj(res, {id:noteID, content:""}); @@ -36,7 +21,7 @@ async function newNote(req, res) { async function getNote(req, res, match) { console.log(Date.now()+` Getting note ${req.uid}:${match.noteID}`); const noteFile = `${NOTE_DIR}/${req.uid}/${match.noteID}.md`; - const content = await rf(noteFile, "UTF-8"); // TODO exists + const content = await rf(noteFile, "UTF-8"); sj(res, {id:match.noteID, content}); } @@ -56,9 +41,10 @@ async function listNotes(req, res) { } // TODO list in order of recent updates w/ timestamps -// TODO pagination? +// TODO pagination? Bulk get? +// TODO handle fs errors -module.exports.attach = (app, {authed}) => { +export const attach = app => { app.get("/list", authed(listNotes)); app.post("/new", authed(newNote)); app.get("/(?<noteID>[0-9]{16})", authed(getNote)); diff --git a/app/package.json b/app/package.json index 0a4ce2d..a83c944 100644 --- a/app/package.json +++ b/app/package.json @@ -1,5 +1,6 @@ { "name": "notes-web", + "type": "module", "version": "0.0.1", "description": "Zettelkasten-inspired note taking web app", "author": "Alexis Hovorka <[email protected]>", diff --git a/app/utils.js b/app/utils.js index 2ff2b9c..03e2233 100644 --- a/app/utils.js +++ b/app/utils.js @@ -1,23 +1,31 @@ -"use strict"; - -const ct = module.exports.ct = (res, mime, code, head) => +export const ct = (res, mime, code, head) => res.writeHead(code||200, Object.apply({"Content-Type": mime||"application/json"}, head)); -module.exports.sj = (res, data, {code, head}={}) => { ct(res, null, code, head); res.end(JSON.stringify(data)); } +export const sj = (res, data, {code, head}={}) => { ct(res, null, code, head); res.end(JSON.stringify(data)); } -module.exports.res204 = (res) => { res.statusCode = 204; res.end(); } -module.exports.err400 = (res, msg) => { ct(res, "text/plain", 400); res.end(""+(msg||"400 Bad Request")); } -module.exports.err401 = (res, msg) => { ct(res, "text/plain", 401); res.end(""+(msg||"401 Unauthorized")); } -module.exports.err403 = (res, msg) => { ct(res, "text/plain", 403); res.end(""+(msg||"403 Forbidden")); } -module.exports.err404 = (res, msg) => { ct(res, "text/plain", 404); res.end(""+(msg||"404 Not Found")); } -module.exports.err500 = (res, msg) => { ct(res, "text/plain", 500); res.end(""+(msg||"500 Internal Server Error")); +export const res204 = (res) => { res.statusCode = 204; res.end(); } +export const err400 = (res, msg) => { ct(res, "text/plain", 400); res.end(""+(msg||"400 Bad Request")); } +export const err401 = (res, msg) => { ct(res, "text/plain", 401); res.end(""+(msg||"401 Unauthorized")); } +export const err403 = (res, msg) => { ct(res, "text/plain", 403); res.end(""+(msg||"403 Forbidden")); } +export const err404 = (res, msg) => { ct(res, "text/plain", 404); res.end(""+(msg||"404 Not Found")); } +export const err500 = (res, msg) => { ct(res, "text/plain", 500); res.end(""+(msg||"500 Internal Server Error")); console.log(Date.now+" [ERROR] 500"); } -module.exports.cors = fn => (req, res, ...rest) => { +export const cors = fn => (req, res, ...rest) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST"); fn(req, res, ...rest); }; -module.exports.parseCookies = req => +export const parseCookies = req => req.headers.cookie?.split(";") .map(c => c.split("=").map(s => decodeURIComponent(s.trim()))) .reduce((a,c) => (a[c[0]]=c[1],a), {}); + +export const clamp = (x,l,h) => Math.max(l,Math.min(x,h)); + +export function deepFreeze(obj) { + for (const name of Reflect.ownKeys(obj)) { + const value = obj[name]; + if ((value && typeof value === "object") || + typeof value === "function") deepFreeze(value); + } return Object.freeze(obj); +} |