summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexis Hovorka <[email protected]>2024-02-13 23:11:36 -0700
committerAlexis Hovorka <[email protected]>2024-02-13 23:11:36 -0700
commit70d3a32ae766dc15fd6c21e382068f44dbaff8b8 (patch)
treec288685745e0681dc5417fe64d7495a5aae06c36
parentaf96e03db9eab2a468561d1d82a32e8a7b01da90 (diff)
[feat] Flesh out auth flow and note store
-rw-r--r--app/app.js57
-rw-r--r--app/auth.js448
-rw-r--r--app/config.js36
-rw-r--r--app/note-store.js66
-rw-r--r--app/public/index.html21
-rw-r--r--app/public/main.js175
6 files changed, 781 insertions, 22 deletions
diff --git a/app/app.js b/app/app.js
index d9cd39c..23bf0ab 100644
--- a/app/app.js
+++ b/app/app.js
@@ -1,9 +1,9 @@
"use strict";
-const http = require("http");
const fs = require("fs");
-
-const argon2 = require("argon2");
+const http = require("http");
+const crypto = require("crypto");
+const {sj, cors} = require("./utils");
const Router = require("./lib/router");
const Static = require("./lib/static");
@@ -11,40 +11,55 @@ const Static = require("./lib/static");
//const pipe = require("./lib/pipe");
//const otp = require("./lib/otp");
+const config = require("./config");
+const {HOST, PORT} = config;
+
if (!fs.existsSync("./logs")) fs.mkdirSync("./logs");
if (!fs.existsSync("./users")) fs.mkdirSync("./users");
if (!fs.existsSync("./private")) fs.mkdirSync("./private");
-Math.clamp = Math.clamp || ((x,l,h) => Math.max(l,Math.min(x,h)));
-const PORT = Math.clamp(+process.env.PORT||8080, 1, 65535);
-const HOST = process.env.HOST||"0.0.0.0";
-
const server = http.createServer();
const stat = new Static("./public");
//const wss = new Socket(server);
const app = new Router();
-const cors = fn => (req, res, ...rest) => {
- res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Access-Control-Allow-Methods", "GET, POST");
- fn(req, res, ...rest); };
+const auth = require("./auth")(config);
+const authed = auth.authed;
+auth.attach(app);
-const sj = (res, data) => {
- res.setHeader("Content-Type", "application/json");
- res.end(JSON.stringify(data)); };
+const noteStore = require("./note-store");
+noteStore.attach(app, auth);
-//app.get("/", (req, res) => {
-// res.writeHead(302, {"Location":"https://alexishovorka.com/"});
-// res.end();
-//});
+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)});
+ // TODO avatar etc
+}));
-//await argon2.hash(password, {type: argon2.argon2id});
-//await argon2.verify(hash, password);
+// TODO https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
server.on("request", (req, res) => {
console.log(`${Date.now()} ${req.method} ${req.url}`);
+ //const scriptNonce = crypto.randomBytes(24).toString("hex");
+ //const styleNonce = crypto.randomBytes(24).toString("hex");
+ //res.setHeader("Strict-Transport-Security", "max-age=86400; includeSubDomains");
+ res.setHeader("Content-Security-Policy", ""
+ //+ `script-src 'nonce-${scriptNonce}' 'strict-dynamic'; ` // TODO
+ //+ `style-src 'nonce-${styleNonce}' 'strict-dynamic'; `
+ + "manifest-src 'self'; "
+ + "connect-src 'self'; "
+ + "worker-src 'self'; "
+ + "media-src 'self'; "
+ + "img-src 'self'; "
+ + "base-uri 'none'; "
+ + "object-src 'none'; "
+ + "form-action 'none'; "
+ + "frame-ancestors 'none'; "
+ );
+ res.setHeader("Cache-Control", 'no-cache="Set-Cookie"');
+ // TODO look into more cache headers
app.route(req, res) || stat.route(req, res);
});
server.listen(PORT, HOST);
-console.log(`${Date.now()} Running on http://${HOST}:${PORT}`);
+console.log(Date.now()+` Running on http://${HOST}:${PORT}`);
diff --git a/app/auth.js b/app/auth.js
new file mode 100644
index 0000000..eb71a2f
--- /dev/null
+++ b/app/auth.js
@@ -0,0 +1,448 @@
+"use strict";
+
+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");
+
+const rf = util.promisify(fs.readFile);
+const wf = util.promisify(fs.writeFile);
+const randHex = len => crypto.randomBytes(len).toString("hex");
+
+module.exports = config => {
+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 = {};
+
+// Run once immediately, then every interval until not called
+function debounce(fn, interval=100) {
+ let timer, doubled;
+ const timeoutFn = () => {
+ timer = undefined;
+ if (doubled) {
+ timer = setTimeout(timeoutFn, interval);
+ doubled = false;
+ return fn();
+ }
+ };
+
+ return () => {
+ if (doubled) return;
+ if (timer) return doubled = true;
+ timer = setTimeout(timeoutFn, interval);
+ return fn();
+ };
+};
+
+function loadJSONSync(path) {
+ const obj = {};
+ try {
+ Object.assign(obj, JSON.parse(fs.readFileSync(path)));
+ } catch(e) {
+ console.log(Date.now()+` Error loading ${path}, creating fallback empty set`);
+ fs.rename(path, path+".bad."+Date.now(), err => {});
+ }
+ return obj;
+}
+
+const checkTokenAge = tokenData =>
+ (Date.now()-tokenData.timestamp > TOKEN_MAX_AGE*1000) ||
+ (tokenData.renewed && Date.now()-tokenData.renewed > 1000);
+const setTokenCookie = (res, token, keepSession) =>
+ res.setHeader("Set-Cookie", `token=${token}; `+
+ (token? keepSession? "Max-Age="+TOKEN_MAX_AGE+"; " : "" : "Max-Age=0; ")+
+ `HttpOnly; SameSite=Strict; Domain=${DOMAIN}`+(SECURE?"; Secure":""));
+ //res.setHeader("Cache-Control", 'no-cache="Set-Cookie"'); // Done globally in app.js
+ // TODO Centralize cache options?
+
+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 =>
+ userIDs.hasOwnProperty(username.toLowerCase()) && userIDs[username.toLowerCase()];
+const getUsername = exports.getUsername = uid =>
+ userNames.hasOwnProperty(uid) && userNames[uid];
+const createUID = username => { let uid;
+ do { uid = randHex(5); } while (getUsername(uid));
+ userIDs[username.toLowerCase()] = uid;
+ writeUIDs();
+ return uid;
+}
+const changeUsername = (uid, newName) => {
+ if (userIDs[newName.toLowerCase()] !== uid) return false;
+ const oldName = userNames[uid].toLowerCase();
+ userNames[uid] = newName;
+ delete userIDs[oldName];
+ userIDs[newName.toLowerCase()] = uid;
+ writeUIDs();
+ return true;
+};
+
+const tokensFile = "private/tokens.json";
+const tokens = loadJSONSync(tokensFile), userSessions = {},
+ sessionIDs = new Set(), renewedTokens = {};
+const getUserSessions = uid => userSessions[uid] ||= new Set();
+for (const [token, tokenData] of Object.entries(tokens)) {
+ if (checkTokenAge(tokenData)) delete tokens[token];
+ else {
+ getUserSessions(tokenData.uid).add(token);
+ sessionIDs.add(tokenData.sessionID);
+ }
+}
+const newSessionID = () => { let id;
+ do { id = randHex(SESSION_ID_LENGTH); } while (sessionIDs.has(id));
+ return id;
+};
+
+const writeTokens = debounce(() => wf(tokensFile, JSON.stringify(tokens)));
+const deleteToken = (token, sessionEnd) => {
+ const tokenData = tokens[token];
+ if (sessionEnd) sessionIDs.delete(tokenData.sessionID);
+ userSessions[tokenData?.uid]?.delete(token);
+ delete tokens[token];
+ writeTokens();
+};
+const getToken = token => {
+ if (token.length !== TOKEN_LENGTH*2) {
+ console.log(Date.now()+" [WARN] Incorrect token format");
+ return false;
+ }
+
+ if (renewedTokens[token]) {
+ // Possible hijacking, nip both ends
+ deleteToken(renewedTokens[token], true);
+ return false;
+ }
+
+ const tokenData = tokens.hasOwnProperty(token) && tokens[token];
+ if (tokenData) {
+ if (checkTokenAge(tokenData)) {
+ deleteToken(token, true);
+ return false;
+ }
+ tokenData.lastUsed = Date.now();
+ writeTokens();
+ }
+ return tokenData;
+};
+const createToken = (uid, meta) => { let token;
+ do { token = randHex(TOKEN_LENGTH); } while (getToken(token));
+ const now = Date.now();
+ tokens[token] = Object.assign(meta, {uid, timestamp:now, lastUsed:now});
+ getUserSessions(uid).add(token);
+ writeTokens();
+ return token;
+};
+const renewToken = (res, token, fingerprint, gracePeriod=500) => {
+ const tokenData = tokens[token];
+ if (tokenData.renewed) return;
+ tokenData.renewed = Date.now();
+ const newToken = createToken(tokenData.uid,
+ Object.assign(fingerprint, {
+ sessionID: tokenData.sessionID,
+ keepSession: tokenData.keepSession,
+ sessionStart: tokenData.sessionStart,
+ }));
+
+ setTokenCookie(res, newToken, tokenData.keepSession);
+
+ setTimeout(() => {
+ deleteToken(token);
+ renewedTokens[token] = newToken;
+ }, gracePeriod); // Let parallel requests finish
+ setTimeout(() => delete renewedTokens[token], TOKEN_RENEW_AGE*1000);
+};
+
+const collectFingerprint = req => ({
+ userAgent: req.headers["user-agent"],
+ ip: fingerprintIP(req),
+ // TODO GeoIP? Maintain set?
+ // TODO Sec-CH-UA
+});
+
+const checkReferer = req => {
+ if (req.headers["referer"] && !req.headers["referer"].includes(DOMAIN))
+ console.log(Date.now()+" [WARN] Unexpected HTTP Referer: "+req.headers["referer"]); };
+
+async function login(req, res, match, data) {
+ const currentToken = parseCookies(req)?.token;
+ if (currentToken || !data.username || !data.password) return err400(res);
+ const error = {success:false, msg:"Bad username or password"};
+ const sendError = res => { rateLimitIP(req, 2, 9); sj(res, error); };
+
+ const uid = getUID(data.username);
+ if (!uid) return sendError(res); // User doesn't exist
+
+ let user;
+ try { user = JSON.parse(await rf(`private/${uid}.json`));
+ } catch (e) { return err500(res); } // Can't load user data
+
+ checkReferer(req);
+
+ const pass = user.password;
+ if (pass.startsWith("$change")) {
+ if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(res);
+ console.log(Date.now()+" Logging in "+uid+", must change password");
+ return sj(res, {success:true, changePassword:true});
+
+ } else if (pass.startsWith("$argon2")) {
+ if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(res); // Avoid DDOS
+ if (!await argon2.verify(pass, data.password)) return sendError(res);
+ if (user.mustChangePassword) {
+ console.log(Date.now()+" Logging in "+uid+", must change password");
+ return sj(res, {success:true, changePassword:true});
+ }
+
+ console.log(Date.now()+" Logging in "+uid);
+ setTokenCookie(res, createToken(uid,
+ Object.assign(collectFingerprint(req), {
+ sessionID: newSessionID(),
+ keepSession: data.keepSession,
+ sessionStart: Date.now(),
+ })), data.keepSession);
+ return sj(res, {success:true});
+
+ } else return err500(res);
+}
+
+function logout(req, res) {
+ const token = parseCookies(req)?.token;
+ const tokenData = getToken(token);
+ if (tokenData) {
+ console.log(Date.now()+" Logging out "+tokenData.uid);
+ deleteToken(token, true);
+ } else {
+ console.log(Date.now()+" [WARN] Logging out bad token");
+ rateLimitIP(req, -2, 4);
+ }
+ setTokenCookie(res, "");
+ res204(res);
+}
+
+function changePassword(req, res, match, data) {
+ const token = parseCookies(req)?.token;
+
+ const fn = async () => {
+ if (!data.password || !data.newPassword
+ || (!token && !data.username)) return err400(res);
+ const error = token?
+ {success:false, msg:"Bad password"}:
+ {success:false, msg:"Bad username or password"};
+ const sendError = res => { rateLimitIP(req, token?3:2, 9); sj(res, error); };
+
+ const uid = token? req.uid : getUID(data.username);
+ if (!uid) return token? err401(res) : sendError(req);
+
+ let user;
+ try { user = JSON.parse(await rf(`private/${uid}.json`));
+ } catch (e) { return err500(res); }
+
+ checkReferer(req);
+
+ const pass = user.password;
+ if (pass.startsWith("$change")) {
+ if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(req);
+
+ } else if (pass.startsWith("$argon2")) {
+ if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(req); // Avoid DDOS
+ if (!await argon2.verify(pass, data.password)) return sendError(req);
+ if (!token && !user.mustChangePassword) return err401(res); // Must log in; OTP etc.
+
+ } else return err500(res);
+
+ if (data.newPassword.length < PASSWORD_MIN_LENGTH)
+ return sj(res, {success:false, msg:`New password is too short (must be >=${PASSWORD_MIN_LENGTH} chars)`})
+ if (data.newPassword.length > PASSWORD_MAX_LENGTH)
+ return sj(res, {success:false, msg:`New password is too long (must be <=${PASSWORD_MAX_LENGTH} chars)`})
+
+ console.log(Date.now()+" Changing password for "+uid);
+ user.password = await argon2.hash(data.newPassword, {type: argon2.argon2id});
+ await wf(`private/${uid}.json`, JSON.stringify(user));
+
+ if (!res.getHeader("Set-Cookie")) { // Might have been renewed by authed()
+ if (token) renewToken(res, token, fingerprint, 0);
+ else setTokenCookie(res, createToken(uid,
+ Object.assign(collectFingerprint(req), {
+ sessionID: newSessionID(),
+ sessionStart: Date.now(),
+ keepSession: data.keepSession,
+ })), data.keepSession);
+ }
+
+ sj(res, {success:true});
+ };
+
+ if (token) return authed(fn)(req, res);
+ else return fn();
+}
+
+async function changeUsernameReq(req, res, match, data) {
+ if (!data.newUsername || !data.password) return err400(res);
+ if (data.newUsername.length > USERNAME_MAX_LENGTH)
+ return sj(res, {success:false, msg:`New username is too long (must be <=${USERNAME_MAX_LENGTH} chars)`})
+
+ const error = {success:false, msg:"Bad password"};
+ const sendError = res => { rateLimitIP(req, 3, 9); sj(res, error); };
+
+ let user;
+ try { user = JSON.parse(await rf(`private/${req.uid}.json`));
+ } catch (e) { return err500(res); }
+
+ const pass = user.password;
+ if (pass.startsWith("$change")) {
+ if (pass.split("$")[2] && pass.split("$")[2] !== data.password) return sendError(req);
+
+ } else if (pass.startsWith("$argon2")) {
+ if (data.password.length > PASSWORD_MAX_LENGTH) return sendError(req); // Avoid DDOS
+ if (!await argon2.verify(pass, data.password)) return sendError(req);
+
+ } else return err500(res);
+
+ console.log(Date.now()+` Changing username for ${req.uid} to ${data.newUsername}`);
+
+ rateLimitIP(req, -2, 7); // To limit name probing
+
+ if (changeUsername(req.uid, data.newUsername)) return sj(res, {success:true});
+ else return sj(res, {success:false, msg:"Username already taken"})
+}
+
+function sessionList(req, res) {
+ console.log(Date.now()+" Getting session list for "+req.uid);
+ sj(res, {
+ active: Array.from(getUserSessions(req.uid))
+ .map(t => tokens[t]).filter(s => !checkTokenAge(s) && !s.renewed)
+ .map(s => ({
+ id: s.sessionID,
+ currentSession: s.sessionID === req.sessionID,
+ sessionStart: s.sessionStart,
+ userAgent: s.userAgent,
+ lastUsed: s.lastUsed,
+ ip: s.ip,
+ // TODO other fingerprint info users should see to validate sessions
+ })),
+ recent: [], // TODO
+ });
+}
+
+function deauthSession(req, res, match, data) {
+ if (!data.id || data.id.length !== SESSION_ID_LENGTH) return err400(res);
+ console.log(Date.now()+" Deauthing session "+req.uid+":"+data.id);
+ const token = Array.from(getUserSessions(req.uid)).find(t => tokens[t].hash.startsWith(data.id));
+ if (!token) { console.log(Date.now()+" [WARN] Session doesn't exist"); return err400(res); }
+ deleteToken(token, true);
+
+ if (req.sessionID === data.id) setTokenCookie(res, "");
+ return res204(res);
+}
+
+function deauthAll(req, res) {
+ const uid = req.uid;
+ console.log(Date.now()+" Ending all sessions for "+uid);
+ const sessions = Array.from(getUserSessions(req.uid));
+ for (const token of sessions) deleteToken(token, true);
+ setTokenCookie(res, "");
+ return res204(res);
+}
+
+const rateLimitIPs = {};
+const rateLimitTime = level => 100 * Math.pow(2, level);
+function rateLimitIP(req, level, stickiness) { // TODO verify
+ const ip = fingerprintIP(req);
+ const rl = rateLimitIPs[ip] ||= {level, startingLevel:level, waiting:0};
+ const delta = level > rl.level? level - rl.level : 1;
+ rl.level += delta;
+
+ if (rl.level >= 0)
+ console.log(Date.now()+` [WARN] Rate limiting ${ip} (${level}:${rl.level})`);
+
+ setTimeout(() => {
+ if (rl.level >= 0) console.log(Date.now()+` Rate limiting decreased for ${ip}`);
+ rl.level -= delta;
+ if (rl.level === rl.startingLevel) delete rateLimitIPs[ip];
+ }, rateLimitTime(rl.level + stickiness));
+}
+const rateLimit = fn => (req, res, ...rest) => {
+ const rl = rateLimitIPs[fingerprintIP(req)];
+ if (!rl || rl.level < 0) return fn(req, res, ...rest);
+ if (rl.level > RATE_LIMIT_MAX_LEVEL ||
+ rl.waiting > RATE_LIMIT_MAX_WAITING) {
+ console.log(Date.now()+" [WARN] Aborting request (rate limiting maxed out)");
+ return req.destroy();
+ }
+
+ console.log(Date.now()+` [WARN] Rate limiting request for ${rateLimitTime(rl.level)}ms`);
+
+ rl.waiting++;
+ setTimeout(() => {
+ rl.waiting--;
+ fn(req, res, ...rest);
+ }, rateLimitTime(rl.level));
+}
+
+function authed(fn) { return rateLimit((req, res, ...rest) => {
+ const token = parseCookies(req)?.token;
+ if (!token) return err401(res);
+
+ const tokenData = getToken(token);
+ const uid = tokenData?.uid;
+ if (!tokenData) {
+ console.log(Date.now()+" [WARN] Bad token");
+ rateLimitIP(req, -2, 4);
+ setTokenCookie(res, "");
+ return err401(res);
+ }
+
+ checkReferer(req);
+
+ const fingerprint = collectFingerprint(req);
+ if (Date.now()-tokenData.timestamp < TOKEN_RENEW_AGE*1000 &&
+ fingerprint.ip !== tokenData.ip && fingerprint.userAgent !== tokenData.userAgent) {
+ console.log(Date.now()+" [WARN] Fingerprint check failed");
+ deleteToken(token, true);
+ rateLimitIP(req, 0, 4);
+ setTokenCookie(res, "");
+ return err401(res);
+ }
+ // TODO other verification from fingerprint
+ // - if user-agent changes too much (could be browser update if small)
+ // - if IP alternates between 2+ places too fast
+
+ if (Date.now()-tokenData.timestamp > TOKEN_RENEW_AGE*1000 && !tokenData.renewed) {
+ console.log(Date.now()+" Renewing token for "+uid);
+ renewToken(res, token, fingerprint);
+ }
+
+ req.uid = uid;
+ req.sessionID = tokenData.sessionID;
+ return fn(req, res, ...rest);
+}); } exports.authed = authed;
+
+exports.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]}
+ app.jpost("/change-username", authed(changeUsernameReq)); // {newUsername, password} -> {success[, msg]}
+ app.get("/session-list", authed(sessionList)); // -> {active:[{id:<sessionID>, ...}, ...], recent:[...]}
+ app.jpost("/deauth-session", authed(deauthSession)); // {id:<sessionID>}
+ app.post("/deauth-all", authed(deauthAll));
+};
+
+// TODO
+// - create user
+// - delete user
+// - OTP (MITM/XSS/shoulder surfing; ensure codes only work once)
+// - PIN (client-side, and easy session verification)
+// - normalize error response times?
+// - rate limit username/uid signin attempts?
+// - log failed attempts, then notify user
+// - 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
new file mode 100644
index 0000000..6a66027
--- /dev/null
+++ b/app/config.js
@@ -0,0 +1,36 @@
+"use strict";
+
+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);
+}
+
+Math.clamp ||= ((x,l,h) => Math.max(l,Math.min(x,h)));
+const PORT = Math.clamp(+process.env.PORT||8080, 1, 65535);
+const HOST = process.env.HOST||"0.0.0.0";
+
+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,
+ },
+});
diff --git a/app/note-store.js b/app/note-store.js
new file mode 100644
index 0000000..c816efd
--- /dev/null
+++ b/app/note-store.js
@@ -0,0 +1,66 @@
+"use strict";
+
+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";
+
+function genNoteID() {
+ const now = new Date().toISOString();
+ return now.replace(/[^0-9]/g,"").slice(0,16);
+}
+
+async function newNote(req, res) {
+ let noteID, noteFile;
+
+ do {
+ noteID = genNoteID();
+ noteFile = `${NOTE_DIR}/${req.uid}/${noteID}.md`;
+ } while (await exists(noteFile)) // TODO increment
+
+ console.log(Date.now()+` Creating note ${req.uid}:${noteID}`);
+
+ await wf(noteFile, "");
+
+ sj(res, {id:noteID, content:""});
+}
+
+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
+ sj(res, {id:match.noteID, content});
+}
+
+async function setNote(req, res, match, data) {
+ console.log(Date.now()+` Setting note ${req.uid}:${match.noteID}`);
+ if (match.noteID !== data.id) return err400(res);
+ const noteFile = `${NOTE_DIR}/${req.uid}/${match.noteID}.md`;
+ await wf(noteFile, data.content, "UTF-8");
+ sj(res, {});
+}
+
+async function listNotes(req, res) {
+ console.log(Date.now()+` Getting notes for ${req.uid}`);
+ const files = await rd(`${NOTE_DIR}/${req.uid}`);
+ const notes = files.map(fn => fn.slice(0,-3));
+ sj(res, notes);
+}
+
+// TODO list in order of recent updates w/ timestamps
+// TODO pagination?
+
+module.exports.attach = (app, {authed}) => {
+ app.get("/list", authed(listNotes));
+ app.post("/new", authed(newNote));
+ app.get("/(?<noteID>[0-9]{16})", authed(getNote));
+ app.jpost("/(?<noteID>[0-9]{16})", authed(setNote));
+};
diff --git a/app/public/index.html b/app/public/index.html
index 3c435f5..d5932a1 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -52,12 +52,31 @@
<meta name="author" content="Alexis Hovorka">
<meta name="description" content="Zettelkasten-inspired note taking web app">
- <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400&display=swap">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,700&display=swap">
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Notes</h1>
+ <form>
+ <input type="text" id="username" name="username">
+ <input type="password" id="password" name="password">
+ <input type="checkbox" id="keep-session" name="keepSession">
+ <button id="login">Log In</button>
+ </form>
+ <br>
+ <input type="password" id="new-password" name="newPassword">
+ <button id="change-password">Change Password</button>
+ <br>
+ <input type="text" id="new-username" name="newUsername">
+ <button id="change-username">Change Username</button>
+ <br>
+ <input type="text" id="uid" name="uid">
+ <button id="logout">Log Out</button>
+ <button id="logout-everywhere">Log Out Everywhere</button>
+ <div id="notes"></div>
+ <button id="new-note">New Note</button>
+
<!--script src="sock.js"></script-->
<script src="main.js"></script>
</body>
diff --git a/app/public/main.js b/app/public/main.js
index ff960c2..5dc9531 100644
--- a/app/public/main.js
+++ b/app/public/main.js
@@ -5,6 +5,17 @@ const $ = (s,c) => (c||document).querySelector(s);
function $$(x,y,z,a){a=(z||document).querySelectorAll(x);if(typeof y=="function")[].forEach.call(a,y);return a}
function m(a,b,c){c=document;b=c.createElement(b||"p");b.innerHTML=a.trim();for(a=c.createDocumentFragment();c=b.firstChild;)a.appendChild(c);return a.firstChild}
+function debounce(fn, delay) {
+ let timeout = null;
+ return (...args) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ timeout = null;
+ fn.apply(null, args);
+ }, delay||500);
+ }
+}
+
//sock.init(`ws${secure?"s":""}://${location.host}/ws`);
//sock.on("hello", e => {
// console.log("hello", e);
@@ -15,4 +26,168 @@ function m(a,b,c){c=document;b=c.createElement(b||"p");b.innerHTML=a.trim();for(
// navigator.serviceWorker.register("sw.js")
// .then(() => console.log("Service worker registered"));
//}
+
+function drawNote(note) {
+ const el = m(`<div>
+ <h3>${note.id}</h3>
+ <textarea>${note.content}</textarea>
+ </div>`);
+
+ $("textarea", el).addEventListener("input",
+ debounce(() => { saveNote(note.id, $("textarea", el).value); }, 500));
+
+ $("#notes").appendChild(el);
+}
+
+async function getNote(id) {
+ const res = await fetch(`${id}`);
+ const note = await res.json();
+ console.log(note);
+ drawNote(note);
+}
+
+async function getList() {
+ const res = await fetch("/list");
+ const list = await res.json();
+ console.log(list);
+
+ for (let i=0;i<list.length;i++) {
+ await getNote(list[i]);
+ }
+}
+
+async function saveNote(id, content) {
+ const res = await fetch("/"+id, {
+ method: "POST",
+ body: JSON.stringify({id, content}),
+ });
+
+ const note = await res.json();
+ console.log(note);
+}
+
+async function newNote() {
+ const res = await fetch("/new", {
+ method: "POST",
+ });
+
+ const note = await res.json();
+ console.log(note);
+
+ drawNote(note);
+}
+
+$("#new-note").addEventListener("click", newNote);
+
+async function getUserData() {
+ const res = await fetch(`/user`);
+ const user = await res.json();
+ console.log(user);
+ if (user.uid) $("#uid").value = user.uid;
+}
+
+async function getUserSessions() {
+ const res = await fetch(`/session-list`);
+ const sessions = await res.json();
+ console.log(sessions);
+}
+
+$("#login").addEventListener("click", async () => {
+ const res = await fetch("/login", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ username: $("#username").value,
+ password: $("#password").value,
+ keepSession: $("#keep-session").checked,
+ })
+ });
+
+ // TODO check return code
+
+ const json = await res.json();
+ console.log(json);
+
+ // TODO "Must Change Password" flow
+
+ getUserData();
+ getList();
+});
+
+$("#change-password").addEventListener("click", async () => {
+ const res = await fetch("/change-password", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ username: $("#username").value,
+ password: $("#password").value,
+ newPassword: $("#new-password").value,
+ keepSession: $("#keep-session").checked,
+ })
+ });
+
+ const json = await res.json();
+ console.log(json);
+
+ getUserData();
+});
+
+$("#change-username").addEventListener("click", async () => {
+ const res = await fetch("/change-username", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({
+ newUsername: $("#new-username").value,
+ password: $("#password").value,
+ })
+ });
+
+ const json = await res.json();
+ console.log(json);
+
+ getUserData();
+});
+
+$("#logout").addEventListener("click", async () => {
+ const res = await fetch("/logout", {
+ method: "POST",
+ });
+
+ //const json = await res.json();
+ //console.log(json);
+ console.log("Logged out");
+ $("#uid").value = "";
+});
+
+$("#logout-everywhere").addEventListener("click", async () => {
+ const res = await fetch("/deauth-all", {
+ method: "POST",
+ });
+
+ console.log("Logged out everywhere");
+ $("#uid").value = "";
+});
+
+try { getUserData(); getList(); getUserSessions(); } catch(e) {}
+
+// TODO make sure to save unsynced delta even if token has expired, then sync when logged in
+// TODO Cookie consent on signup; "only necessary for maintaining user session and cached data"
+// TODO "Review sessions" popup client-side when relogin required on a token which shouldn't have expired yet
+
+// TODO prevent data: and javascript: links?
+// Also embedding of external content?
+// encodeURI *AND* encode for attribute (quote marks etc)
+// https://github.com/cure53/DOMPurify
+//
+// elem.textContent = dangerVariable; !!!!!!!!!
+// elem.insertAdjacentText(dangerVariable);
+// elem.className = dangerVariable;
+// elem.setAttribute(safeName, dangerVariable);
+// formfield.value = dangerVariable;
+// document.createTextNode(dangerVariable);
+// document.createElement(dangerVariable);
+// elem.innerHTML = DOMPurify.sanitize(dangerVar);
+// https://github.com/cure53/DOMPurify/blob/main/src/attrs.js
+// https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
+
});