From 8a2bd6ecc377d36c52e3a9d94f004976274f3b0b Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Wed, 16 Apr 2025 19:50:12 -0700 Subject: [PATCH 01/21] update index & auth to use postgres --- index.js | 22 ++++++++++------------ liberals/auth.js | 33 +++++++++++---------------------- package-lock.json | 40 +++++++++++++++++++++++++++++----------- package.json | 3 ++- 4 files changed, 52 insertions(+), 46 deletions(-) diff --git a/index.js b/index.js index e0b2a20..dd4d492 100644 --- a/index.js +++ b/index.js @@ -1,28 +1,26 @@ import * as fs from 'fs' import { execSync } from 'child_process' +import postgres from 'postgres' import express, { json } from 'express' const app = express() -if (!process.env.API_DBHOST || !process.env.API_DBCRED) { - console.log("FATAL: API_DBHOST and API_DBCRED must be set") +if (!process.env.DATABASE_URI) { + console.log("FATAL: DATABASE_URI must be set") process.exit(1) } -const globalConfig = await fetch(`${process.env.API_DBHOST}/config/${process.env.API_DBCRED.split(":")[0]}`,{ - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} -}).then(response => { - if (response.status !== 200) { - console.log(`FATAL: Failed to download configuration: ${response.status} ${response.statusText}`) - process.exit(1) - } else { - return response.json() - } +const db = postgres(process.env.DATABASE_URI) + +const globalConfig = await db`select content from config where id = ${process.env.CONFIG_OVERRIDE ?? 'production'}`.then(response => {return response[0]["content"]}).catch(error => { + console.log(`FATAL: Error occured in downloading configuration: ${error}`) + process.exit(1) }) + const globalVersion = execSync(`git show --oneline -s`).toString().split(" ")[0] // Returns ISO 8601 Date & 24hr time for UTC-7/PDT const startTime = new Date(new Date().getTime() - 25200000).toISOString().slice(0,19).replace('T',' ') -export { app, fs, globalConfig, globalVersion } +export { app, fs, db, globalConfig, globalVersion } app.use(json()) // Allows receiving JSON bodies // see important note: https://expressjs.com/en/api.html#express.json diff --git a/liberals/auth.js b/liberals/auth.js index 171db72..e7ac0ca 100644 --- a/liberals/auth.js +++ b/liberals/auth.js @@ -1,31 +1,20 @@ -import { globalConfig } from "../index.js" +import { globalConfig, db } from "../index.js" /** - * Checks if a token exists in the sessions file (authentication) and if it has the correct permissions (authorization) + * (DEPRECATED) Checks if a token exists in the sessions file (authentication) and if it has the correct permissions (authorization) * @param {string} token Token as received by client * @param {string} scope Scope the token will need to have in order to succeed - * @returns True for successful authentication and authorization, false if either fail + * @returns {boolean} True for successful authentication and authorization, false if either fail */ async function checkToken(token,scope) { - return await fetch(`${process.env.API_DBHOST}/auth/sessions`, { - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} - }).then(fetchRes => { - - return fetchRes.json().then(dbRes => { - - if (dbRes.sessions[token] == undefined) { // If the token is not on the sessions list then reject - return false - } else if (dbRes.sessions[token].scopes.includes(scope)) { // If the token is on the seesions list and includes the scope then accept - return true - } else { // Otherwise reject - return false - } - - }) - - }).catch(error => { - console.log(`ERROR: auth.js: Fetch failed: ${error}`) - return false + return await db`select s.token, s.scopes, s.expires, u.username from sessions s join users u on s.owner = u.id where s.token = ${token}`.then(response => { + if (response.length === 0) { + return false + } else if (response[0]?.scopes.split(",").includes(scope)) { + return true + } else { + return false + } }) } diff --git a/package-lock.json b/package-lock.json index 0088f1a..6374e23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "express": "^4.18.2", "marked": "^14.1.3", - "nodemailer": "^6.9.15" + "nodemailer": "^6.9.15", + "postgres": "^3.4.5" }, "devDependencies": { "@types/bun": "^1.0.12", @@ -259,9 +260,9 @@ } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -283,7 +284,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -298,6 +299,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/finalhandler": { @@ -466,9 +471,9 @@ } }, "node_modules/marked": { - "version": "14.1.3", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz", - "integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==", + "version": "14.1.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz", + "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -591,11 +596,24 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/postgres": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.5.tgz", + "integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 8f9de25..ee5577d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "dependencies": { "express": "^4.18.2", "marked": "^14.1.3", - "nodemailer": "^6.9.15" + "nodemailer": "^6.9.15", + "postgres": "^3.4.5" }, "name": "enstrayedapi", "version": "1.0.0", From 4c0f140e9edfd28b71587152ae8ea9b0777ec8a0 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Thu, 17 Apr 2025 22:41:08 -0700 Subject: [PATCH 02/21] working changes --- liberals/auth.js | 13 ++++ routes/debug.js | 15 ++++ routes/etyd.js | 197 +++++++++++++++++++++++------------------------ todo.md | 5 ++ 4 files changed, 128 insertions(+), 102 deletions(-) create mode 100644 routes/debug.js create mode 100644 todo.md diff --git a/liberals/auth.js b/liberals/auth.js index e7ac0ca..eb60dc8 100644 --- a/liberals/auth.js +++ b/liberals/auth.js @@ -18,4 +18,17 @@ async function checkToken(token,scope) { }) } +/** + * New function to check if a token exists in the sessions table (authentication) and if it has the desired scope (authorization) + * @param {string} token Token as received by client + * @param {string} scope Desired scope for action + * @typedef {Object} Object containing the result and the username of the token owner + * @property {boolean} result Boolean result of if the check passed + * @property {string} owner Username of the token owner + */ + +async function checkTokenNew(token,scope) { + +} + export {checkToken} \ No newline at end of file diff --git a/routes/debug.js b/routes/debug.js new file mode 100644 index 0000000..c155ad5 --- /dev/null +++ b/routes/debug.js @@ -0,0 +1,15 @@ +import { app, globalConfig } from "../index.js" // Get globals from index +import { checkToken } from "../liberals/auth.js" +import { logRequest } from "../liberals/logging.js" + +app.get("/api/debugtokencheck", (rreq,rres) => { + checkToken(rreq.get("Authorization"),"etyd").then(authRes => { + if (authRes) { + rres.sendStatus(200) + } else { + rres.sendStatus(401) + } + }) +}) + +export { app } \ No newline at end of file diff --git a/routes/etyd.js b/routes/etyd.js index f185745..653a4ea 100644 --- a/routes/etyd.js +++ b/routes/etyd.js @@ -1,130 +1,123 @@ -import { app, globalConfig } from "../index.js" // Get globals from index +import { app, db, globalConfig } from "../index.js" // Get globals from index import { checkToken } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" app.get("/api/etyd*", (rreq,rres) => { - fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd","")}`,{ - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} - }).then(dbRes => { - if (dbRes.status == 404) { - rres.sendStatus(404) + let userRequest = rreq.path.replace("/api/etyd/","") + db`select content from etyd where url = ${userRequest}`.then(response => { + if (response.length == 0) { + rres.status(404).send(`etyd.cc: URL "${userRequest}" was not found`) } else { - dbRes.json().then(dbRes => { - try { - rres.redirect(dbRes.content.url) // Node will crash if the Database entry is malformed - } catch (responseError) { - logRequest(rres,rreq,500,responseError) - rres.sendStatus(500) - } - }) + rres.redirect(response[0].content) } - }).catch(fetchError => { - logRequest(rres,rreq,500,fetchError) - rres.sendStatus(500) + }).catch(dbError => { + logRequest(rres,rreq,500,dbError) + rres.status(500).send(`etyd.cc: An internal error occured`) }) }) -// app.delete("/api/etyd*", (rreq,rres) => { +app.delete("/api/etyd*", (rreq,rres) => { -// if (rreq.get("Authorization") === undefined) { -// rres.sendStatus(400) -// } else { -// checkToken(rreq.get("Authorization"),"etyd").then(authRes => { -// if (authRes === false) { -// rres.sendStatus(401) -// } else if (authRes === true) { // Authorization successful + if (rreq.get("Authorization") === undefined) { + rres.sendStatus(400) + } else { + checkToken(rreq.get("Authorization"),"etyd").then(authRes => { + if (authRes === false) { + rres.sendStatus(401) + } else if (authRes === true) { // Authorization successful -// fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ -// headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} -// }).then(dbRes => { + fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ + headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} + }).then(dbRes => { -// if (dbRes.status == 404) { -// rres.sendStatus(404) // Entry does not exist -// } else { -// dbRes.json().then(dbRes => { + if (dbRes.status == 404) { + rres.sendStatus(404) // Entry does not exist + } else { + dbRes.json().then(dbRes => { -// fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", ""),{ -// headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} -// }}`, { -// method: "DELETE", -// headers: { -// "If-Match": dbRes["_rev"] // Using the If-Match header is easiest for deleting entries in couchdb -// } -// }).then(fetchRes => { -// if (fetchRes.status == 200) { -// // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 200 KEY: ${rreq.get("Authorization")}`) -// logRequest(rres,rreq,200) -// rres.sendStatus(200) -// } -// }).catch(fetchError => { -// // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 500: ${fetchError}`) -// logRequest(rres,rreq,500,fetchError) -// rres.sendStatus(500) -// }) + fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ + headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`}, + method: "DELETE", + headers: { + "If-Match": dbRes["_rev"] // Using the If-Match header is easiest for deleting entries in couchdb + } + }).then(fetchRes => { + + if (fetchRes.status == 200) { + // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 200 KEY: ${rreq.get("Authorization")}`) + logRequest(rres,rreq,200) + rres.sendStatus(200) + } else { + rres.send(`Received status ${fetchRes.status}`) + } + }).catch(fetchError => { + // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 500: ${fetchError}`) + logRequest(rres,rreq,500,fetchError) + rres.sendStatus(500) + }) -// }) -// } + }) + } -// }).catch(fetchError => { -// logRequest(rres,rreq,500,fetchError) -// rres.sendStatus(500) -// }) + }).catch(fetchError => { + logRequest(rres,rreq,500,fetchError) + rres.sendStatus(500) + }) -// } -// }) -// } + } + }) + } -// }) +}) -// app.post("/api/etyd*", (rreq,rres) => { +app.post("/api/etyd*", (rreq,rres) => { -// if (rreq.get("Authorization") === undefined) { -// rres.sendStatus(400) -// } else { -// checkToken(rreq.get("Authorization"),"etyd").then(authRes => { -// if (authRes === false) { -// rres.sendStatus(401) -// } else if (authRes === true) { // Authorization successful + if (rreq.get("Authorization") === undefined) { + rres.sendStatus(400) + } else { + checkToken(rreq.get("Authorization"),"etyd").then(authRes => { + if (authRes === false) { + rres.sendStatus(401) + } else if (authRes === true) { // Authorization successful -// if (rreq.body["url"] == undefined) { -// rres.sendStatus(400) -// } else { -// fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", ""),{ -// headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} -// }}`, { -// method: "PUT", -// body: JSON.stringify({ -// "content": { -// "url": rreq.body["url"] -// } -// }) -// }).then(dbRes => { + if (rreq.body["url"] == undefined) { + rres.sendStatus(400) + } else { + fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ + headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`}, + method: "PUT", + body: JSON.stringify({ + "content": { + "url": rreq.body["url"] + } + }) + }).then(dbRes => { -// switch(dbRes.status) { -// case 409: -// rres.sendStatus(409) -// break; + switch(dbRes.status) { + case 409: + rres.sendStatus(409) + break; -// case 201: -// rres.status(200).send(rreq.path.replace("/api/etyd", "")) -// break; + case 201: + rres.status(200).send(rreq.path.replace("/api/etyd", "")) + break; -// default: -// logRequest(rres,rreq,500,`CouchDB PUT did not return expected code: ${dbRes.status} ${dbRes.statusText}`) -// rres.sendStatus(500) -// break; -// } + default: + logRequest(rres,rreq,500,`CouchDB PUT did not return expected code: ${dbRes.status} ${dbRes.statusText}`) + rres.sendStatus(500) + break; + } -// }).catch(fetchError => { -// logRequest(rres,rreq,500,fetchError) -// rres.sendStatus(500) -// }) -// } + }).catch(fetchError => { + logRequest(rres,rreq,500,fetchError) + rres.sendStatus(500) + }) + } -// } -// }) -// } + } + }) + } -// }) +}) export {app} // export routes to be imported by index for execution \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..71f8b01 --- /dev/null +++ b/todo.md @@ -0,0 +1,5 @@ +- [ ] GET /api/whoami - Returns owner of token and what scopes it has +- [ ] GET /api/login - OIDC login redirect to ECLS +- [ ] GET /api/callback - Creates new token that is intended to be local to browser; e.g. can be used in turn to make longer lasting more specific tokens +- [ ] POST /api/token - Allows owner to create a new token with customized scopes, comments & expiration date +- [ ] DELETE /api/token - Invalidate a token \ No newline at end of file From b917800eece394ad71f0b5f331b4a609200b9f50 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Fri, 18 Apr 2025 13:16:49 -0700 Subject: [PATCH 03/21] implement new token checking function and modify email.js to use it --- liberals/auth.js | 12 ++++++++++-- liberals/logging.js | 5 +++-- routes/email.js | 14 +++++++------- todo.md | 4 +++- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/liberals/auth.js b/liberals/auth.js index eb60dc8..6348230 100644 --- a/liberals/auth.js +++ b/liberals/auth.js @@ -28,7 +28,15 @@ async function checkToken(token,scope) { */ async function checkTokenNew(token,scope) { - + return await db`select s.token, s.scopes, s.expires, u.username from sessions s join users u on s.owner = u.id where s.token = ${token}`.then(response => { + if (response.length === 0) { + return { result: false, owner: response[0]?.username} + } else if (response[0]?.scopes.split(",").includes(scope)) { + return { result: true, owner: response[0]?.username} + } else { + return { result: false, owner: response[0]?.username} + } + }) } -export {checkToken} \ No newline at end of file +export {checkToken, checkTokenNew} \ No newline at end of file diff --git a/liberals/logging.js b/liberals/logging.js index 3586855..ecdb8e2 100644 --- a/liberals/logging.js +++ b/liberals/logging.js @@ -4,9 +4,10 @@ * @param {object} request Parent request object * @param {number} code Status code to log, should be same as sent to client * @param {string} extra Optional extra details to add to log, ideal for caught errors + * @param {object} authresponse Optionally include result of auth response to include owner information for a token */ -function logRequest(response,request,code,extra) { - console.log(`${request.get("cf-connecting-ip") ?? request.ip} ${request.get("Authorization") ?? ""} ${request.method} ${request.path} returned ${code} ${extra ?? ""}`) +function logRequest(response,request,code,extra,authresponse) { + console.log(`${request.get("cf-connecting-ip") ?? request.ip} ${authresponse.owner ?? ""}/${request.get("Authorization") ?? ""} ${request.method} ${request.path} returned ${code} ${extra ?? ""}`) } export { logRequest } \ No newline at end of file diff --git a/routes/email.js b/routes/email.js index 4119776..3d916ca 100644 --- a/routes/email.js +++ b/routes/email.js @@ -1,5 +1,5 @@ import { app, globalConfig } from "../index.js" // Get globals from index -import { checkToken } from "../liberals/auth.js" +import { checkTokenNew } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" import * as nodemailer from 'nodemailer' @@ -14,10 +14,10 @@ const transporter = nodemailer.createTransport({ }) app.post("/api/sendemail", (rreq,rres) => { - checkToken(rreq.get("Authorization"),"email").then(authRes => { - if (authRes === false) { + checkTokenNew(rreq.get("Authorization"),"email").then(authRes => { + if (authRes.result === false) { rres.sendStatus(401) - } else if (authRes === true) { + } else if (authRes.result === true) { if (rreq.body == undefined || rreq.body.recipient == undefined) { // 2024-05-11: Turbo bodge check to make sure request JSON is valid, probably wont work but whatever rres.sendStatus(400) } else { @@ -29,14 +29,14 @@ app.post("/api/sendemail", (rreq,rres) => { text: rreq.body.message ?? "Message Not Set" }).then(transportResponse => { if (transportResponse.response.slice(0,1) === "2") { - logRequest(rres,rreq,200,transportResponse.response) + logRequest(rres,rreq,200,transportResponse.response,authRes) rres.status(200).send(transportResponse.response) } else { - logRequest(rres,rreq,400,transportResponse.response) + logRequest(rres,rreq,400,transportResponse.response,authRes) rres.status(400).send(transportResponse.response) } }).catch(transportError => { - logRequest(rres,rreq,500,transportError) + logRequest(rres,rreq,500,transportError,authRes) rres.sendStatus(500) }) diff --git a/todo.md b/todo.md index 71f8b01..27281b2 100644 --- a/todo.md +++ b/todo.md @@ -2,4 +2,6 @@ - [ ] GET /api/login - OIDC login redirect to ECLS - [ ] GET /api/callback - Creates new token that is intended to be local to browser; e.g. can be used in turn to make longer lasting more specific tokens - [ ] POST /api/token - Allows owner to create a new token with customized scopes, comments & expiration date -- [ ] DELETE /api/token - Invalidate a token \ No newline at end of file +- [ ] DELETE /api/token - Invalidate a token +- [ ] liberals/libnowplaying - Implement queryCider() +- [ ] routes/nowplaying - Reimplement query order to Cider and then Jellyfin From 9f96d82e53d34ba191b68c312848455f5c0332d8 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:36:15 -0700 Subject: [PATCH 04/21] auth modification --- liberals/auth.js | 9 +++++---- routes/auth.js | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 routes/auth.js diff --git a/liberals/auth.js b/liberals/auth.js index 6348230..0db2b29 100644 --- a/liberals/auth.js +++ b/liberals/auth.js @@ -25,16 +25,17 @@ async function checkToken(token,scope) { * @typedef {Object} Object containing the result and the username of the token owner * @property {boolean} result Boolean result of if the check passed * @property {string} owner Username of the token owner + * @property {number} ownerId Database ID of the token owner */ async function checkTokenNew(token,scope) { - return await db`select s.token, s.scopes, s.expires, u.username from sessions s join users u on s.owner = u.id where s.token = ${token}`.then(response => { + return await db`select s.*, u.username from sessions s join users u on s.owner = u.id where s.token = ${token}`.then(response => { if (response.length === 0) { - return { result: false, owner: response[0]?.username} + return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner} } else if (response[0]?.scopes.split(",").includes(scope)) { - return { result: true, owner: response[0]?.username} + return { result: true, owner: response[0]?.username, ownerId: response[0]?.owner} } else { - return { result: false, owner: response[0]?.username} + return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner} } }) } diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..fce1deb --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,25 @@ +import { app, db, globalConfig } from "../index.js" // Get globals from index +import { checkTokenNew } from "../liberals/auth.js" +import { logRequest } from "../liberals/logging.js" + +app.get("/api/auth/whoami", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +app.get("/api/auth/login", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +app.get("/api/auth/callback", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +app.post("/api/auth/token", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +app.delete("/api/auth/token", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +export { app } \ No newline at end of file From 74cf834699084844577d61276d384114494edf00 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:52:49 -0700 Subject: [PATCH 05/21] fix problem with logging & add misc functions --- liberals/logging.js | 2 +- liberals/misc.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 liberals/misc.js diff --git a/liberals/logging.js b/liberals/logging.js index ecdb8e2..ebbe529 100644 --- a/liberals/logging.js +++ b/liberals/logging.js @@ -7,7 +7,7 @@ * @param {object} authresponse Optionally include result of auth response to include owner information for a token */ function logRequest(response,request,code,extra,authresponse) { - console.log(`${request.get("cf-connecting-ip") ?? request.ip} ${authresponse.owner ?? ""}/${request.get("Authorization") ?? ""} ${request.method} ${request.path} returned ${code} ${extra ?? ""}`) + console.log(`${request.get("cf-connecting-ip") ?? request.ip} ${authresponse?.owner ?? ""}/${request.get("Authorization") ?? ""} ${request.method} ${request.path} returned ${code} ${extra ?? ""}`) } export { logRequest } \ No newline at end of file diff --git a/liberals/misc.js b/liberals/misc.js new file mode 100644 index 0000000..b12a8f6 --- /dev/null +++ b/liberals/misc.js @@ -0,0 +1,23 @@ +function randomStringBase16(length) { + let characters = "0123456789abcdef" + let remaining = length + let returnstring = "" + while (remaining > 0) { + returnstring = returnstring + characters.charAt(Math.floor(Math.random() * characters.length)) + remaining = remaining - 1 + } + return returnstring +} + +function randomStringBase62(length) { + let characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHJIJKLMNOPQRSTUVWXYZ" + let remaining = length + let returnstring = "" + while (remaining > 0) { + returnstring = returnstring + characters.charAt(Math.floor(Math.random() * characters.length)) + remaining = remaining - 1 + } + return returnstring +} + +export { randomStringBase16, randomStringBase62 } \ No newline at end of file From d07d75c994415321b868703915f28cb257bbdc5a Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sun, 20 Apr 2025 18:30:29 -0700 Subject: [PATCH 06/21] main groundwork for oidc handling --- routes/auth.js | 34 ++++++++++++++++++++++++++++++++-- routes/debug.js | 4 ++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/routes/auth.js b/routes/auth.js index fce1deb..b78b00e 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -7,11 +7,41 @@ app.get("/api/auth/whoami", (rreq,rres) => { }) app.get("/api/auth/login", (rreq,rres) => { - rres.send("Non functional endpoint") + rres.redirect(`${globalConfig.oidc.authorizeUrl}?client_id=${globalConfig.oidc.clientId}&response_type=code&scope=openid enstrayedapi&redirect_uri=${rreq.protocol}://${rreq.get("Host")}/api/auth/callback`) }) app.get("/api/auth/callback", (rreq,rres) => { - rres.send("Non functional endpoint") + fetch(globalConfig.oidc.tokenUrl, { // Call token endpoint at IdP using code provdided during callback + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded"}, + body: `grant_type=authorization_code&code=${rreq.query.code}&redirect_uri=${rreq.protocol}://${rreq.get("Host")}/api/auth/callback&client_id=${globalConfig.oidc.clientId}&client_secret=${globalConfig.oidc.clientSecret}` + }).then(fetchRes1 => { + fetchRes1.json().then(fetchRes1 => { // Convert response to JSON then continue + if (fetchRes1.error) { // Fetch to token endpoint succeded but resulted in error, usually because the provided code is invalid + logRequest(rres,rreq,500,`Callback-Token-${fetchRes1.error}`) + rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Token-${fetchRes1.error}`) + } else { // Assumed success + fetch(globalConfig.oidc.userinfoUrl, { // Call userinfo endpoint at IdP using token provided during previous step + headers: { "Authorization": `Bearer ${fetchRes1.access_token}`} + }).then(fetchRes2 => { + if (fetchRes2.ok === false) { // Fetch to userinfo endpoint succeded but resulted in error (usually 401) + logRequest(rres,rreq,500,`Callback-Userinfo-${fetchRes2.status}`) + rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Userinfo-${fetchRes2.status}`) + } else { + fetchRes2.json().then(fetchRes2 => { + rres.send(fetchRes2) + }) + } + }).catch(fetchErr2 => { // Fetch to userinfo endpoint failed for some other reason + logRequest(rres,rreq,500,`Callback-Fetch2-${fetchErr2}`) + rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Fetch2-${fetchErr2}`) + }) + } + }) + }).catch(fetchErr1 => { // Fetch to token endpoint failed for some other reason + logRequest(rres,rreq,500,`Callback-Fetch1-${fetchErr1}`) + rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Fetch1-${fetchErr1}`) + }) }) app.post("/api/auth/token", (rreq,rres) => { diff --git a/routes/debug.js b/routes/debug.js index c155ad5..c5c350e 100644 --- a/routes/debug.js +++ b/routes/debug.js @@ -12,4 +12,8 @@ app.get("/api/debugtokencheck", (rreq,rres) => { }) }) +app.get("/api/debugurl", (rreq,rres) => { + rres.send(`${rreq.protocol}://${rreq.get("Host")}`) +}) + export { app } \ No newline at end of file From c868c9bc09821966baa69e1aa0ba31192559202a Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sun, 20 Apr 2025 20:15:02 -0700 Subject: [PATCH 07/21] groundwork for writing new tokens to db --- liberals/misc.js | 34 +++++++++++++++++++++++++++++++++- routes/auth.js | 27 ++++++++++++++++++--------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/liberals/misc.js b/liberals/misc.js index b12a8f6..5fafd90 100644 --- a/liberals/misc.js +++ b/liberals/misc.js @@ -20,4 +20,36 @@ function randomStringBase62(length) { return returnstring } -export { randomStringBase16, randomStringBase62 } \ No newline at end of file +function getHumanReadableUserAgent(useragent) { + let formattedua = useragent.replace(/[\/()]/g," ").split(" ") + let os = "" + let browser = "" + + if (formattedua.includes("Windows")) { + os += "Windows" + } else if (formattedua.includes("Macintosh")) { + os += "macOS" + } else if (formattedua.includes("iPhone")) { + os += "iOS" + } else if (formattedua.includes("Android")) { + os += "Android" + } else if (formattedua.includes("Linux")) { + os += "Linux" + } else { + os += "Other" + } + + if (formattedua.includes("Firefox")) { + browser += "Firefox" + } else if (formattedua.includes("Chrome")) { + browser += "Chrome" + } else if (formattedua.includes("Safari")) { + browser += "Safari" + } else { + browser += "Other" + } + + return `${os} ${browser}` +} + +export { randomStringBase16, randomStringBase62, getHumanReadableUserAgent } \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js index b78b00e..cec9dc7 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,6 +1,7 @@ import { app, db, globalConfig } from "../index.js" // Get globals from index import { checkTokenNew } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" +import { randomStringBase62, getHumanReadableUserAgent } from "../liberals/misc.js" app.get("/api/auth/whoami", (rreq,rres) => { rres.send("Non functional endpoint") @@ -18,30 +19,38 @@ app.get("/api/auth/callback", (rreq,rres) => { }).then(fetchRes1 => { fetchRes1.json().then(fetchRes1 => { // Convert response to JSON then continue if (fetchRes1.error) { // Fetch to token endpoint succeded but resulted in error, usually because the provided code is invalid - logRequest(rres,rreq,500,`Callback-Token-${fetchRes1.error}`) - rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Token-${fetchRes1.error}`) + localError500(`Callback-Token-${fetchRes1.error}`) } else { // Assumed success fetch(globalConfig.oidc.userinfoUrl, { // Call userinfo endpoint at IdP using token provided during previous step headers: { "Authorization": `Bearer ${fetchRes1.access_token}`} }).then(fetchRes2 => { if (fetchRes2.ok === false) { // Fetch to userinfo endpoint succeded but resulted in error (usually 401) - logRequest(rres,rreq,500,`Callback-Userinfo-${fetchRes2.status}`) - rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Userinfo-${fetchRes2.status}`) + localError500(`Callback-Userinfo-${fetchRes2.status}`) } else { fetchRes2.json().then(fetchRes2 => { - rres.send(fetchRes2) + let newToken = randomStringBase62(64) + let newExpiration = Date.now() + 86400 + let newComment = `Login token for ${getHumanReadableUserAgent(rreq.get("User-Agent"))} on ${rreq.get("cf-connecting-ip") ?? rreq.ip}` + db`select * from users where oidc_username = ${fetchRes2.username};`.then(dbRes1 => { + db`insert into sessions (token,owner,scopes,expires,comment) values (${newToken},${dbRes1[0]?.id},${fetchRes2.enstrayedapi_scopes},${newExpiration},${newComment})`.then(dbRes2 => { + rres.send(dbRes2) + }) + }) }) } }).catch(fetchErr2 => { // Fetch to userinfo endpoint failed for some other reason - logRequest(rres,rreq,500,`Callback-Fetch2-${fetchErr2}`) - rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Fetch2-${fetchErr2}`) + localError500(`Callback-Fetch2-${fetchErr2}`) }) } }) }).catch(fetchErr1 => { // Fetch to token endpoint failed for some other reason - logRequest(rres,rreq,500,`Callback-Fetch1-${fetchErr1}`) - rres.status(500).send(`An error occured during login, a token was not created.

500 Callback-Fetch1-${fetchErr1}`) + localError500(`Callback-Fetch-${fetchErr1}`) }) + + function localError500(code) { + logRequest(rres,rreq,500,code) + rres.status(500).send(`An error occured during login, a token was not created.

500 ${code}`) + } }) app.post("/api/auth/token", (rreq,rres) => { From 2ad8d04c9961922a21d43ffb74d26cdf6e16b13d Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:22:54 -0700 Subject: [PATCH 08/21] readme will need rewritten later --- README.md | 53 ++--------------------------------------------------- 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index f3b9c36..903d3f7 100644 --- a/README.md +++ b/README.md @@ -8,49 +8,12 @@ This file contains documentation relevant for development and deployment, but no If you would like to report a bug or security issue, please open a GitHub issue. If you are the operator of a service this application accesses, use the contact information provided during registration with your service to contact me directly. ## Configuration -The configuration is downloaded from CouchDB on startup, however two environment variables must be set to specify the URL of the CouchDB server and the credentials for accessing it: -| Variable | Required? | Purpose | -|--------------|----------------------|-----------------------------------------------------------------------------------------------------| -| `API_PORT` | No, defaults to 8081 | Sets the port the server will listen on | -| `API_DBHOST` | Yes | Complete URL of the CouchDB instance, including port and protocol | -| `API_DBCRED` | Yes | Credentials to access the CouchDB instance, in Basic Authentication format e.g. `username:password` | -
Configuration Example -* `frontpage.directory`: Directory of frontpage, will be served at root with modifications. -* `mailjet.apiKey`: Mailjet API Key. -* `mailjet.senderAddress`: Email address that emails will be received from, must be verified in Mailjet admin panel. -* `nowplaying.*.apiKey`: API key of respective service. -* `nowplaying.*.target`: User that should be queried to retrieve playback information. +
Configuration Template ```json -{ - "frontpage": { - "directory": "" - }, - "mailjet": { - "apiKey": "", - "senderAddress": "" - }, - - "nowplaying": { - "lastfm": { - "apiKey": "", - "target": "" - }, - "jellyfin": { - "apiKey": "", - "host": "", - "target": "" - }, - "cider": { - "apiKeys": [], - "hosts": [] - } - } - -} ```
@@ -58,9 +21,6 @@ The configuration is downloaded from CouchDB on startup, however two environment ## Docker In production, this application is designed to be run in Docker, and the container built by pulling the latest commit from the main branch. As such, deploying this application is just a matter of creating a directory and copying the Dockerfile: -> [!IMPORTANT] -> Please review the Configuration section of this document for important information. By default, the `config.json` file is expected to be mounted into the container at `/app/config.json`. - ```dockerfile FROM node:22 WORKDIR /app @@ -75,16 +35,7 @@ ENTRYPOINT [ "node", "index.js" ]
Docker Compose File ```yaml ---- -services: - enstrayedapi: - build: - context: . - image: enstrayedapi - container_name: enstrayedapi - restart: unless-stopped - volumes: - - ./config.json:/app/config.json + ```
From a92d3c7a68082631345db4820d2fa012688fffe1 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Thu, 24 Apr 2025 19:30:46 -0700 Subject: [PATCH 09/21] begin new website --- website/idea.txt | 9 +++++++++ website/posts/20240409-API-Documentation.html | 8 +++++--- website/static/posts.css | 3 ++- website/static/snow-leopard.svg | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 website/idea.txt create mode 100644 website/static/snow-leopard.svg diff --git a/website/idea.txt b/website/idea.txt new file mode 100644 index 0000000..8ed9c10 --- /dev/null +++ b/website/idea.txt @@ -0,0 +1,9 @@ +Welcome to Enstrayed.com | @enstrayed +I received an Email from @enstrayed.com | Twitter Bluesky Github +↗️ Click here for information about EOES | API Documentation +I need help with an enstrayed.com web service | +↗️ Click here to visit the Helpdesk | +I'm looking for something else +➡️ URL Toolbox +➡️ API Documentation +➡️ Downloads \ No newline at end of file diff --git a/website/posts/20240409-API-Documentation.html b/website/posts/20240409-API-Documentation.html index c030ee6..d419713 100644 --- a/website/posts/20240409-API-Documentation.html +++ b/website/posts/20240409-API-Documentation.html @@ -34,9 +34,11 @@

/api/etyd/*

etyd.js - GET - POST - DELETE +
+ GET + POST + DELETE +

Retrieves, creates or deletes entries for the etyd.cc URL shortener. Replace * in the URL for the target of the request.

diff --git a/website/static/posts.css b/website/static/posts.css index 3df6078..656b127 100644 --- a/website/static/posts.css +++ b/website/static/posts.css @@ -50,9 +50,10 @@ h2 { align-items: center; margin-top: 2rem; gap: 1em; + flex-wrap: wrap; } -.inlineheader > span { +.inlineheader > div > span { padding: 0.2rem; color: white; background-color: #f06445; diff --git a/website/static/snow-leopard.svg b/website/static/snow-leopard.svg new file mode 100644 index 0000000..4eb629f --- /dev/null +++ b/website/static/snow-leopard.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From e59162ae04424105b37f74857d6e9ec82e404e01 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sat, 26 Apr 2025 06:35:37 -0700 Subject: [PATCH 10/21] new index & icons --- liberals/libnowplaying.js | 4 +- routes/frontpage.js | 4 +- website/idea.txt | 9 --- website/static/bs.ico | Bin 16958 -> 0 bytes website/static/icons/external.svg | 4 ++ website/static/icons/github.svg | 3 + website/static/icons/link.svg | 4 ++ website/static/icons/post.svg | 4 ++ website/static/icons/twitter.svg | 3 + website/static/newindex.css | 79 ++++++++++++++++++++++++ website/static/orange-globe.svg | 3 + website/static/snow-leopard.ico | Bin 0 -> 7753 bytes website/static/snow-leopard.png | Bin 0 -> 7837 bytes website/static/snow-leopard.svg | 2 +- website/templates/newindextemplate.html | 66 ++++++++++++++++++++ 15 files changed, 171 insertions(+), 14 deletions(-) delete mode 100644 website/idea.txt delete mode 100644 website/static/bs.ico create mode 100644 website/static/icons/external.svg create mode 100644 website/static/icons/github.svg create mode 100644 website/static/icons/link.svg create mode 100644 website/static/icons/post.svg create mode 100644 website/static/icons/twitter.svg create mode 100644 website/static/newindex.css create mode 100644 website/static/orange-globe.svg create mode 100644 website/static/snow-leopard.ico create mode 100644 website/static/snow-leopard.png create mode 100644 website/templates/newindextemplate.html diff --git a/liberals/libnowplaying.js b/liberals/libnowplaying.js index 0bfb6a2..3876e18 100644 --- a/liberals/libnowplaying.js +++ b/liberals/libnowplaying.js @@ -27,7 +27,7 @@ async function queryLastfm() { "artUrl": response.recenttracks.track[0].image[3]["#text"], "link": response.recenttracks.track[0].url }, - "html": `Album Art

I'm listening to

${response.recenttracks.track[0].name} by ${response.recenttracks.track[0].artist["#text"]}

from ${response.recenttracks.track[0].album["#text"]}

View on Last.fm
` + "html": `Album Art
I'm listening to ${response.recenttracks.track[0].name} by ${response.recenttracks.track[0].artist["#text"]} from ${response.recenttracks.track[0].album["#text"]} View on Last.fm
` } } } @@ -60,7 +60,7 @@ async function queryJellyfin() { "artUrl": `${globalConfig.nowplaying.jellyfin.hostPublic}/Items/${response[x].NowPlayingItem.Id}/Images/Primary`, "link": `https://www.last.fm/music/${response[x].NowPlayingItem.Artists[0].replaceAll(" ","+")}/_/${response[x].NowPlayingItem.Name.replaceAll(" ","+")}` }, - "html": `Album Art

I'm listening to

${response[x].NowPlayingItem.Name} by ${response[x].NowPlayingItem.Artists[0]}

from ${response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`}

View on Last.fm
` + "html": `Album Art
I'm listening to ${response[x].NowPlayingItem.Name} by ${response[x].NowPlayingItem.Artists[0]} from ${response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`} View on Last.fm
` } } diff --git a/routes/frontpage.js b/routes/frontpage.js index a91402e..91fc172 100644 --- a/routes/frontpage.js +++ b/routes/frontpage.js @@ -11,7 +11,7 @@ app.get("/", (rreq, rres) => { if (Date.now() < timeSinceLastQuery+10000) { rres.send(cachedResult) } else { - let indexFile = fs.readFileSync(process.cwd()+"/website/templates/indextemplate.html","utf-8") + let indexFile = fs.readFileSync(process.cwd()+"/website/templates/newindextemplate.html","utf-8") cachedResult = indexFile.replace("",parseFiles()).replace("",`API Version ${globalVersion}`) rres.send(cachedResult) } @@ -22,7 +22,7 @@ app.get("/static/*", (rreq,rres) => { }) app.get("/favicon.ico", (rreq,rres) => { - rres.sendFile(process.cwd()+"/website/static/bs.ico") + rres.sendFile(process.cwd()+"/website/static/snow-leopard.ico") }) app.get("/posts/*", (rreq,rres) => { diff --git a/website/idea.txt b/website/idea.txt deleted file mode 100644 index 8ed9c10..0000000 --- a/website/idea.txt +++ /dev/null @@ -1,9 +0,0 @@ -Welcome to Enstrayed.com | @enstrayed -I received an Email from @enstrayed.com | Twitter Bluesky Github -↗️ Click here for information about EOES | API Documentation -I need help with an enstrayed.com web service | -↗️ Click here to visit the Helpdesk | -I'm looking for something else -➡️ URL Toolbox -➡️ API Documentation -➡️ Downloads \ No newline at end of file diff --git a/website/static/bs.ico b/website/static/bs.ico deleted file mode 100644 index bfb61b8c7cb0c1b45df2bcda3de2d7c9713f939a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16958 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 + + + \ No newline at end of file diff --git a/website/static/icons/github.svg b/website/static/icons/github.svg new file mode 100644 index 0000000..e1632c7 --- /dev/null +++ b/website/static/icons/github.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/static/icons/link.svg b/website/static/icons/link.svg new file mode 100644 index 0000000..df65a89 --- /dev/null +++ b/website/static/icons/link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/website/static/icons/post.svg b/website/static/icons/post.svg new file mode 100644 index 0000000..4ab0ab5 --- /dev/null +++ b/website/static/icons/post.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/website/static/icons/twitter.svg b/website/static/icons/twitter.svg new file mode 100644 index 0000000..7d37572 --- /dev/null +++ b/website/static/icons/twitter.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/static/newindex.css b/website/static/newindex.css new file mode 100644 index 0000000..ba58806 --- /dev/null +++ b/website/static/newindex.css @@ -0,0 +1,79 @@ +body { + margin: 2em 0 0 0; + font-family: 'Segoe UI Variable', sans-serif; + + background-color: #282828; + color: #F1F1F1; + + display: grid; + place-items: center; + + +} + +.mainContent { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 2.5em; + + padding: 2em; + background-color: #202020; + box-shadow: 0 0 1em 0 #202020; +} + +.linkColumn { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +#nowplaying { + display: flex; + flex-direction: row; + gap: 1em; + flex-wrap: wrap; +} + +#nowplaying > img { + height: 10em; +} + +#nowplaying > div { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.2em; + max-width: 14em; +} + +#nowplaying .nowPlayingLine2 { + font-size: 1.4em; +} + +.apiVersion { + margin: 0.8em 0 0.8em; +} + +.marginBottom1em { + margin: 0 0 1em; +} + +.blogPostsList > ul { + list-style-image: url('/static/icons/post.svg'); + list-style-position: inside; + padding: 0; + margin: 0; +} + +a, a:link { + color: #FF5A36; +} + +a:hover { + color: #ff8266; +} + +a > img { + margin-right: 0.2em; +} \ No newline at end of file diff --git a/website/static/orange-globe.svg b/website/static/orange-globe.svg new file mode 100644 index 0000000..67bdf05 --- /dev/null +++ b/website/static/orange-globe.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/static/snow-leopard.ico b/website/static/snow-leopard.ico new file mode 100644 index 0000000000000000000000000000000000000000..2320ba56f5d891e9f6a25a74cce0f81464ba52b6 GIT binary patch literal 7753 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 literal 0 HcmV?d00001 diff --git a/website/static/snow-leopard.svg b/website/static/snow-leopard.svg index 4eb629f..344d548 100644 --- a/website/static/snow-leopard.svg +++ b/website/static/snow-leopard.svg @@ -1,6 +1,6 @@ - + diff --git a/website/templates/newindextemplate.html b/website/templates/newindextemplate.html new file mode 100644 index 0000000..c7ce888 --- /dev/null +++ b/website/templates/newindextemplate.html @@ -0,0 +1,66 @@ + + + + + + Enstrayed + + + + + + +
+
+ Globe Icon +

Welcome to Enstrayed.com

+
+
+ I received an Email from @enstrayed.com
+ Click here for information about EOES +
+
+ I need help with an Enstrayed.com web service
+ Click here to visit the Helpdesk +
+ + +
+ Downloads +
+
+ +
+
+
+
+ Snow Leopard Icon +

@Enstrayed

+
+ Twitter + GitHub +
+
+
+

Notes

+
    + +
+
+
+
+ + \ No newline at end of file From f3bb0f9142e7b445819262a2046767a2c2456543 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sat, 26 Apr 2025 08:54:31 -0700 Subject: [PATCH 11/21] update css for posts & docs tweaks --- website/posts/20240409-API-Documentation.html | 32 +++++++++++--- website/static/newposts.css | 42 +++++++++++++++++++ website/templates/markdownposttemplate.html | 6 ++- website/templates/newindextemplate.html | 10 ++++- 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 website/static/newposts.css diff --git a/website/posts/20240409-API-Documentation.html b/website/posts/20240409-API-Documentation.html index d419713..fd7f099 100644 --- a/website/posts/20240409-API-Documentation.html +++ b/website/posts/20240409-API-Documentation.html @@ -2,7 +2,7 @@ - + @@ -16,21 +16,39 @@ Return to enstrayed.com

API Documentation

-

This page was last updated 2024-08-20.

+

This page was last updated 2025-04-26.

Source Code & Issue Tracker: github.com/enstrayed/enstrayedapi


+
+

Important Note

+
+ +

Multiple API endpoints are being rewritten/added, especially relating to authentication & authorization, as part of a database change to Postgres. These changes are being made in the new-db branch.

+

/api/nowplaying

nowplaying.js - GET +
+ GET +

Returns whatever I'm listening to via the Last.fm API in JSON. If ?format=html is used in the URL it will return the same but in HTML. If nothing is playing the JSON response will just have "playing": false.

+
+

/api/nowplayingbeta

+ nowplaying.js +
+ GET +
+
+ +

Beta verison of the /nowplaying endpoint. This version will change frequently but presently queries my Jellyfin for what I'm listening to and will return that as JSON. If ?format=html is appended to the URL it will return the same but in HTML. Each line in the HTML response has a class nowPlayingLine[1-4] that can be used to style the text using CSS. See libnowplaying.js:63 for the format of the result.

+

/api/etyd/*

etyd.js @@ -55,7 +73,9 @@

/api/sendemail

mailjet.js - POST +
+ POST +

Sends an email to the specified recipient, intended for application & automation use.

@@ -74,7 +94,7 @@

/api/ip

ip.js - GET +
GET

Returns the IP, country and Cloudflare ray of the request in JSON.

@@ -82,7 +102,7 @@

/api/headers

ip.js - GET +
GET

Returns all request headers in JSON.

diff --git a/website/static/newposts.css b/website/static/newposts.css new file mode 100644 index 0000000..55c79f7 --- /dev/null +++ b/website/static/newposts.css @@ -0,0 +1,42 @@ +html { + display: grid; + place-items: center; + background-color: #282828; +} + +body { + margin: 2em 0 0 0; + font-family: 'Segoe UI Variable', sans-serif; + + background-color: #202020; + color: #F1F1F1; + + max-width: 80ch; + padding: 2em; +} + +a, a:link { + color: #FF5A36; +} + +a:hover { + color: #ff8266; +} + +.inlineheader { + display: flex; + align-items: center; + margin-top: 2rem; + gap: 1em; + flex-wrap: wrap; +} + +.inlineheader h2 { + margin: 0; +} + +.inlineheader > div > span { + padding: 0.2rem; + color: white; + background-color: #f06445; +} \ No newline at end of file diff --git a/website/templates/markdownposttemplate.html b/website/templates/markdownposttemplate.html index cbcf96e..a34529f 100644 --- a/website/templates/markdownposttemplate.html +++ b/website/templates/markdownposttemplate.html @@ -2,16 +2,18 @@ - + - + <!--SSR_REPLACE_TITLE--> + + diff --git a/website/templates/newindextemplate.html b/website/templates/newindextemplate.html index c7ce888..3c9ab4a 100644 --- a/website/templates/newindextemplate.html +++ b/website/templates/newindextemplate.html @@ -3,7 +3,15 @@ - Enstrayed + Enstrayed.com + + + + + + + + Success! You may now close this window.`) + } else { + rres.setHeader("Set-Cookie", `APIToken=${newToken}; Domain=${rreq.hostname}; Expires=${new Date(newExpiration).toUTCString()}; Path=/`).send(`Success! No state was provided, so you can close this window.`) + } + }).catch(dbErr => { + localError500(`Callback-Write-${dbErr}`) }) + + }) } }).catch(fetchErr2 => { // Fetch to userinfo endpoint failed for some other reason From 080f58baa06ab41015af0bd061d7e489f4d2b560 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sun, 4 May 2025 19:15:26 -0700 Subject: [PATCH 14/21] more auth changes + add tokenman page --- routes/auth.js | 27 +++++++++++++- routes/frontpage.js | 4 -- website/static/pages/tokenman.html | 60 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 website/static/pages/tokenman.html diff --git a/routes/auth.js b/routes/auth.js index 55b81a2..515e74d 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -5,7 +5,7 @@ import { randomStringBase62, getHumanReadableUserAgent } from "../liberals/misc. app.get("/api/auth/whoami", (rreq,rres) => { if (!rreq.cookies["APIToken"] && !rreq.get("Authorization")) { - rres.send({ "loggedIn": false, "username": "", "scopes": "" }) + rres.status(400).send({ "loggedIn": false, "username": "", "scopes": "" }) } else { db`select s.scopes, u.username from sessions s join users u on s.owner = u.id where s.token = ${rreq.cookies["APIToken"] ?? rreq.get("Authorization")}`.then(dbRes => { if (dbRes.length > 0 && dbRes.length < 2) { @@ -37,6 +37,23 @@ app.get("/api/auth/login", (rreq,rres) => { }) +app.get("/api/auth/logout", (rreq,rres) => { + if (rreq.cookies["APIToken"] || rreq.get("Authorization")) { + db`delete from sessions where token = ${rreq.cookies["APIToken"] ?? rreq.get("Authorization")}`.then(dbRes => { + if (dbRes.count > 0) { + rres.send("Success") + } else { + rres.status(400).send("Error: Token does not exist.") + } + }).catch(dbErr => { + logRequest(rres,rreq,500,dbErr) + rres.status(500).send("Error: Exception occured while invalidating token, details: "+dbErr) + }) + } else { + rres.status(400).send("Error: Missing token or authorization header, you may not be logged in.") + } +}) + app.get("/api/auth/callback", (rreq,rres) => { fetch(globalConfig.oidc.tokenUrl, { // Call token endpoint at IdP using code provdided during callback method: "POST", @@ -99,4 +116,12 @@ app.delete("/api/auth/token", (rreq,rres) => { rres.send("Non functional endpoint") }) +app.get("/api/auth/tokenlist", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + +app.get("/api/auth/nuke", (rreq,rres) => { + rres.send("Non functional endpoint") +}) + export { app } \ No newline at end of file diff --git a/routes/frontpage.js b/routes/frontpage.js index 91fc172..1682a47 100644 --- a/routes/frontpage.js +++ b/routes/frontpage.js @@ -39,10 +39,6 @@ app.get("/posts/*", (rreq,rres) => { }) -app.get("/urltoolbox", (rreq,rres) => { - rres.send("Under construction") -}) - function parseFiles() { let files = fs.readdirSync(process.cwd()+"/website/posts") let result = "" diff --git a/website/static/pages/tokenman.html b/website/static/pages/tokenman.html new file mode 100644 index 0000000..b2b8879 --- /dev/null +++ b/website/static/pages/tokenman.html @@ -0,0 +1,60 @@ + + + + + + TokenMan + + + + +

TokenMan

+
+ + + Not Logged In +
+ + \ No newline at end of file From 4be52c7f26e28afccddfc732613a1192d38e2ffe Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sun, 4 May 2025 22:59:46 -0700 Subject: [PATCH 15/21] documentation updates --- routes/auth.js | 3 +- website/posts/20240409-API-Documentation.html | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/routes/auth.js b/routes/auth.js index 515e74d..d99beae 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,4 +1,4 @@ -import { app, db, globalConfig } from "../index.js" // Get globals from index +// import { app, db, globalConfig } from "../index.js" // Get globals from index import { checkTokenNew } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" import { randomStringBase62, getHumanReadableUserAgent } from "../liberals/misc.js" @@ -80,6 +80,7 @@ app.get("/api/auth/callback", (rreq,rres) => { let newDestination = atob(rreq.query.state.split("_")[1].replace("-","/")) rres.setHeader("Set-Cookie", `APIToken=${newToken}; Domain=${rreq.hostname}; Expires=${new Date(newExpiration).toUTCString()}; Path=/`).redirect(newDestination) } else if (rreq.query.state === "display") { + // Change this to not write the token to a cookie rres.setHeader("Set-Cookie", `APIToken=${newToken}; Domain=${rreq.hostname}; Expires=${new Date(newExpiration).toUTCString()}; Path=/`).send(`Success! Your token is ${newToken}`) } else if (rreq.query.state === "close") { rres.setHeader("Set-Cookie", `APIToken=${newToken}; Domain=${rreq.hostname}; Expires=${new Date(newExpiration).toUTCString()}; Path=/`).send(` Success! You may now close this window.`) diff --git a/website/posts/20240409-API-Documentation.html b/website/posts/20240409-API-Documentation.html index fd7f099..cee6fdf 100644 --- a/website/posts/20240409-API-Documentation.html +++ b/website/posts/20240409-API-Documentation.html @@ -106,5 +106,40 @@

Returns all request headers in JSON.

+ +
+

/api/auth/whoami

+ auth.js:6 +
GET
+
+

Returns JSON with the username of the token owner as well as what scopes the token has access to.

+ +
+ + auth.js:23 +
GET
+
+

Redirects the user to ECLS to login. The state parameter can be used to specify how the login flow will behave. The accepted "states" are:

+
    +
  • redirect - Redirects the user to a page after logging in. This paramter requires the destination paramter to also be set with the URL the user will be redirected to.
  • +
  • display - Displays the generated token to the user after login. Currently, this still writes the new token to the APIToken cookie, though this is planned to change.
  • +
  • close - This will close the page after logging in. This requires the page to be opened with JavaScript otherwise it will not automatically close.
  • +
+ +
+

/api/auth/logout

+ auth.js:40 +
GET
+
+

Invalidates the token used to access the endpoint.

+ +
+

/api/auth/callback

+ auth.js:57 +
GET
+
+

Internal Use Only. This is the endpoint used by ECLS to finish the login flow. It will write the newly created token to the APIToken cookie as well as performing the action set by state, see login endpoint.

+ + \ No newline at end of file From d3f0b29094d8343d020f2e072c8534147f1ad562 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Fri, 9 May 2025 16:59:54 -0700 Subject: [PATCH 16/21] finish etyd + checktoken tweaks --- liberals/auth.js | 37 ++++++++----- routes/auth.js | 2 +- routes/debug.js | 19 ------- routes/email.js | 2 +- routes/etyd.js | 134 +++++++++++++++-------------------------------- 5 files changed, 68 insertions(+), 126 deletions(-) delete mode 100644 routes/debug.js diff --git a/liberals/auth.js b/liberals/auth.js index 0db2b29..066316c 100644 --- a/liberals/auth.js +++ b/liberals/auth.js @@ -19,25 +19,36 @@ async function checkToken(token,scope) { } /** - * New function to check if a token exists in the sessions table (authentication) and if it has the desired scope (authorization) - * @param {string} token Token as received by client + * Checks for tokens provided in request and validates them against the sessions table including the desired scope + * @param {object} request Express request object * @param {string} scope Desired scope for action - * @typedef {Object} Object containing the result and the username of the token owner + * @typedef {object} Object containing the result and the username of the token owner * @property {boolean} result Boolean result of if the check passed * @property {string} owner Username of the token owner * @property {number} ownerId Database ID of the token owner */ -async function checkTokenNew(token,scope) { - return await db`select s.*, u.username from sessions s join users u on s.owner = u.id where s.token = ${token}`.then(response => { - if (response.length === 0) { - return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner} - } else if (response[0]?.scopes.split(",").includes(scope)) { - return { result: true, owner: response[0]?.username, ownerId: response[0]?.owner} - } else { - return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner} - } - }) +async function checkTokenNew(request, scope) { + + if (!request.cookies["APIToken"] && !request.get("Authorization")) { + return { result: false, owner: "", ownerId: "" } + } else { + return await db`select s.*, u.username from sessions s join users u on s.owner = u.id where s.token = ${request.get("Authorization") ?? request.cookies["APIToken"]}`.then(response => { + if (response.length === 0) { + return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner } + } else if (response[0]?.scopes.split(",").includes(scope)) { + return { result: true, owner: response[0]?.username, ownerId: response[0]?.owner } + } else { + return { result: false, owner: response[0]?.username, ownerId: response[0]?.owner } + } + }).catch(dbErr => { + return { result: false, owner: "", ownerId: "" } + }) + } + + + + } export {checkToken, checkTokenNew} \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js index d99beae..7178d09 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,4 +1,4 @@ -// import { app, db, globalConfig } from "../index.js" // Get globals from index +import { app, db, globalConfig } from "../index.js" // Get globals from index import { checkTokenNew } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" import { randomStringBase62, getHumanReadableUserAgent } from "../liberals/misc.js" diff --git a/routes/debug.js b/routes/debug.js deleted file mode 100644 index c5c350e..0000000 --- a/routes/debug.js +++ /dev/null @@ -1,19 +0,0 @@ -import { app, globalConfig } from "../index.js" // Get globals from index -import { checkToken } from "../liberals/auth.js" -import { logRequest } from "../liberals/logging.js" - -app.get("/api/debugtokencheck", (rreq,rres) => { - checkToken(rreq.get("Authorization"),"etyd").then(authRes => { - if (authRes) { - rres.sendStatus(200) - } else { - rres.sendStatus(401) - } - }) -}) - -app.get("/api/debugurl", (rreq,rres) => { - rres.send(`${rreq.protocol}://${rreq.get("Host")}`) -}) - -export { app } \ No newline at end of file diff --git a/routes/email.js b/routes/email.js index 3d916ca..9058096 100644 --- a/routes/email.js +++ b/routes/email.js @@ -14,7 +14,7 @@ const transporter = nodemailer.createTransport({ }) app.post("/api/sendemail", (rreq,rres) => { - checkTokenNew(rreq.get("Authorization"),"email").then(authRes => { + checkTokenNew(rreq,"email").then(authRes => { if (authRes.result === false) { rres.sendStatus(401) } else if (authRes.result === true) { diff --git a/routes/etyd.js b/routes/etyd.js index 653a4ea..dbd3cf1 100644 --- a/routes/etyd.js +++ b/routes/etyd.js @@ -1,5 +1,5 @@ import { app, db, globalConfig } from "../index.js" // Get globals from index -import { checkToken } from "../liberals/auth.js" +import { checkToken, checkTokenNew } from "../liberals/auth.js" import { logRequest } from "../liberals/logging.js" app.get("/api/etyd*", (rreq,rres) => { @@ -18,105 +18,55 @@ app.get("/api/etyd*", (rreq,rres) => { app.delete("/api/etyd*", (rreq,rres) => { - if (rreq.get("Authorization") === undefined) { - rres.sendStatus(400) - } else { - checkToken(rreq.get("Authorization"),"etyd").then(authRes => { - if (authRes === false) { - rres.sendStatus(401) - } else if (authRes === true) { // Authorization successful - - fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} - }).then(dbRes => { - - if (dbRes.status == 404) { - rres.sendStatus(404) // Entry does not exist - } else { - dbRes.json().then(dbRes => { - - fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`}, - method: "DELETE", - headers: { - "If-Match": dbRes["_rev"] // Using the If-Match header is easiest for deleting entries in couchdb - } - }).then(fetchRes => { - - if (fetchRes.status == 200) { - // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 200 KEY: ${rreq.get("Authorization")}`) - logRequest(rres,rreq,200) - rres.sendStatus(200) - } else { - rres.send(`Received status ${fetchRes.status}`) - } - }).catch(fetchError => { - // console.log(`${rres.get("cf-connecting-ip")} DELETE ${rreq.path} returned 500: ${fetchError}`) - logRequest(rres,rreq,500,fetchError) - rres.sendStatus(500) - }) - - }) - } - - }).catch(fetchError => { - logRequest(rres,rreq,500,fetchError) + checkTokenNew(rreq,"etyd").then(authRes => { + if (authRes.result === false) { + rres.sendStatus(401) // Token not provided or invalid for this action + } else { + db`delete from etyd where url = ${rreq.path.replace("/api/etyd/","")} and owner = ${authRes.ownerId}`.then(dbRes => { + if (dbRes.count === 1) { + rres.sendStatus(200) + } else if (dbRes.count === 0) { + rres.sendStatus(400) + } else { + logRequest(rres, rreq, 500, `Something bad happened during delete from database`) rres.sendStatus(500) - }) - - } - }) - } + } + }).catch(dbErr => { + logRequest(rres, rreq, 500, dbErr) + rres.sendStatus(500) + }) + } + }) }) app.post("/api/etyd*", (rreq,rres) => { - if (rreq.get("Authorization") === undefined) { - rres.sendStatus(400) - } else { - checkToken(rreq.get("Authorization"),"etyd").then(authRes => { - if (authRes === false) { - rres.sendStatus(401) - } else if (authRes === true) { // Authorization successful - - if (rreq.body["url"] == undefined) { - rres.sendStatus(400) - } else { - fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd", "")}`,{ - headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`}, - method: "PUT", - body: JSON.stringify({ - "content": { - "url": rreq.body["url"] - } - }) - }).then(dbRes => { - - switch(dbRes.status) { - case 409: - rres.sendStatus(409) - break; - - case 201: - rres.status(200).send(rreq.path.replace("/api/etyd", "")) - break; - - default: - logRequest(rres,rreq,500,`CouchDB PUT did not return expected code: ${dbRes.status} ${dbRes.statusText}`) - rres.sendStatus(500) - break; - } - - }).catch(fetchError => { - logRequest(rres,rreq,500,fetchError) + checkTokenNew(rreq,"etyd").then(authRes => { + if (authRes.result === false) { + rres.sendStatus(401) // Token not provided or invalid for this action + } else { + if (!rreq.body["url"]) { // Assumption that if the url key isnt present in the body then the request is malformed + rres.sendStatus(400) + } else { + db`insert into etyd (url,content,owner) values (${rreq.path.replace("/api/etyd/","")},${rreq.body["url"]},${authRes.ownerId})`.then(dbRes => { + if (dbRes.count === 1) { + rres.sendStatus(201) + } else { + logRequest(rres,rreq,500,`Database insert did not return expected count but did not error out`) rres.sendStatus(500) - }) - } - + } + }).catch(dbErr => { + if (dbErr.code == "23505") { // Unique constraint violation, entry already exists + rres.sendStatus(409) + } else { + logRequest(rres,rreq,500,dbErr) + rres.sendStatus(500) + } + }) } - }) - } + } + }) }) From 3461e9f618d80ca97ad714c3cf097bc0d702858e Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Sat, 10 May 2025 00:29:37 -0700 Subject: [PATCH 17/21] what --- website/static/icons/external.svg | 2 +- website/static/icons/github.svg | 2 +- website/static/icons/link.svg | 2 +- website/static/icons/post.svg | 2 +- website/static/icons/twitter.svg | 2 +- website/static/newindex.css | 19 ++++++++++++++----- website/templates/newindextemplate.html | 11 ++++++++--- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/website/static/icons/external.svg b/website/static/icons/external.svg index 9f0431b..afc5079 100644 --- a/website/static/icons/external.svg +++ b/website/static/icons/external.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/website/static/icons/github.svg b/website/static/icons/github.svg index e1632c7..90e3f81 100644 --- a/website/static/icons/github.svg +++ b/website/static/icons/github.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/website/static/icons/link.svg b/website/static/icons/link.svg index df65a89..ff2f0dc 100644 --- a/website/static/icons/link.svg +++ b/website/static/icons/link.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/website/static/icons/post.svg b/website/static/icons/post.svg index 4ab0ab5..e96c37b 100644 --- a/website/static/icons/post.svg +++ b/website/static/icons/post.svg @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/website/static/icons/twitter.svg b/website/static/icons/twitter.svg index 7d37572..e08ec79 100644 --- a/website/static/icons/twitter.svg +++ b/website/static/icons/twitter.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/website/static/newindex.css b/website/static/newindex.css index 26acfa6..ca7755d 100644 --- a/website/static/newindex.css +++ b/website/static/newindex.css @@ -1,9 +1,9 @@ body { - margin: 2em 0 0 0; + margin: 0; font-family: 'Segoe UI Variable', sans-serif; background-color: #282828; - color: #F1F1F1; + display: grid; place-items: center; @@ -18,8 +18,11 @@ body { gap: 2.5em; padding: 2em; - background-color: #202020; + background-color: #fdfdfd; box-shadow: 0 0 1em 0 #202020; + + position: relative; + margin-top: 96px; } .linkColumn { @@ -48,7 +51,7 @@ body { } #nowplaying .nowPlayingLine2 { - font-size: 1.4em; + font-size: 1.25em; } .apiVersion { @@ -68,7 +71,7 @@ body { } a, a:link { - color: #FF5A36; + color: #366FFF; } a:hover { @@ -77,4 +80,10 @@ a:hover { a > img { margin-right: 0.2em; +} + +#miauMiau { + position: absolute; + top: -90px; + right: 9%; } \ No newline at end of file diff --git a/website/templates/newindextemplate.html b/website/templates/newindextemplate.html index 5ca4add..eeea358 100644 --- a/website/templates/newindextemplate.html +++ b/website/templates/newindextemplate.html @@ -27,9 +27,12 @@ +
+ Snow Leopard Icon
- Globe Icon + +

