diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/app.js | 57 | ||||
-rw-r--r-- | app/auth.js | 448 | ||||
-rw-r--r-- | app/config.js | 36 | ||||
-rw-r--r-- | app/note-store.js | 66 | ||||
-rw-r--r-- | app/public/index.html | 21 | ||||
-rw-r--r-- | app/public/main.js | 175 |
6 files changed, 781 insertions, 22 deletions
@@ -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 + }); |