we ball
This commit was merged in pull request #8.
This commit is contained in:
Nathan
2025-06-13 22:59:48 -07:00
committed by GitHub
45 changed files with 1342 additions and 250 deletions

View File

@@ -1,65 +1,22 @@
# 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
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` |
TODO: Rewrite
<details> <summary>Configuration Example</summary>
* `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.
<details> <summary>Configuration Template</summary>
```json
{
"frontpage": {
"directory": ""
},
"mailjet": {
"apiKey": "",
"senderAddress": ""
},
"nowplaying": {
"lastfm": {
"apiKey": "",
"target": ""
},
"jellyfin": {
"apiKey": "",
"host": "",
"target": ""
},
"cider": {
"apiKeys": [],
"hosts": []
}
}
}
```
</details>
## 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`.
TODO: Rewrite & add Komodo TOML files
```dockerfile
FROM node:22
@@ -75,19 +32,7 @@ ENTRYPOINT [ "node", "index.js" ]
<details> <summary>Docker Compose File</summary>
```yaml
---
services:
enstrayedapi:
build:
context: .
image: enstrayedapi
container_name: enstrayedapi
restart: unless-stopped
volumes:
- ./config.json:/app/config.json
```
</details>
## 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.

View File

@@ -1,31 +1,32 @@
import * as fs from 'fs'
import { execSync } from 'child_process'
import postgres from 'postgres'
import express, { json } from 'express'
import cookieParser from 'cookie-parser'
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}`)
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)
} else {
return response.json()
}
})
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
app.use(cookieParser()) // Allows receiving cookies
process.on('SIGTERM', function() {
console.log("Received SIGTERM, exiting...")

View File

@@ -1,32 +1,54 @@
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 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 (dbRes.sessions[token].scopes.includes(scope)) { // If the token is on the seesions list and includes the scope then accept
} else if (response[0]?.scopes.split(",").includes(scope)) {
return true
} else { // Otherwise reject
} else {
return false
}
})
}).catch(error => {
console.log(`ERROR: auth.js: Fetch failed: ${error}`)
return false
})
}
export {checkToken}
/**
* 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
* @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(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}

View File

@@ -27,7 +27,7 @@ async function queryLastfm() {
"artUrl": response.recenttracks.track[0].image[3]["#text"],
"link": response.recenttracks.track[0].url
},
"html": `<img src="${response.recenttracks.track[0].image[3]["#text"]}" alt="Album Art" style="width: 10em;"> <div class="textlist"> <p>I'm listening to</p> <h3>${response.recenttracks.track[0].name} by ${response.recenttracks.track[0].artist["#text"]}</h3> <p>from ${response.recenttracks.track[0].album["#text"]}</p> <a href="${response.recenttracks.track[0].url}" class="noindent">View on Last.fm</a></div>`
"html": `<img src="${response.recenttracks.track[0].image[3]["#text"]}" alt="Album Art"> <div> <span>I'm listening to</span> <span>${response.recenttracks.track[0].name} by ${response.recenttracks.track[0].artist["#text"]}</span> <span>from ${response.recenttracks.track[0].album["#text"]}</span> <a href="${response.recenttracks.track[0].url}" class="noindent">View on Last.fm</a></div>`
}
}
}
@@ -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": `<img src="${globalConfig.nowplaying.jellyfin.hostPublic}/Items/${response[x].NowPlayingItem.Id}/Images/Primary" alt="Album Art" style="width: 10em;"> <div> <h4>I'm listening to</h4> <h3>${response[x].NowPlayingItem.Name} by ${response[x].NowPlayingItem.Artists[0]}</h3> <h4>from ${response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`}</h4> <a href="https://www.last.fm/music/${response[x].NowPlayingItem.Artists[0].replaceAll(" ","+")}/_/${response[x].NowPlayingItem.Name.replaceAll(" ","+")}">View on Last.fm</a></div>`
"html": `<img src="${globalConfig.nowplaying.jellyfin.hostPublic}/Items/${response[x].NowPlayingItem.Id}/Images/Primary" alt="Album Art"> <div> <span class="nowPlayingLine1">I'm listening to</span> <span class="nowPlayingLine2">${response[x].NowPlayingItem.Name} by ${response[x].NowPlayingItem.Artists[0]}</span> <span class="nowPlayingLine3">from ${response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`}</span> <a class="nowPlayingLine4" href="https://www.last.fm/music/${response[x].NowPlayingItem.Artists[0].replaceAll(" ","+")}/_/${response[x].NowPlayingItem.Name.replaceAll(" ","+")}">View on Last.fm</a></div>`
}
}

View File

@@ -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 }

55
liberals/misc.js Normal file
View File

@@ -0,0 +1,55 @@
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
}
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 }

45
package-lock.json generated
View File

@@ -9,9 +9,11 @@
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"cookie-parser": "^1.4.7",
"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",
@@ -157,6 +159,28 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -470,9 +494,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"
@@ -600,6 +624,19 @@
"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",

View File

@@ -1,8 +1,10 @@
{
"dependencies": {
"cookie-parser": "^1.4.7",
"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",

128
routes/auth.js Normal file
View File

@@ -0,0 +1,128 @@
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) => {
if (!rreq.cookies["APIToken"] && !rreq.get("Authorization")) {
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) {
rres.send({ "loggedIn": true, "username": dbRes[0]?.username, "scopes": dbRes[0]?.scopes.split(",") })
} else {
rres.send({ "loggedIn": false, "username": "", "scopes": "" })
}
}).catch(dbErr => {
logRequest(rres,rreq,500,dbErr)
rres.status(500).send({ "loggedIn": false, "username": "", "scopes": "" })
})
}
})
app.get("/api/auth/login", (rreq,rres) => {
if (rreq.query.state === "redirect") {
if (!rreq.query.destination) {
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&state=none`)
} else {
let newState = `redirect_${btoa(rreq.query.destination).replace("/","-")}`
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&state=${newState}`)
}
} else if (rreq.query.state === "display" || rreq.query.state === "close") {
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&state=${rreq.query.state}`)
} else {
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&state=none`)
}
})
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("Token does not exist.")
}
}).catch(dbErr => {
logRequest(rres,rreq,500,dbErr)
rres.status(500).send("Exception occured while invalidating token, details: "+dbErr)
})
} else {
rres.status(400).send("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",
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
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)
localError500(`Callback-Userinfo-${fetchRes2.status}`)
} else {
fetchRes2.json().then(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`insert into sessions (token,owner,scopes,expires,comment) values (${newToken},(select id from users where oidc_username = ${fetchRes2.username}),${fetchRes2.enstrayedapi_scopes},${newExpiration},${newComment});`.then(dbRes1 => {
if (rreq.query.state.split("_")[0] === "redirect") {
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 <code>${newToken}</code>`)
} else if (rreq.query.state === "close") {
rres.setHeader("Set-Cookie", `APIToken=${newToken}; Domain=${rreq.hostname}; Expires=${new Date(newExpiration).toUTCString()}; Path=/`).send(`<script>document.addEventListener("DOMContentLoaded", () => {window.close();});</script> 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
localError500(`Callback-Fetch2-${fetchErr2}`)
})
}
})
}).catch(fetchErr1 => { // Fetch to token endpoint failed for some other reason
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.<br><br><code>500 ${code}</code>`)
}
})
app.post("/api/auth/token", (rreq,rres) => {
rres.send("Non functional endpoint")
})
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 }

View File

@@ -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,"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)
})

View File

@@ -1,130 +1,73 @@
import { app, globalConfig } from "../index.js" // Get globals from index
import { checkToken } from "../liberals/auth.js"
import { app, db, globalConfig } from "../index.js" // Get globals from index
import { checkToken, checkTokenNew } 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.redirect(response[0].content)
}
}).catch(dbError => {
logRequest(rres,rreq,500,dbError)
rres.status(500).send(`etyd.cc: An internal error occured`)
})
})
app.delete("/api/etyd*", (rreq,rres) => {
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) => {
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)
}
})
}
}).catch(fetchError => {
logRequest(rres,rreq,500,fetchError)
rres.sendStatus(500)
})
}
})
// 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)
// }
// }).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)
// })
// }
// })
// }
// })
// 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)
// rres.sendStatus(500)
// })
// }
// }
// })
// }
// })
})
export {app} // export routes to be imported by index for execution

View File

@@ -7,22 +7,26 @@ 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 {
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("<!--SSR_BLOGPOSTS-->",parseFiles()).replace("<!--SSR_APIVERSION-->",`<sup>API Version ${globalVersion}</sup>`)
rres.send(cachedResult)
}
})
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/",""))
})
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) => {
@@ -39,10 +43,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 = ""

19
routes/helpdesk.js Normal file
View File

@@ -0,0 +1,19 @@
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/",""))
})
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 }

View File

@@ -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
}
}
}

View File

@@ -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. <br><br> <b>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. </b>"
},
"username": {
"type": "text",
"content": "What is your ECLS username?"
}
}
}

View File

@@ -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. <br><br> <b>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.</b>"
},
"newusername": {
"type": "text",
"content": "New Username"
},
"newdisplayname": {
"type": "text",
"content": "New Display Name"
},
"newemail": {
"type": "text",
"content": "New Email Address"
}
}
}

View File

@@ -0,0 +1,14 @@
{
"anonymousSubmission": false,
"form": {
"description": {
"type": "span",
"content": "Important: <ul> <li>Your Enstrayed Cloud quota does NOT affect your EOES mailbox quota.</li> <li>Your request will be reviewed and may be denied for any reason.</li> </ul>"
},
"newquota": {
"type": "text",
"content": "New quota (in GB)",
"required": true
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -0,0 +1,5 @@
{
"form": {
}
}

View File

@@ -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"
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>example article</h1>
</body>
</html>

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enstrayed Helpdesk</title>
<link rel="stylesheet" href="/static/helpdesk/helpdesk.css">
<script src="/static/helpdesk/login.js"></script>
</head>
<body>
<div class="headerbar">
<h1>Enstrayed Helpdesk</h1>
<a href="/helpdesk/ticket/new">Open Ticket</a>
<a href="/helpdesk/articles">Knowledgebase</a>
<div class="headerbarright">
<button id="loginButton" onclick="loginFunction()">Login</button>
</div>
</div>
<div class="maincontent">
<div class="cardrow">
<div>
<h1>How can I help you?</h1>
<div class="linkColumn">
<div>
<span>I'm filing a legal request, such as a DMCA takedown or other legal matter</span><br>
<a href=""><img src="/static/icons/arrow-right.svg" alt="">Click here to start a Legal Request</a>
</div>
<div>
<span>I'm responsibly disclosing a problem, such a security vulnerability</span><br>
<a href=""><img src="/static/icons/arrow-right.svg" alt="">Click here to start a Responsible Disclosure</a>
</div>
<div>
<span>I need help logging into my ECLS account</span><br>
<a href=""><img src="/static/icons/external.svg" alt="">Click here to reset your ECLS password</a><br>
<a href=""><img src="/static/icons/arrow-right.svg" alt="">Click here to start ECLS account recovery</a>
</div>
<div>
<span>I have another question or need to get in contact</span><br>
<a href=""><img src="/static/icons/arrow-right.svg" alt="">Click here to start a General Inquiry</a>
</div>
</div>
</div>
<div>
<h1>Popular Articles</h1>
<div class="linkColumn">
<div>
<span>How do I use Security Keys in ECLS?</span><br>
<a href=""><img src="/static/icons/post.svg" alt="">Important ECLS FIDO2/Webauthn/Passkey Information</a>
</div>
<div>
<span>How do I login to Jellyfin?</span><br>
<a href=""><img src="/static/icons/post.svg" alt="">Logging into Jellyfin</a>
</div>
<div>
<span>How do I change my ECLS password?</span><br>
<a href=""><img src="/static/icons/post.svg" alt="">Change ECLS Password</a>
</div>
<div>
<span>How do I use federation features in Enstrayed Cloud?</span><br>
<a href=""><img src="/static/icons/post.svg" alt="">Enstrayed Cloud Federation Features</a>
</div>
</div>
</div>
</div>
</div>
<dialog id="globalDialog">
<h2 id="globalDialogHeader">Warning</h2>
<p id="globalDialogText">This is a warning</p>
<button onclick="document.getElementById('globalDialog').close()">Dismiss</button>
</dialog>
</body>
</html>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enstrayed Helpdesk: New Ticket</title>
<link rel="stylesheet" href="/static/helpdesk/helpdesk.css">
<script src="/static/helpdesk/login.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('formSelection').value = 'none'
document.getElementById('formSelection').addEventListener('change', function() {
useGlobalDialog(`Info`, `You selected ${this.value}`)
})
fetch(`/api/helpdesk/forms/manifest.json`).then(fetchRes => {
fetchRes.json().then(jsonRes => {
for (let x in jsonRes) {
let newElement = document.createElement('option')
newElement.value = x
newElement.textContent = jsonRes[x]
document.getElementById('formSelection').appendChild(newElement)
}
})
})
})
</script>
</head>
<body>
<div class="headerbar">
<h1>Enstrayed Helpdesk</h1>
<a href="/helpdesk">Main Page</a>
<a href="/helpdesk/articles">Knowledgebase</a>
<div class="headerbarright">
<button id="loginButton" onclick="loginFunction()">Login</button>
</div>
</div>
<div class="maincontent">
<div class="newticketmaincontent">
<h2>New Ticket</h2>
<label for="formSelection">Please select a form: </label>
<select name="Form Selection" id="formSelection">
<option value="none" disabled selected>-- Choose From List --</option>
</select>
<hr>
</div>
</div>
<dialog id="globalDialog">
<h2 id="globalDialogHeader">Warning</h2>
<p id="globalDialogText">This is a warning</p>
<button onclick="document.getElementById('globalDialog').close()">Dismiss</button>
</dialog>
</body>
</html>

View File

@@ -2,7 +2,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/posts.css">
<link rel="stylesheet" href="/static/newposts.css">
<meta property="og:site_name" content="enstrayed.com">
<meta property="og:url" content="https://enstrayed.com/posts/20240409-API-Documentation.html">
@@ -16,28 +16,48 @@
<body>
<a href="/">Return to enstrayed.com</a>
<h1>API Documentation</h1>
<p>This page was last updated 2024-08-20.</p>
<p>This page was last updated 2025-04-26.</p>
<p>Source Code & Issue Tracker: <a href="https://github.com/enstrayed/enstrayedapi">github.com/enstrayed/enstrayedapi</a></p>
<hr>
<div class="inlineheader">
<h2>Important Note</h2>
</div>
<p>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 <a href="https://github.com/Enstrayed/enstrayedapi/tree/new-db">new-db</a> branch.</p>
<div class="inlineheader">
<h2>/api/nowplaying</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/nowplaying.js">nowplaying.js</a>
<div>
<span>GET</span>
</div>
</div>
<p>Returns whatever I'm listening to via the Last.fm API in JSON. If <code>?format=html</code> is used in the URL it will return the same but in HTML. If nothing is playing the JSON response will just have <code>"playing": false</code>.</p>
<div class="inlineheader">
<h2>/api/nowplayingbeta</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/nowplaying.js">nowplaying.js</a>
<div>
<span>GET</span>
</div>
</div>
<p>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 <code>?format=html</code> is appended to the URL it will return the same but in HTML. Each line in the HTML response has a class <code>nowPlayingLine[1-4]</code> that can be used to style the text using CSS. See <a href="https://github.com/Enstrayed/enstrayedapi/blob/main/liberals/libnowplaying.js#L63">libnowplaying.js:63</a> for the format of the result.</p>
<div class="inlineheader">
<h2>/api/etyd/*</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/etyd.js">etyd.js</a>
<div>
<span>GET</span>
<span>POST</span>
<span>DELETE</span>
</div>
</div>
<p>Retrieves, creates or deletes entries for the etyd.cc URL shortener. Replace * in the URL for the target of the request.</p>
@@ -53,8 +73,10 @@
<div class="inlineheader">
<h2>/api/sendemail</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/mailjet.js#L3">mailjet.js</a>
<div>
<span>POST</span>
</div>
</div>
<p>Sends an email to the specified recipient, intended for application & automation use.</p>
@@ -72,7 +94,7 @@
<div class="inlineheader">
<h2>/api/ip</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/ip.js">ip.js</a>
<span>GET</span>
<div><span>GET</span></div>
</div>
<p>Returns the IP, country and Cloudflare ray of the request in JSON.</p>
@@ -80,9 +102,44 @@
<div class="inlineheader">
<h2>/api/headers</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/ip.js">ip.js</a>
<span>GET</span>
<div><span>GET</span></div>
</div>
<p>Returns all request headers in JSON.</p>
<div class="inlineheader">
<h2>/api/auth/whoami</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/new-db/routes/auth.js">auth.js:6</a>
<div><span>GET</span></div>
</div>
<p>Returns JSON with the username of the token owner as well as what scopes the token has access to.</p>
<div class="inlineheader">
<h2 id="jumplink_authlogin">/api/auth/login</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/new-db/routes/auth.js">auth.js:23</a>
<div><span>GET</span></div>
</div>
<p>Redirects the user to ECLS to login. The <code>state</code> parameter can be used to specify how the login flow will behave. The accepted "states" are:</p>
<ul>
<li><code>redirect</code> - Redirects the user to a page after logging in. This paramter requires the <code>destination</code> paramter to also be set with the URL the user will be redirected to.</li>
<li><code>display</code> - Displays the generated token to the user after login. Currently, this still writes the new token to the <code>APIToken</code> cookie, though this is planned to change.</li>
<li><code>close</code> - This will close the page after logging in. This requires the page to be opened with JavaScript otherwise it will not automatically close.</li>
</ul>
<div class="inlineheader">
<h2>/api/auth/logout</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/new-db/routes/auth.js">auth.js:40</a>
<div><span>GET</span></div>
</div>
<p>Invalidates the token used to access the endpoint.</p>
<div class="inlineheader">
<h2>/api/auth/callback</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/new-db/routes/auth.js">auth.js:57</a>
<div><span>GET</span></div>
</div>
<p><b>Internal Use Only. </b>This is the endpoint used by ECLS to finish the login flow. It will write the newly created token to the <code>APIToken</code> cookie as well as performing the action set by <code>state</code>, see <a href="#jumplink_authlogin">login endpoint</a>.</p>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,152 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_ii_18_12057)">
<path d="M3 9C3 8.44772 3.44772 8 4 8H28C28.5523 8 29 8.44772 29 9V20C29 20.5523 28.5523 21 28 21H4C3.44772 21 3 20.5523 3 20V9Z" fill="url(#paint0_linear_18_12057)"/>
</g>
<path d="M3 9C3 8.44772 3.44772 8 4 8H28C28.5523 8 29 8.44772 29 9V20C29 20.5523 28.5523 21 28 21H4C3.44772 21 3 20.5523 3 20V9Z" fill="url(#paint1_linear_18_12057)"/>
<path d="M3 9C3 8.44772 3.44772 8 4 8H28C28.5523 8 29 8.44772 29 9V20C29 20.5523 28.5523 21 28 21H4C3.44772 21 3 20.5523 3 20V9Z" fill="url(#paint2_radial_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4748 20.9999H19.525L29 11.5249V16.4746L24.4748 20.9999Z" fill="url(#paint3_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4748 20.9999H19.525L29 11.5249V16.4746L24.4748 20.9999Z" fill="url(#paint4_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4748 20.9999H19.525L29 11.5249V16.4746L24.4748 20.9999Z" fill="url(#paint5_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4748 21H9.52502L22.525 8H27.4748L14.4748 21Z" fill="url(#paint6_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4748 21H9.52502L22.525 8H27.4748L14.4748 21Z" fill="url(#paint7_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.4748 21H9.52502L22.525 8H27.4748L14.4748 21Z" fill="url(#paint8_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.47476 21H4C3.44772 21 3 20.5523 3 20V17.525L12.525 8H17.4748L4.47476 21Z" fill="url(#paint9_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.47476 21H4C3.44772 21 3 20.5523 3 20V17.525L12.525 8H17.4748L4.47476 21Z" fill="url(#paint10_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.47476 21H4C3.44772 21 3 20.5523 3 20V17.525L12.525 8H17.4748L4.47476 21Z" fill="url(#paint11_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.47476 21H4C3.44772 21 3 20.5523 3 20V17.525L12.525 8H17.4748L4.47476 21Z" fill="url(#paint12_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.47476 8H4C3.44772 8 3 8.44772 3 9V12.4748L7.47476 8Z" fill="url(#paint13_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.47476 8H4C3.44772 8 3 8.44772 3 9V12.4748L7.47476 8Z" fill="url(#paint14_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.47476 8H4C3.44772 8 3 8.44772 3 9V12.4748L7.47476 8Z" fill="url(#paint15_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 21H24V30H26V21Z" fill="url(#paint16_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26 21H24V30H26V21Z" fill="url(#paint17_radial_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 21H6V30H8V21Z" fill="url(#paint18_linear_18_12057)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 21H6V30H8V21Z" fill="url(#paint19_radial_18_12057)"/>
<path d="M6 7.5C6 6.67157 6.67157 6 7.5 6C8.32843 6 9 6.67157 9 7.5V8H6V7.5Z" fill="url(#paint20_linear_18_12057)"/>
<path d="M23 7.5C23 6.67157 23.6716 6 24.5 6C25.3284 6 26 6.67157 26 7.5V8H23V7.5Z" fill="url(#paint21_linear_18_12057)"/>
<g filter="url(#filter1_f_18_12057)">
<rect x="7.10938" y="21.5" width="0.47476" height="8" rx="0.23738" fill="#DFD0E9"/>
</g>
<g filter="url(#filter2_f_18_12057)">
<rect x="25" y="21.5" width="0.47476" height="8" rx="0.23738" fill="#DFD0E9"/>
</g>
<defs>
<filter id="filter0_ii_18_12057" x="2.5" y="7.5" width="27" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.5" dy="-0.5"/>
<feGaussianBlur stdDeviation="0.375"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.831373 0 0 0 0 0.494118 0 0 0 0 0.282353 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_18_12057"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.5" dy="0.5"/>
<feGaussianBlur stdDeviation="0.375"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 0.415686 0 0 0 1 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_18_12057" result="effect2_innerShadow_18_12057"/>
</filter>
<filter id="filter1_f_18_12057" x="6.45938" y="20.85" width="1.77473" height="9.3" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="0.325" result="effect1_foregroundBlur_18_12057"/>
</filter>
<filter id="filter2_f_18_12057" x="24.35" y="20.85" width="1.77473" height="9.3" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="0.325" result="effect1_foregroundBlur_18_12057"/>
</filter>
<linearGradient id="paint0_linear_18_12057" x1="25.8457" y1="9.98514" x2="8.5" y2="18.625" gradientUnits="userSpaceOnUse">
<stop stop-color="#F7D142"/>
<stop offset="1" stop-color="#FCB545"/>
</linearGradient>
<linearGradient id="paint1_linear_18_12057" x1="3" y1="14.0109" x2="3.79" y2="14.0109" gradientUnits="userSpaceOnUse">
<stop stop-color="#A57C4B"/>
<stop offset="1" stop-color="#A57C4B" stop-opacity="0"/>
</linearGradient>
<radialGradient id="paint2_radial_18_12057" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(4.68831 8) scale(12.0629 1.90436)">
<stop offset="0.135638" stop-color="#FBBC43"/>
<stop offset="1" stop-color="#FBBC43" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint3_linear_18_12057" x1="27.6071" y1="11.5249" x2="18.4096" y2="17.9823" gradientUnits="userSpaceOnUse">
<stop stop-color="#353138"/>
<stop offset="1" stop-color="#503B62"/>
</linearGradient>
<linearGradient id="paint4_linear_18_12057" x1="22.0781" y1="20.9999" x2="22.0781" y2="20.3116" gradientUnits="userSpaceOnUse">
<stop stop-color="#4B2867"/>
<stop offset="1" stop-color="#4B2867" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint5_linear_18_12057" x1="29" y1="14.4905" x2="25.7434" y2="14.4905" gradientUnits="userSpaceOnUse">
<stop stop-color="#353139"/>
<stop offset="0.166461" stop-color="#413D46"/>
<stop offset="0.455618" stop-color="#413D46" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint6_linear_18_12057" x1="24.836" y1="8" x2="11.4256" y2="21" gradientUnits="userSpaceOnUse">
<stop stop-color="#353138"/>
<stop offset="1" stop-color="#503B62"/>
</linearGradient>
<linearGradient id="paint7_linear_18_12057" x1="14.3616" y1="21" x2="14.3616" y2="20.0557" gradientUnits="userSpaceOnUse">
<stop stop-color="#4B2867"/>
<stop offset="1" stop-color="#4B2867" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint8_linear_18_12057" x1="23.3427" y1="8" x2="23.3427" y2="12.0689" gradientUnits="userSpaceOnUse">
<stop stop-color="#353139"/>
<stop offset="0.166461" stop-color="#413D46"/>
<stop offset="0.422206" stop-color="#413D46" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint9_linear_18_12057" x1="15.3468" y1="8" x2="2.32672" y2="18.1782" gradientUnits="userSpaceOnUse">
<stop stop-color="#353138"/>
<stop offset="1" stop-color="#503B62"/>
</linearGradient>
<linearGradient id="paint10_linear_18_12057" x1="6.90025" y1="21" x2="6.90025" y2="20.0557" gradientUnits="userSpaceOnUse">
<stop stop-color="#4B2867"/>
<stop offset="1" stop-color="#4B2867" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint11_linear_18_12057" x1="14.1427" y1="8" x2="14.1427" y2="12.0689" gradientUnits="userSpaceOnUse">
<stop stop-color="#353139"/>
<stop offset="0.166461" stop-color="#413D46"/>
<stop offset="0.422206" stop-color="#413D46" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint12_linear_18_12057" x1="3" y1="15.1145" x2="3.75" y2="15.1145" gradientUnits="userSpaceOnUse">
<stop stop-color="#2F2C32"/>
<stop offset="1" stop-color="#2F2C32" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint13_linear_18_12057" x1="6.81693" y1="8" x2="2.47324" y2="11.0496" gradientUnits="userSpaceOnUse">
<stop stop-color="#353138"/>
<stop offset="1" stop-color="#503B62"/>
</linearGradient>
<linearGradient id="paint14_linear_18_12057" x1="6.44467" y1="8" x2="6.44467" y2="9.40056" gradientUnits="userSpaceOnUse">
<stop stop-color="#353139"/>
<stop offset="0.166461" stop-color="#413D46"/>
<stop offset="0.422206" stop-color="#413D46" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint15_linear_18_12057" x1="3" y1="10.4489" x2="4" y2="10.4489" gradientUnits="userSpaceOnUse">
<stop stop-color="#2F2C32"/>
<stop offset="1" stop-color="#2F2C32" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint16_linear_18_12057" x1="24" y1="23.9375" x2="25" y2="23.9375" gradientUnits="userSpaceOnUse">
<stop stop-color="#857C8C"/>
<stop offset="1" stop-color="#B9A7C4"/>
</linearGradient>
<radialGradient id="paint17_radial_18_12057" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(25 29.5937) rotate(-90) scale(8.79687 5.25369)">
<stop offset="0.912727" stop-color="#7E5E91" stop-opacity="0"/>
<stop offset="0.992895" stop-color="#7E5E91"/>
</radialGradient>
<linearGradient id="paint18_linear_18_12057" x1="6" y1="23.9375" x2="7" y2="23.9375" gradientUnits="userSpaceOnUse">
<stop stop-color="#857C8C"/>
<stop offset="1" stop-color="#B9A7C4"/>
</linearGradient>
<radialGradient id="paint19_radial_18_12057" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(7 29.5937) rotate(-90) scale(8.79687 5.25369)">
<stop offset="0.912727" stop-color="#7E5E91" stop-opacity="0"/>
<stop offset="0.992895" stop-color="#7E5E91"/>
</radialGradient>
<linearGradient id="paint20_linear_18_12057" x1="7.5" y1="6" x2="7.5" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0.5625" stop-color="#CB1E72"/>
<stop offset="1" stop-color="#E61C31"/>
</linearGradient>
<linearGradient id="paint21_linear_18_12057" x1="24.5" y1="6" x2="24.5" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0.5625" stop-color="#CB1E72"/>
<stop offset="1" stop-color="#E61C31"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,83 @@
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, .newticketmaincontent {
background-color: #fff;
padding: 2em;
}
.newticketmaincontent {
min-width: 80ch;
max-width: 80ch;
}
.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, .newticketmaincontent > h2 {
margin: 0 0 0.5em 0;
}
dialog > button {
float: right;
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm4.5 5.5a.5.5 0 0 0 0 1h5.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5"/>
<path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="2.5 2 11 11">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1 1 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4 4 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="0 0 16 16">
<path d="M5.5 7a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zM5 9.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5m0 2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5"/>
<path d="M9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.5zm0 1v2A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#366FFF" viewBox="0 0 16 16">
<path d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334q.002-.211-.006-.422A6.7 6.7 0 0 0 16 3.542a6.7 6.7 0 0 1-1.889.518 3.3 3.3 0 0 0 1.447-1.817 6.5 6.5 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.32 9.32 0 0 1-6.767-3.429 3.29 3.29 0 0 0 1.018 4.382A3.3 3.3 0 0 1 .64 6.575v.045a3.29 3.29 0 0 0 2.632 3.218 3.2 3.2 0 0 1-.865.115 3 3 0 0 1-.614-.057 3.28 3.28 0 0 0 3.067 2.277A6.6 6.6 0 0 1 .78 13.58a6 6 0 0 1-.78-.045A9.34 9.34 0 0 0 5.026 15"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -0,0 +1,89 @@
body {
margin: 0;
font-family: 'Segoe UI Variable', sans-serif;
background-color: #282828;
display: grid;
place-items: center;
}
.mainContent {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2.5em;
padding: 2em;
background-color: #fdfdfd;
box-shadow: 0 0 1em 0 #202020;
position: relative;
margin-top: 96px;
}
.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.25em;
}
.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;
gap: 0.5em;
}
a, a:link {
color: #366FFF;
}
a:hover {
color: #ff8266;
}
a > img {
margin-right: 0.2em;
}
#miauMiau {
position: absolute;
top: -90px;
right: 9%;
}

View File

@@ -0,0 +1,43 @@
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;
box-shadow: 0 0 1em 0 #202020;
}
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;
}

View File

@@ -0,0 +1,3 @@
<svg width="256" height="256" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 16C2 8.28 8.28 2 16 2C23.72 2 30 8.28 30 16C30 23.72 23.72 30 16 30C8.28 30 2 23.72 2 16ZM4.04121 15H8.03076C8.13807 13.2596 8.52434 11.5753 9.1599 10H5.60892C4.74358 11.4936 4.191 13.1903 4.04121 15ZM10.0353 15H15V10H11.3456C10.6097 11.5521 10.1595 13.2429 10.0353 15ZM15 17H10.0353C10.1595 18.7571 10.6097 20.4479 11.3456 22H15V17ZM15 24H12.506C13.2065 25.0055 14.0427 25.9242 15 26.73V24ZM19.0053 27.6195C21.3304 27.0184 23.3836 25.7366 24.9392 24H21.8647C21.0976 25.3284 20.1372 26.548 19.0053 27.6195ZM22.8399 22H26.39V22.0019C27.256 20.5078 27.8089 18.8105 27.9588 17H23.9692C23.8619 18.7406 23.4756 20.4251 22.8399 22ZM21.9647 17H17V22H20.6544C21.3903 20.4479 21.8405 18.7571 21.9647 17ZM23.9692 15H27.9588C27.8089 13.1895 27.256 11.4922 26.39 9.99813V10H22.8401C23.4757 11.5753 23.8619 13.2596 23.9692 15ZM20.6544 10H17V15H21.9647C21.8405 13.2429 21.3903 11.5521 20.6544 10ZM21.8655 8H24.9392C23.3853 6.26532 21.3348 4.98441 19.0129 4.38249C20.1418 5.45442 21.0999 6.67328 21.8655 8ZM17 5.27004V8H19.494C18.7934 6.99455 17.9573 6.07585 17 5.27004ZM15 5.27001C14.0427 6.07583 13.2065 6.99454 12.506 8H15V5.27001ZM17 24V26.73C17.9573 25.9242 18.7935 25.0055 19.494 24H17ZM5.60892 22H9.16297C8.52579 20.4243 8.1385 18.7398 8.03086 17H4.04121C4.191 18.8097 4.74358 20.5064 5.60892 22ZM13.0014 27.6212C11.8688 26.5485 10.9075 25.3282 10.1396 24H7.0608C8.61787 25.7382 10.6736 27.0208 13.0014 27.6212ZM10.1345 8C10.9 6.67329 11.8581 5.45443 12.987 4.3825C10.6651 4.98443 8.61466 6.26533 7.0608 8H10.1345Z" fill="#FF5A36"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TokenMan</title>
<style>
body {
font-family: "Segoe UI Variable", sans-serif;
background-color: #111;
color: #fff;
}
</style>
<script>
function loginFunction() {
let loginWindow = window.open(`http://bottleneck.pizzly-catfish.ts.net:8081/api/auth/login?state=close`,`_blank`)
let loginWatcher = setInterval(() => {
if (loginWindow.closed) {
fetch(`http://bottleneck.pizzly-catfish.ts.net:8081/api/auth/whoami`).then(fetchRes => {
fetchRes.json().then(jsonRes => {
if (jsonRes.loggedIn) {
document.getElementById("loggedInText").innerText = `Logged in as ${jsonRes.username} with scopes ${jsonRes.scopes.join(", ")}`
document.getElementById("loginButton").disabled = true
document.getElementById("logoutButton").disabled = false
} else {
alert("An error occured during login.")
}
clearInterval(loginWatcher);
})
})
}
}, 500);
}
function logoutFunction() {
fetch(`http://bottleneck.pizzly-catfish.ts.net:8081/api/auth/logout`).then(fetchRes => {
if (fetchRes.status == 200) {
document.getElementById("loggedInText").innerText = `Not Logged In`
document.getElementById("loginButton").disabled = false
document.getElementById("logoutButton").disabled = true
} else {
fetchRes.text().then(text => {
alert("An error occured during logout: " + text)
})
}
})
}
</script>
</head>
<body>
<h1>TokenMan</h1>
<div>
<button onclick="loginFunction()" id="loginButton">Login</button>
<button onclick="logoutFunction()" id="logoutButton" disabled="true">Logout</button>
<span id="loggedInText">Not Logged In</span>
</div>
</body>
</html>

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,20 @@
<!--From microsoft/fluent-emoji, used github copilot to modify the color palette-->
<!--https://github.com/microsoft/fluentui-emoji/blob/main/assets/Leopard/Flat/leopard_flat.svg-->
<svg width="256" height="256" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 22.52L15.39 29.61C15.45 29.81 15.3 30 15.09 30H11.46C11.28 30 11.15 29.84 11.18 29.67L11.35 28.69C11.41 28.36 11.66 28.11 11.97 28.03L10.5 22.52H13.5Z" fill="#B0B0B0"/>
<path d="M16.97 28L18.44 22.52H21.42L19.52 29.63C19.46 29.85 19.27 30 19.04 30H16.2C16.01 30 15.87 29.83 15.9 29.64L16.07 28.69C16.14 28.29 16.49 28 16.89 28H16.97Z" fill="#B0B0B0"/>
<path d="M4.74 18H6L7.32233 20.3509L7.99 23.63V28C7.44 28 7 28.44 7 28.98V29.58C7 29.81 7.19 30 7.42 30H10.67C10.84 30 10.98 29.86 10.98 29.69V24H19.56L21.96 26.4L21.53 28H21.31C20.83 28 20.41 28.33 20.3 28.79L20.1 29.59C20.05 29.8 20.21 30 20.43 30H23.65C23.87 30 24.06 29.86 24.12 29.66L25.62 25.16C25.77 24.7 25.88 24.22 25.94 23.73C25.94 23.71 25.94 23.69 25.95 23.68C25.95 23.66 25.95 23.65 25.96 23.63C25.96 23.61 25.96 23.59 25.97 23.57C25.97 23.55 25.9725 23.53 25.975 23.51C25.9775 23.49 25.98 23.47 25.98 23.45C25.98 23.435 25.9825 23.4175 25.985 23.4C25.9875 23.3825 25.99 23.365 25.99 23.35C25.99 23.33 25.9925 23.31 25.995 23.29C25.9974 23.2706 25.9998 23.2513 26 23.2319V24C26 25.65 27.35 27 29 27C29.55 27 30 26.55 30 26C30 25.45 29.55 25 29 25C28.45 25 28 24.55 28 24V18C28 16.35 26.65 15 25 15H24.18C24.1605 15 24.1411 15.0006 24.1218 15.0017C24.0947 15.0006 24.0674 15 24.04 15H19.2342L17.9961 15.6542L16.6664 15H15.29C14.5103 15 13.7478 14.7846 13.0826 14.3858C13.0311 14.355 11.3056 14.2227 11.3056 14.2227V12.3169C11.1001 11.811 10.99 11.263 10.99 10.7V9.5C10.99 8.67 10.32 8 9.49 8C8.66 8 7.99 8.67 7.99 9.5V10H7.64C5.63 10 4 11.63 4 13.64C4 13.84 3.84 14 3.64 14L2.57812 15.6953L4 17.8984C4.2355 17.9646 4.48372 18 4.74 18Z" fill="#D0D0D0"/>
<path d="M10 10V9.5C10 9.22 9.78 9 9.5 9C9.22 9 9 9.22 9 9.5V10H10Z" fill="#E0E0E0"/>
<path d="M2 15.26C2 16.52 2.85 17.57 4 17.9V13.64C4 13.84 3.84 14 3.64 14H2.82031L2 15.26Z" fill="#E0E0E0"/>
<path d="M7.99 19.99V23.63C6.81 23.04 6 21.83 6 20.42V18C7.1 18 7.99 18.89 7.99 19.99Z" fill="#E0E0E0"/>
<path d="M11.95 14.96C12.41 14.96 12.83 14.74 13.08 14.39C13.0667 14.3821 13.0526 14.3741 13.0383 14.3659C12.9987 14.3434 12.9567 14.3194 12.92 14.29C12.18 13.8 11.62 13.1 11.3 12.3C10.85 12.54 10.54 13.01 10.54 13.55C10.54 14.33 11.17 14.96 11.95 14.96Z" fill="#6D6D6D"/>
<path d="M17.95 16C17.32 16 16.8 15.58 16.64 15H19.26C19.1 15.58 18.58 16 17.95 16Z" fill="#6D6D6D"/>
<path d="M13.95 18.05C14.5023 18.05 14.95 17.6023 14.95 17.05C14.95 16.4978 14.5023 16.05 13.95 16.05C13.3977 16.05 12.95 16.4978 12.95 17.05C12.95 17.6023 13.3977 18.05 13.95 18.05Z" fill="#6D6D6D"/>
<path d="M15 20.59C15 20.9158 14.7359 21.18 14.41 21.18C14.0842 21.18 13.82 20.9158 13.82 20.59C13.82 20.2642 14.0842 20 14.41 20C14.7359 20 15 20.2642 15 20.59Z" fill="#6D6D6D"/>
<path d="M22.18 20.59C22.18 20.9158 21.9158 21.18 21.59 21.18C21.2642 21.18 21 20.9158 21 20.59C21 20.2642 21.2642 20 21.59 20C21.9158 20 22.18 20.2642 22.18 20.59Z" fill="#6D6D6D"/>
<path d="M22.95 17.05C22.95 17.6023 22.5023 18.05 21.95 18.05C21.3977 18.05 20.95 17.6023 20.95 17.05C20.95 16.4978 21.3977 16.05 21.95 16.05C22.5023 16.05 22.95 16.4978 22.95 17.05Z" fill="#6D6D6D"/>
<path d="M17.95 20C18.7011 20 19.31 19.3911 19.31 18.64C19.31 17.8889 18.7011 17.28 17.95 17.28C17.1989 17.28 16.59 17.8889 16.59 18.64C16.59 19.3911 17.1989 20 17.95 20Z" fill="#6D6D6D"/>
<path d="M30 26C30 26.55 29.55 27 29 27C28.65 27 28.31 26.94 28 26.83V24C28 24.55 28.45 25 29 25C29.55 25 30 25.45 30 26Z" fill="#6D6D6D"/>
<path d="M5 14.5C5 14.78 5.22 15 5.5 15C5.78 15 6 14.78 6 14.5V14C6 13.72 5.78 13.5 5.5 13.5C5.22 13.5 5 13.72 5 14V14.5Z" fill="#212121"/>
<path d="M2 14.72C2 14.32 2.32 14 2.72 14H3.69L2 15.5781V14.72Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Under Construction</title>
<style>
html {
height: 75%;
}
body {
margin: 0;
display: grid;
place-items: center;
height: 100%;
color: #fff;
background-color: #0b0b0b;
}
body > div {
margin-left: 2em;
margin-right: 2em;
}
a, a:hover {
color: #fff;
text-decoration: underline;
}
</style>
</head>
<body>
<div>
<img src="/static/construction.svg" alt="Construction Icon" width="64">
<h1>Under Construction</h1>
<p>
This page is undergoing a revamp and will be available again soon.<br>
All API endpoints are still available. Documentation is available <a href="/posts/20240409-API-Documentation.html">here</a>.
</p>
</div>
</body>
</html>

View File

@@ -2,16 +2,18 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/posts.css">
<link rel="stylesheet" href="/static/newposts.css">
<meta property="og:site_name" content="enstrayed.com">
<meta property="og:url" content="<!--SSR_REPLACE_URL-->>">
<meta property="og:url" content="<!--SSR_REPLACE_URL-->">
<title><!--SSR_REPLACE_TITLE--></title>
<meta property="og:title" content="<!--SSR_REPLACE_TITLE-->">
<meta name="description" content="No description available.">
<meta property="og:description" content="No description available.">
<meta property="og:image" content="https://enstrayed.com/static/snowleopard.png">
<meta property="og:color" content="#FF5A36">
</head>
<body>

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enstrayed.com</title>
<meta property="og:title" content="Enstrayed.com">
<meta name="description" content="Welcome to Enstrayed.com">
<meta property="og:description" content="Welcome to Enstrayed.com">
<meta property="og:url" content="https://enstrayed.com">
<meta property="og:image" content="https://enstrayed.com/static/snowleopard.png">
<meta property="og:color" content="#FF5A36">
<link rel="stylesheet" href="/static/newindex.css">
<script>
fetch("/api/nowplayingbeta?format=html").then(response => response.text()).then(response => {document.getElementById("nowplaying").innerHTML = response})
</script>
<script>
function rerollBlogPostsHeaderText() {
let adjectives = [ "Assorted", "Random", "Various", "Miscellaneous", "Different", "Eclectic", "Mixed", "Profound"];
document.getElementById("blogPostsHeader").innerHTML = adjectives[Math.floor(Math.random() * adjectives.length)] + " Notes";
}
rerollBlogPostsHeaderText();
</script>
</head>
<body>
<div class="mainContent">
<img src="/static/snow-leopard.svg" alt="Snow Leopard Icon" width="96" id="miauMiau">
<div>
<!-- <img src="/static/orange-globe.svg" alt="Globe Icon" width="64"> -->
<h1>Welcome to Enstrayed.com</h1>
<div class="linkColumn">
<div>
<span>I received an Email from @enstrayed.com</span><br>
<a href="https://helpdesk.enstrayed.com"><img src="/static/icons/external.svg" alt="">Click here for information about EOES</a>
</div>
<div>
<span>I need help with an Enstrayed.com web service</span><br>
<a href="https://helpdesk.enstrayed.com"><img src="/static/icons/external.svg" alt="">Click here to visit the Helpdesk</a>
</div>
<div>
<a href="/urltoolbox"><img src="/static/icons/link.svg" alt="">URL Toolbox</a>
</div>
<div>
<a href="/posts/20240409-API-Documentation.html"><img src="/static/icons/link.svg" alt="">API Documentation</a>
</div>
<div>
<a href="https://downloads.enstrayed.com"><img src="/static/icons/link.svg" alt="">Downloads</a>
</div>
<div class="apiVersion">
<!--SSR_APIVERSION-->
</div>
</div>
</div>
<div>
<!-- <img src="/static/snow-leopard.svg" alt="Snow Leopard Icon" width="64"> -->
<h1>@Enstrayed</h1>
<div class="linkColumn marginBottom1em">
<a href="https://twitter.com/enstrayed"><img src="/static/icons/twitter.svg" alt="">Twitter</a>
<a href="https://github.com/enstrayed"><img src="/static/icons/github.svg" alt="">GitHub</a>
</div>
<div class="blogPostsList">
<h2 id="blogPostsHeader" onclick="rerollBlogPostsHeaderText()">Notes</h2>
<ul>
<!--SSR_BLOGPOSTS-->
</ul>
</div>
</div>
</div>
<div id="nowplaying"></div>
</body>
</html>