Welcome to Enstrayed.com

@@ -55,13 +58,13 @@
- Snow Leopard Icon +

@Enstrayed

-
+

Notes

    @@ -70,5 +73,7 @@
+ +
\ No newline at end of file From 1c3427e63fe90e1ef8ba34aaf238a58839d3b903 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 16 May 2025 00:23:09 -0700 Subject: [PATCH 18/21] groundwork for helpdesk stuff --- routes/auth.js | 2 +- routes/helpdesk.js | 11 +++ .../helpdesk/forms/ecls_deleteaccount.json | 14 +++ website/helpdesk/kbas/example.html | 11 +++ website/helpdesk/templates/landing.html | 96 +++++++++++++++++++ website/static/helpdesk.css | 78 +++++++++++++++ website/static/icons/arrow-right.svg | 3 + 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 routes/helpdesk.js create mode 100644 website/helpdesk/forms/ecls_deleteaccount.json create mode 100644 website/helpdesk/kbas/example.html create mode 100644 website/helpdesk/templates/landing.html create mode 100644 website/static/helpdesk.css create mode 100644 website/static/icons/arrow-right.svg diff --git a/routes/auth.js b/routes/auth.js index 7178d09..ef7cdfb 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -5,7 +5,7 @@ import { randomStringBase62, getHumanReadableUserAgent } from "../liberals/misc. app.get("/api/auth/whoami", (rreq,rres) => { if (!rreq.cookies["APIToken"] && !rreq.get("Authorization")) { - rres.status(400).send({ "loggedIn": false, "username": "", "scopes": "" }) + rres.send({ "loggedIn": false, "username": "", "scopes": "" }) } else { db`select s.scopes, u.username from sessions s join users u on s.owner = u.id where s.token = ${rreq.cookies["APIToken"] ?? rreq.get("Authorization")}`.then(dbRes => { if (dbRes.length > 0 && dbRes.length < 2) { diff --git a/routes/helpdesk.js b/routes/helpdesk.js new file mode 100644 index 0000000..1c37c4d --- /dev/null +++ b/routes/helpdesk.js @@ -0,0 +1,11 @@ +import { app } from "../index.js" + +app.get("/helpdesk", (rreq, rres) => { + rres.sendFile(process.cwd()+"/website/helpdesk/templates/landing.html") +}) + +app.get("/helpdesk/articles/*", (rreq, rres) => { + rres.sendFile(process.cwd()+"/website/helpdesk/kbas/"+rreq.url.replace("/helpdesk/articles/","")) +}) + +export { app } \ No newline at end of file diff --git a/website/helpdesk/forms/ecls_deleteaccount.json b/website/helpdesk/forms/ecls_deleteaccount.json new file mode 100644 index 0000000..255b9fc --- /dev/null +++ b/website/helpdesk/forms/ecls_deleteaccount.json @@ -0,0 +1,14 @@ +{ + "title": "ECLS: Delete Account", + "form": { + "description": { + "type": "span", + "content": "" + }, + "confirmation": { + "type": "checkbox", + "content": "I have read all of the above and understand this action is irreverisble", + "required": true + } + } +} \ No newline at end of file diff --git a/website/helpdesk/kbas/example.html b/website/helpdesk/kbas/example.html new file mode 100644 index 0000000..cb8bc22 --- /dev/null +++ b/website/helpdesk/kbas/example.html @@ -0,0 +1,11 @@ + + + + + + Document + + +

example article

+ + \ No newline at end of file diff --git a/website/helpdesk/templates/landing.html b/website/helpdesk/templates/landing.html new file mode 100644 index 0000000..266e752 --- /dev/null +++ b/website/helpdesk/templates/landing.html @@ -0,0 +1,96 @@ + + + + + + Enstrayed Helpdesk + + + + + +
+

Enstrayed Helpdesk

+ Open Ticket + Knowledgebase + +
+ +
+
+
+
+
+

How can I help you?

+
+
+ I'm filing a legal request, such as a DMCA takedown or other legal matter
+ Click here to start a Legal Request +
+
+ I'm responsibly disclosing a problem, such a security vulnerability
+ Click here to start a Responsible Disclosure +
+ +
+ I have another question or need to get in contact
+ Click here to start a General Inquiry +
+
+
+
+

Popular Articles

+
+
+ How do I use Security Keys in ECLS?
+ Important ECLS FIDO2/Webauthn/Passkey Information +
+
+ How do I login to Jellyfin?
+ Logging into Jellyfin +
+
+ How do I change my ECLS password?
+ Change ECLS Password +
+
+ How do I use federation features in Enstrayed Cloud?
+ Enstrayed Cloud Federation Features +
+
+
+
+
+ +

Warning

+

This is a warning

+ +
+ + \ No newline at end of file diff --git a/website/static/helpdesk.css b/website/static/helpdesk.css new file mode 100644 index 0000000..19426fd --- /dev/null +++ b/website/static/helpdesk.css @@ -0,0 +1,78 @@ +html { + font-family: 'Segoe UI Variable', sans-serif; + + background-color: #282828; +} + +body { + margin: 0; + display: grid; + grid-template-rows: auto 1fr; + grid-template-columns: 1fr; + height: 100vh; +} + +.headerbar { + background-color: #fff; + padding: 0 2em 0 2em; + display: flex; + flex-direction: row; + gap: 2em; + align-items: center; +} + + +.headerbarright { + margin-left: auto; +} + +.maincontent { + display: grid; + place-items: center; +} + +.cardrow { + display: flex; + flex-direction: row; + gap: 2em; +} + +.cardrow > div { + background-color: #fff; + padding: 2em; +} + +.cardrow > div > h1 { + margin: 0 0 0.5em 0; +} + +.linkColumn { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +a, a:link { + color: #366FFF; +} + +a > img { + margin-right: 0.2em; +} + +dialog { + padding: 2em; + border: none; +} + +dialog > h2 { + margin: 0 0 0.5em 0; +} + +dialog > button { + float: right; +} + +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} \ No newline at end of file diff --git a/website/static/icons/arrow-right.svg b/website/static/icons/arrow-right.svg new file mode 100644 index 0000000..1094594 --- /dev/null +++ b/website/static/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 0ef0b82765ae1b9b2d552e0c2f691cba342919c6 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Mon, 26 May 2025 01:26:35 -0700 Subject: [PATCH 19/21] this codebase is kindof a mess now --- routes/auth.js | 6 +-- routes/helpdesk.js | 8 ++++ website/helpdesk/forms/manifest.json | 10 ++++ website/helpdesk/templates/landing.html | 32 ++----------- website/helpdesk/templates/newticket.html | 56 ++++++++++++++++++++++ website/static/{ => helpdesk}/helpdesk.css | 9 +++- website/static/helpdesk/login.js | 54 +++++++++++++++++++++ 7 files changed, 143 insertions(+), 32 deletions(-) create mode 100644 website/helpdesk/forms/manifest.json create mode 100644 website/helpdesk/templates/newticket.html rename website/static/{ => helpdesk}/helpdesk.css (86%) create mode 100644 website/static/helpdesk/login.js diff --git a/routes/auth.js b/routes/auth.js index ef7cdfb..98ae8c1 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -43,14 +43,14 @@ app.get("/api/auth/logout", (rreq,rres) => { if (dbRes.count > 0) { rres.send("Success") } else { - rres.status(400).send("Error: Token does not exist.") + rres.status(400).send("Token does not exist.") } }).catch(dbErr => { logRequest(rres,rreq,500,dbErr) - rres.status(500).send("Error: Exception occured while invalidating token, details: "+dbErr) + rres.status(500).send("Exception occured while invalidating token, details: "+dbErr) }) } else { - rres.status(400).send("Error: Missing token or authorization header, you may not be logged in.") + rres.status(400).send("Missing token or authorization header, you may not be logged in.") } }) diff --git a/routes/helpdesk.js b/routes/helpdesk.js index 1c37c4d..d456bb5 100644 --- a/routes/helpdesk.js +++ b/routes/helpdesk.js @@ -8,4 +8,12 @@ app.get("/helpdesk/articles/*", (rreq, rres) => { rres.sendFile(process.cwd()+"/website/helpdesk/kbas/"+rreq.url.replace("/helpdesk/articles/","")) }) +app.get("/helpdesk/ticket/new", (rreq,rres) => { + rres.sendFile(process.cwd()+"/website/helpdesk/templates/newticket.html") +}) + +app.get("/api/helpdesk/forms/*", (rreq, rres) => { + rres.sendFile(process.cwd()+"/website/helpdesk/forms/"+rreq.url.replace("/api/helpdesk/forms/","")) +}) + export { app } \ No newline at end of file diff --git a/website/helpdesk/forms/manifest.json b/website/helpdesk/forms/manifest.json new file mode 100644 index 0000000..9807937 --- /dev/null +++ b/website/helpdesk/forms/manifest.json @@ -0,0 +1,10 @@ +{ + "general_inquiry": "General Inquiry", + "legalrequest": "Legal Request", + "responsibledisclosure": "Responsible Disclosure", + "general_techsupport": "General Technical Support", + "ecls_deleteaccount": "ECLS: Delete Account", + "ecls_passwordreset": "ECLS: Password Reset", + "ecls_personalinfochange": "ECLS: Personal Information Change", + "enstrayedcloud_quotachange": "Enstrayed Cloud: Quota Change" +} \ No newline at end of file diff --git a/website/helpdesk/templates/landing.html b/website/helpdesk/templates/landing.html index 266e752..16ba4b7 100644 --- a/website/helpdesk/templates/landing.html +++ b/website/helpdesk/templates/landing.html @@ -4,30 +4,8 @@ Enstrayed Helpdesk - - + + @@ -88,9 +66,9 @@
-

Warning

-

This is a warning

- +

Warning

+

This is a warning

+
\ No newline at end of file diff --git a/website/helpdesk/templates/newticket.html b/website/helpdesk/templates/newticket.html new file mode 100644 index 0000000..d47a2e4 --- /dev/null +++ b/website/helpdesk/templates/newticket.html @@ -0,0 +1,56 @@ + + + + + + Enstrayed Helpdesk: New Ticket + + + + + + +
+

Enstrayed Helpdesk

+ Main Page + Knowledgebase + +
+ +
+
+
+
+

New Ticket

+ + +
+
+
+ +

Warning

+

This is a warning

+ +
+ + \ No newline at end of file diff --git a/website/static/helpdesk.css b/website/static/helpdesk/helpdesk.css similarity index 86% rename from website/static/helpdesk.css rename to website/static/helpdesk/helpdesk.css index 19426fd..084a94a 100644 --- a/website/static/helpdesk.css +++ b/website/static/helpdesk/helpdesk.css @@ -37,11 +37,16 @@ body { gap: 2em; } -.cardrow > div { +.cardrow > div, .newticketmaincontent { background-color: #fff; padding: 2em; } +.newticketmaincontent { + min-width: 80ch; + max-width: 80ch; +} + .cardrow > div > h1 { margin: 0 0 0.5em 0; } @@ -65,7 +70,7 @@ dialog { border: none; } -dialog > h2 { +dialog > h2, .newticketmaincontent > h2 { margin: 0 0 0.5em 0; } diff --git a/website/static/helpdesk/login.js b/website/static/helpdesk/login.js new file mode 100644 index 0000000..71d43a2 --- /dev/null +++ b/website/static/helpdesk/login.js @@ -0,0 +1,54 @@ +var globalLoggedIn = false + +function useGlobalDialog(title,body) { + document.getElementById("globalDialogHeader").innerText = title + document.getElementById("globalDialogText").innerText = body + document.getElementById("globalDialog").showModal() +} + +document.addEventListener("DOMContentLoaded", function () { + fetch(`/api/auth/whoami`).then(fetchRes => { + fetchRes.json().then(jsonRes => { + if (jsonRes.loggedIn) { + globalLoggedIn = true + document.getElementById("loginButton").innerText = `Logout ${jsonRes.username}` + } else { + globalLoggedIn = false + document.getElementById("loginButton").innerText = `Login` + } + }) + }) +}) + +function loginFunction() { + if (globalLoggedIn === true) { + fetch(`/api/auth/logout`).then(fetchRes => { + if (fetchRes.status === 200) { + globalLoggedIn = false + document.getElementById("loginButton").innerText = `Login` + } else { + fetchRes.text().then(textRes => { + useGlobalDialog("Error", `An error occurred during logout: ${textRes}`) + }) + } + }) + } else { + let loginWindow = window.open(`/api/auth/login?state=close`, `_blank`) + let loginWatcher = setInterval(() => { + if (loginWindow.closed) { + fetch(`/api/auth/whoami`).then(fetchRes => { + fetchRes.json().then(jsonRes => { + if (jsonRes.loggedIn) { + globalLoggedIn = true + document.getElementById("loginButton").innerText = `Logout ${jsonRes.username}` + } else { + useGlobalDialog("Error", `You are not logged in. Please try logging in again.`) + } + clearInterval(loginWatcher); + }) + }) + + } + }, 500); + } +} \ No newline at end of file From b4ecb63e432417fe5b656af46fa7cb92a6d9dadc Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Mon, 26 May 2025 17:15:14 -0700 Subject: [PATCH 20/21] add more forms --- .../helpdesk/forms/ecls_passwordreset.json | 12 +++++++++++ .../forms/ecls_personalinfochange.json | 21 +++++++++++++++++++ .../forms/enstrayedcloud_quotachange.json | 14 +++++++++++++ website/helpdesk/forms/general_inquiry.json | 16 ++++++++++++++ website/helpdesk/forms/legalrequest.json | 5 +++++ 5 files changed, 68 insertions(+) create mode 100644 website/helpdesk/forms/ecls_passwordreset.json create mode 100644 website/helpdesk/forms/ecls_personalinfochange.json create mode 100644 website/helpdesk/forms/enstrayedcloud_quotachange.json create mode 100644 website/helpdesk/forms/general_inquiry.json create mode 100644 website/helpdesk/forms/legalrequest.json diff --git a/website/helpdesk/forms/ecls_passwordreset.json b/website/helpdesk/forms/ecls_passwordreset.json new file mode 100644 index 0000000..b894c8d --- /dev/null +++ b/website/helpdesk/forms/ecls_passwordreset.json @@ -0,0 +1,12 @@ +{ + "form": { + "description": { + "type": "span", + "content": "An Email will be sent to the address on your ECLS account with a link to reset your password.

If you cannot access that Email address, or you are locked out of your ECLS account for another reason (e.g. Lost 2FA method), please change the ticket type to 'ECLS: Account Recovery' instead. " + }, + "username": { + "type": "text", + "content": "What is your ECLS username?" + } + } +} \ No newline at end of file diff --git a/website/helpdesk/forms/ecls_personalinfochange.json b/website/helpdesk/forms/ecls_personalinfochange.json new file mode 100644 index 0000000..8cffd59 --- /dev/null +++ b/website/helpdesk/forms/ecls_personalinfochange.json @@ -0,0 +1,21 @@ +{ + "anonymousSubmission": false, + "form": { + "description": { + "type": "span", + "content": "Please enter the information you wish to change. You do not need to enter existing information if it will remain the same.

Important! If you request a username or Email address change, your access to services such as EOES or Jellyfin may be disrupted. You will be contacted in advance if this applies to you." + }, + "newusername": { + "type": "text", + "content": "New Username" + }, + "newdisplayname": { + "type": "text", + "content": "New Display Name" + }, + "newemail": { + "type": "text", + "content": "New Email Address" + } + } +} \ No newline at end of file diff --git a/website/helpdesk/forms/enstrayedcloud_quotachange.json b/website/helpdesk/forms/enstrayedcloud_quotachange.json new file mode 100644 index 0000000..7e03fc1 --- /dev/null +++ b/website/helpdesk/forms/enstrayedcloud_quotachange.json @@ -0,0 +1,14 @@ +{ + "anonymousSubmission": false, + "form": { + "description": { + "type": "span", + "content": "Important:
  • Your Enstrayed Cloud quota does NOT affect your EOES mailbox quota.
  • Your request will be reviewed and may be denied for any reason.
" + }, + "newquota": { + "type": "text", + "content": "New quota (in GB)", + "required": true + } + } +} \ No newline at end of file diff --git a/website/helpdesk/forms/general_inquiry.json b/website/helpdesk/forms/general_inquiry.json new file mode 100644 index 0000000..ae5d662 --- /dev/null +++ b/website/helpdesk/forms/general_inquiry.json @@ -0,0 +1,16 @@ +{ + "form": { + "name": { + "type": "text", + "content": "What is your name?" + }, + "email": { + "type": "text", + "content": "What is a good email address to reach you at?" + }, + "message": { + "type": "bigtext", + "content": "Please enter your message below." + } + } +} \ No newline at end of file diff --git a/website/helpdesk/forms/legalrequest.json b/website/helpdesk/forms/legalrequest.json new file mode 100644 index 0000000..cd405c0 --- /dev/null +++ b/website/helpdesk/forms/legalrequest.json @@ -0,0 +1,5 @@ +{ + "form": { + + } +} \ No newline at end of file From cb289889d29203559145bd4f92d1138e5f3b0fe5 Mon Sep 17 00:00:00 2001 From: Enstrayed <48845980+Enstrayed@users.noreply.github.com> Date: Fri, 13 Jun 2025 22:59:07 -0700 Subject: [PATCH 21/21] just finish it already --- README.md | 22 ++-- routes/frontpage.js | 6 +- todo.md | 7 -- website/static/construction.svg | 152 ++++++++++++++++++++++++++++ website/templates/construction.html | 42 ++++++++ 5 files changed, 207 insertions(+), 22 deletions(-) delete mode 100644 todo.md create mode 100644 website/static/construction.svg create mode 100644 website/templates/construction.html diff --git a/README.md b/README.md index 903d3f7..8b94f5c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,11 @@ -# Enstrayed API -This repository contains the code for my personal web API written in JavaScript using the Express framework. - -## Documentation -This file contains documentation relevant for development and deployment, but not necessarily usage. Information for all endpoints is available [on my website](https://enstrayed.com/posts/20240409-API-Documentation.html). - -## Issues -If you would like to report a bug or security issue, please open a GitHub issue. If you are the operator of a service this application accesses, use the contact information provided during registration with your service to contact me directly. +## Note for Visitors +* This README mainly contains information for operation but not usage. API documentation is available [here](https://enstrayed.com/posts/20240409-API-Documentation.html). +* Have feedback or experiencing a problem with an endpoint? Please [open a GitHub issue](https://github.com/Enstrayed/enstrayedapi/issues/new). +* Security problem? [Open a ticket here](https://helpdesk.enstrayed.com/open.php) with the topic set as 'Responsible Disclosure'. +* This code is unlicensed but I don't really care if you use parts of it (I don't know why you would though). ## Configuration - +TODO: Rewrite
Configuration Template @@ -19,7 +16,7 @@ If you would like to report a bug or security issue, please open a GitHub issue.
## Docker -In production, this application is designed to be run in Docker, and the container built by pulling the latest commit from the main branch. As such, deploying this application is just a matter of creating a directory and copying the Dockerfile: +TODO: Rewrite & add Komodo TOML files ```dockerfile FROM node:22 @@ -38,7 +35,4 @@ ENTRYPOINT [ "node", "index.js" ] ``` -
- -## License -If for whatever reason you want to, you are free to adapt this code for your own projects or as reference. However, this software is provided as-is with no warranty or agreement to support it. \ No newline at end of file + \ No newline at end of file diff --git a/routes/frontpage.js b/routes/frontpage.js index 1682a47..4328ba5 100644 --- a/routes/frontpage.js +++ b/routes/frontpage.js @@ -7,7 +7,7 @@ import { marked } from "marked" var timeSinceLastQuery = Date.now()-10000 var cachedResult = "" -app.get("/", (rreq, rres) => { +app.get("/indexbeta", (rreq, rres) => { if (Date.now() < timeSinceLastQuery+10000) { rres.send(cachedResult) } else { @@ -17,6 +17,10 @@ app.get("/", (rreq, rres) => { } }) +app.get("/", (rreq, rres) => { + rres.sendFile(process.cwd()+"/website/templates/construction.html") +}) + app.get("/static/*", (rreq,rres) => { rres.sendFile(process.cwd()+"/website/static/"+rreq.url.replace("/static/","")) }) diff --git a/todo.md b/todo.md deleted file mode 100644 index 27281b2..0000000 --- a/todo.md +++ /dev/null @@ -1,7 +0,0 @@ -- [ ] GET /api/whoami - Returns owner of token and what scopes it has -- [ ] GET /api/login - OIDC login redirect to ECLS -- [ ] GET /api/callback - Creates new token that is intended to be local to browser; e.g. can be used in turn to make longer lasting more specific tokens -- [ ] POST /api/token - Allows owner to create a new token with customized scopes, comments & expiration date -- [ ] DELETE /api/token - Invalidate a token -- [ ] liberals/libnowplaying - Implement queryCider() -- [ ] routes/nowplaying - Reimplement query order to Cider and then Jellyfin diff --git a/website/static/construction.svg b/website/static/construction.svg new file mode 100644 index 0000000..476d46b --- /dev/null +++ b/website/static/construction.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/website/templates/construction.html b/website/templates/construction.html new file mode 100644 index 0000000..658f7c2 --- /dev/null +++ b/website/templates/construction.html @@ -0,0 +1,42 @@ + + + + + + Under Construction + + + +
+ Construction Icon +

Under Construction

+

+ This page is undergoing a revamp and will be available again soon.
+ All API endpoints are still available. Documentation is available here. +

+
+ + \ No newline at end of file