33 Commits

Author SHA1 Message Date
28edcb21aa update readme and api docs 2025-12-15 22:16:53 -08:00
a5c3db32d3 website fixes before migration 2025-12-12 14:33:52 -08:00
Enstrayed
f8d5d4a69b fix token checking 2025-11-15 16:25:13 -08:00
Enstrayed
3af3c76678 add helpdesk article parsing 2025-10-29 13:17:06 -07:00
Enstrayed
44ed0e19d0 env file handling + fix error in libnowplaying 2025-10-26 00:44:54 -07:00
Enstrayed
535e3958cb minor repo cleanup 2025-10-23 14:17:11 -07:00
Enstrayed
35a84ffb7c update frontpage & new helpdesk kba + other work 2025-10-05 14:40:59 -07:00
Enstrayed
b1ac3d6f58 sync changes 2025-10-01 09:50:32 -07:00
Enstrayed
4de76c34c2 cleanup repo + add articles 2025-09-01 22:45:04 -07:00
Enstrayed
3bc0861f21 misc changes I should just finish this 2025-08-31 20:05:29 -07:00
Enstrayed
1cab7e9869 bruh 2025-07-16 16:20:35 -07:00
Enstrayed
b0e4e0f26c revise readme 2025-06-13 23:37:09 -07:00
Nathan
fb0953c50c add dockerfile for komodo 2025-06-13 23:05:58 -07:00
Nathan
d3e0e34989 fuck it
we ball
2025-06-13 22:59:48 -07:00
Enstrayed
cb289889d2 just finish it already 2025-06-13 22:59:07 -07:00
Enstrayed
b4ecb63e43 add more forms 2025-05-26 17:15:14 -07:00
Enstrayed
0ef0b82765 this codebase is kindof a mess now 2025-05-26 01:26:35 -07:00
Nathan
1c3427e63f groundwork for helpdesk stuff 2025-05-16 00:23:09 -07:00
Enstrayed
3461e9f618 what 2025-05-10 00:29:37 -07:00
Enstrayed
d3f0b29094 finish etyd + checktoken tweaks 2025-05-09 16:59:54 -07:00
Enstrayed
4be52c7f26 documentation updates 2025-05-04 22:59:46 -07:00
Enstrayed
080f58baa0 more auth changes + add tokenman page 2025-05-04 19:15:26 -07:00
Enstrayed
a37c6033df add cookie parser, finish callback & add login and whoami 2025-05-03 19:50:40 -07:00
Nathan
bd26c63ac7 merge new website code into database refactor 2025-05-03 02:31:53 -07:00
Enstrayed
2ad8d04c99 readme will need rewritten later 2025-04-22 09:22:54 -07:00
Enstrayed
c868c9bc09 groundwork for writing new tokens to db 2025-04-20 20:15:02 -07:00
Enstrayed
d07d75c994 main groundwork for oidc handling 2025-04-20 18:30:29 -07:00
Enstrayed
74cf834699 fix problem with logging & add misc functions 2025-04-20 13:52:49 -07:00
Enstrayed
9f96d82e53 auth modification 2025-04-18 22:36:15 -07:00
Enstrayed
b917800eec implement new token checking function and modify email.js to use it 2025-04-18 13:16:49 -07:00
Nathan
10be48e848 merge change from main into new-db
merge change from main into new-db
2025-04-17 23:32:26 -07:00
Enstrayed
4c0f140e9e working changes 2025-04-17 22:41:08 -07:00
Enstrayed
8a2bd6ecc3 update index & auth to use postgres 2025-04-16 19:50:12 -07:00
63 changed files with 1633 additions and 415 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
bun.lockb bun.lockb
todo.txt todo.txt
proto.js proto.js
.env

9
Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM node:22
USER node
WORKDIR /app
RUN git clone https://github.com/enstrayed/enstrayedapi .
RUN npm install
ENTRYPOINT [ "node", "index.js" ]

101
README.md
View File

@@ -1,93 +1,10 @@
# Enstrayed API ## Note for passersby
This repository contains the code for my personal web API written in JavaScript using the Express framework. * If you have feedback or are experiencing a problem, [click here](https://enstrayed.com/helpdesk/ticket/new?form=TBD).
* If you need to report a security issue, [click here](https://enstrayed.com/helpdesk/ticket/new?form=TBD).
* This code is unlicensed but I don't really care if you use parts of it (I don't know why you would though).
## Documentation ## Links
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). * [Published API Documentation](https://enstrayed.com/posts/99999999-API-Documentation.html)
* [Internal Use Endpoints](https://git.enstrayed.com/enstrayed/enstrayedapi/wiki/Internal-Use-Endpoints)
## Issues * [Configuration Reference](https://git.enstrayed.com/enstrayed/enstrayedapi/wiki/Configuration)
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. * [Komodo Configuration](https://git.enstrayed.com/enstrayed/enstrayedapi/wiki/Komodo)
## 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` |
<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.
```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`.
```dockerfile
FROM node:22
WORKDIR /app
RUN git clone https://github.com/enstrayed/enstrayedapi .
RUN npm install
USER node
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

@@ -0,0 +1,17 @@
# Using App Passwords on Enstrayed Cloud
App Passwords are credentials used to access your Enstrayed Cloud account from a specific application or device. They are created automatically for many Nextcloud clients, or can be created manually using the steps below.
**As Enstrayed Cloud exclusively uses ECLS for sign on, you will need to create app passwords for any device that does not support browser login.**
To create an App Password:
1. Login to Enstrayed Cloud
2. Select your profile image in the top right of the page and select 'Personal Settings'<br>
![Screenshot showing steps to access Personal Settings in Enstrayed Cloud](https://i.enstrayed.com/20250716-55f5388f.jpg)
3. In the sidebar on the left side of the page, select 'Security'<br>
![Screenshot highlighting the Security tab on the left sidebar](https://i.enstrayed.com/20250716-55ad5071.jpg)
4. Enter a description for the app password, such as the device name or application, then select 'Create new app password'<br>
![Screenshot showing steps to create an app password](https://i.enstrayed.com/20250716-5512d99f.jpg)
5. You will be presented with a username and unique password; You will use these credentials to access Enstrayed Cloud from your application.<br>
![Screenshot showing the prompt displaying the newly created app password](https://i.enstrayed.com/20250716-55dc2a4d.jpg)

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Card</title>
<style>
html {
height: 100%;
}
body {
display: grid;
place-items: center;
background-color: #111;
font-family: Georgia, 'Times New Roman', Times, serif;
}
.theCardItself {
display: flex;
flex-direction: column;
/* align-items: center; */
max-width: 34ch;
background-image: url("/static/pages/contactcard-assets/nordwood-themes-R53t-Tg6J4c-unsplash.jpg");
background-size: cover;
padding: 1em;
gap: 1em;
}
.linkButton {
display: flex;
gap: 0.5em;
align-content: center;
padding: 0.75em;
color: white;
}
.linkButtonText {
font-size: 1.75em;
font-family: 'Segoe UI', --apple-system, sans-serif;
}
.signalButton {
background-color: #3b45fd;
}
.twitterButton {
background-color: #00a2f5;
}
.immichButton {
background-color: #000;
color: #accbfa;
}
.linkButtonContainer {
width: 100%;
text-decoration: none;
}
.mainContainer {
display: flex;
flex-direction: column;
max-width: 60ch;
}
</style>
</head>
<body>
<div class="theCardItself">
<img src="https://i.enstrayed.com/contactphoto.jpg" alt="Contact Photo"
style="border-radius: 100%; width: 10em;">
<span style="font-size: 3em;">@Enstrayed</span>
<span>It seems you scanned my badge! Here's where to find me elsewhere:</span>
<a href="https://twitter.com/enstrayed" class="linkButtonContainer">
<div class="linkButton twitterButton">
<img src="/dynamic/icon/twitter/ffffff" style="width: 1lh;">
<span class="linkButtonText">Twitter</span>
</div>
</a>
<a href="https://signal.me/#eu/rSUBnWj8pO74gg0tWsm05bYbuoOHPtlNbjL5h7BLng9ff9_K_jVMKoz5glHr3Oco"
class="linkButtonContainer">
<div class="linkButton signalButton">
<img src="/dynamic/icon/signal/ffffff" style="width: 1lh;">
<span class="linkButtonText">Signal</span>
</div>
</a>
<span>Here's the photos I took while in Reno:</span>
<a href="https://photos.enstrayed.com/share/MkDPxuQJJrbU_yISKey-BwVsdnnIkzEQEjMN2Gk1XfCnXtzp9ZtOGub4kN_VTJLzwnI"
class="linkButtonContainer">
<div class="linkButton immichButton">
<img src="/static/pages/contactcard-assets/immich.svg" style="width: 1lh;">
<span class="linkButtonText">BLFC Photo Album</span>
</div>
</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="100 95 590 580" style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FA2921;}
.st1{fill:#ED79B5;}
.st2{fill:#FFB400;}
.st3{fill:#1E83F7;}
.st4{fill:#18C249;}
</style>
<g id="Flower_00000077325900055813483940000000694823054982625702_">
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
C300.01,209.24,339.15,235.47,375.48,267.63z"/>
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

11
archive/example.html Normal file
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

@@ -1,34 +0,0 @@
import { globalConfig, app } from "../index.js"
import { logRequest } from "../liberals/logging.js"
app.delete("/api/token", (rreq,rres) => {
fetch(`${globalConfig.couchdbHost}/auth/sessions`).then(res => res.json()).then(fetchRes => {
if (fetchRes.sessions[rreq.get("Authorization")]) {
delete fetchRes.sessions[rreq.get("Authorization")]
fetch(`${globalConfig.couchdbHost}/auth/sessions`, {
method: "PUT",
headers: {
"If-Match": fetchRes._rev
},
body: JSON.stringify({
sessions: fetchRes.sessions
})
}).then(res => {
if (res.status == 201) {
rres.sendStatus(200)
} else {
logRequest(rres,rreq,500,`Token invalidation may have failed: ${res.status} ${res.statusText}`)
rres.sendStatus(500)
}
}).catch(fetchError => {
logRequest(rres,rreq,500,fetchError)
rres.sendStatus(500)
})
} else {
rres.sendStatus(400)
}
})
})
export default {app}

View File

@@ -1,33 +1,38 @@
import * as fs from 'fs' import * as fs from 'fs'
import { execSync } from 'child_process' import { execSync } from 'child_process'
import postgres from 'postgres'
import express, { json } from 'express' import express, { json } from 'express'
import cookieParser from 'cookie-parser'
const app = express() const app = express()
if (!process.env.API_DBHOST || !process.env.API_DBCRED) { if (!process.env.DATABASE_URL) {
console.log("FATAL: API_DBHOST and API_DBCRED must be set") try {
process.exit(1) process.loadEnvFile("./.env")
} catch {
console.log("FATAL: DATABASE_URL was not defined or found in env file")
process.exit(1)
}
} }
const globalConfig = await fetch(`${process.env.API_DBHOST}/config/${process.env.API_DBCRED.split(":")[0]}`,{ const db = postgres(process.env.DATABASE_URL)
headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`}
}).then(response => { const globalConfig = await db`select content from config where id = ${process.env.CONFIG_OVERRIDE ?? 'production'}`.then(response => { return response[0]["content"] }).catch(error => {
if (response.status !== 200) { console.log(`FATAL: Error occured in downloading configuration: ${error}`)
console.log(`FATAL: Failed to download configuration: ${response.status} ${response.statusText}`) process.exit(1)
process.exit(1)
} else {
return response.json()
}
}) })
const globalVersion = execSync(`git show --oneline -s`).toString().split(" ")[0] const globalVersion = execSync(`git show --oneline -s`).toString().split(" ")[0]
// Returns ISO 8601 Date & 24hr time for UTC-7/PDT // Returns ISO 8601 Date & 24hr time for UTC-7/PDT
const startTime = new Date(new Date().getTime() - 25200000).toISOString().slice(0,19).replace('T',' ') 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 app.use(json()) // Allows receiving JSON bodies
// see important note: https://expressjs.com/en/api.html#express.json // see important note: https://expressjs.com/en/api.html#express.json
app.use(cookieParser()) // Allows receiving cookies
process.on('SIGTERM', function() { process.on('SIGTERM', function () {
console.log("Received SIGTERM, exiting...") console.log("Received SIGTERM, exiting...")
process.exit(0) process.exit(0)
}) })
@@ -40,11 +45,15 @@ fs.readdir("./routes", (err, files) => {
let importedRoutes = [] let importedRoutes = []
files.forEach(file => { files.forEach(file => {
import(`./routes/${file}`) import(`./routes/${file}`)
importedRoutes.push(file.slice(0,-3)) importedRoutes.push(file.slice(0, -3))
}) })
process.stdout.write(` | Imported ${importedRoutes} \n`) process.stdout.write(` | Imported ${importedRoutes} \n`)
} }
}) })
// app.use(function(req,res,next) {
// res.status(404).send("miau")
// })
process.stdout.write(`>>> EnstrayedAPI ${globalVersion} | Started ${startTime} on ${process.env.API_PORT ?? 8081}`) process.stdout.write(`>>> EnstrayedAPI ${globalVersion} | Started ${startTime} on ${process.env.API_PORT ?? 8081}`)
app.listen(process.env.API_PORT ?? 8081) app.listen(process.env.API_PORT ?? 8081)

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} token Token as received by client
* @param {string} scope Scope the token will need to have in order to succeed * @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) { async function checkToken(token,scope) {
return await fetch(`${process.env.API_DBHOST}/auth/sessions`, { 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 => {
headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} if (response.length === 0) {
}).then(fetchRes => { return false
} else if (response[0]?.scopes.split(",").includes(scope)) {
return fetchRes.json().then(dbRes => { return true
} else {
if (dbRes.sessions[token] == undefined) { // If the token is not on the sessions list then reject return false
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
}) })
} }
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 * from sessions where 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

@@ -0,0 +1,49 @@
import { fs } from "../index.js"
function parsePosts() {
let files = fs.readdirSync(process.cwd()+"/website/posts")
let result = ""
for (let x in files) {
if (files[x].endsWith(".html") === false && files[x].endsWith(".md") === false ) { break } // If file/dir is not .html or .md then ignore
let date = files[x].split("-")[0]
if (date < 10000000 || date > 99999999) { break } // If date does not fit ISO8601 format then ignore
date = date.replace(/.{2}/g,"$&-").replace("-","").slice(0,-1) // Insert a dash every 2 characters, remove the first dash, remove the last character
let name = files[x].slice(9).replace(/-/g," ").replace(".html","").replace(".md","") // Strip Date, replace seperator with space & remove file extension
result = `<li>${date} <a href="/posts/${files[x]}">${name}</a></li>`+result
}
return result
}
function parseKbas() {
let files = fs.readdirSync(process.cwd()+"/website/helpdesk/kbas")
let pages = {}
let result = ""
for (let x in files) {
if (files[x].endsWith(".html") === false && files[x].endsWith(".md") === false ) { break } // If file/dir is not .html or .md then ignore
if (!Array.isArray(pages[files[x].split("-")[0]])) { // check if category array does not exist
pages[files[x].split("-")[0]] = [] // create it
}
pages[files[x].split("-")[0]].push(files[x])
}
for (let y in pages) {
result += `<h1>${y}</h1><div class="linkColumn">`
for (let z in pages[y]) {
result += `<a href="/helpdesk/articles/${pages[y][z]}"><img src="/dynamic/icon/post/366FFF">${pages[y][z].split("-")[1].split(".")[0].replace(/_/g," ")}</a>`
}
result += `</div>`
}
return result
}
export { parseKbas, parsePosts}

View File

@@ -32,7 +32,7 @@ async function queryLastfm() {
} }
} }
}).catch(fetchError => { }).catch(fetchError => {
console.log("libnowplaying.js: Fetch failed! "+fetchError) console.log("libnowplaying.js: Fetch failed! " + fetchError)
return {} return {}
}) })
} }
@@ -42,30 +42,34 @@ async function queryLastfm() {
* @returns {object} Object containing response in JSON and HTML (as string) * @returns {object} Object containing response in JSON and HTML (as string)
*/ */
async function queryJellyfin() { async function queryJellyfin() {
return await fetch(`${globalConfig.nowplaying.jellyfin.host}/Sessions`, { try {
headers: { return await fetch(`${globalConfig.nowplaying.jellyfin.host}/Sessions`, {
"Authorization": `MediaBrowser Token=${globalConfig.nowplaying.jellyfin.apiKey}` headers: {
} "Authorization": `MediaBrowser Token=${globalConfig.nowplaying.jellyfin.apiKey}`
}).then(response => response.json()).then(response => {
for (let x in response) {
if (response[x].UserName !== globalConfig.nowplaying.jellyfin.target) { break } // If session does not belong to target specified in config, skip
if (response[x].NowPlayingItem == undefined) { break } // If the NowPlayingItem object is not present, skip (session is not playing anything)
if (response[x].NowPlayingItem.MediaType !== "Audio") { break } // If not playing 'audio', skip, this might change in the future
return {
"json": {
"songName": response[x].NowPlayingItem.Name,
"artistName": response[x].NowPlayingItem.Artists[0],
"albumName": response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`,
"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"> <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>`
} }
} }).then(response => response.json()).then(response => {
for (let x in response) {
if (response[x].UserName !== globalConfig.nowplaying.jellyfin.target) { break } // If session does not belong to target specified in config, skip
if (response[x].NowPlayingItem == undefined) { break } // If the NowPlayingItem object is not present, skip (session is not playing anything)
if (response[x].NowPlayingItem.MediaType !== "Audio") { break } // If not playing 'audio', skip, this might change in the future
return {
"json": {
"songName": response[x].NowPlayingItem.Name,
"artistName": response[x].NowPlayingItem.Artists[0],
"albumName": response[x].NowPlayingItem.Album ?? `${response[x].NowPlayingItem.Name} (Single)`,
"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"> <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>`
}
}
return notPlayingAnythingPlaceholder
})
} catch {
return notPlayingAnythingPlaceholder return notPlayingAnythingPlaceholder
}) }
} }
export { queryLastfm, queryJellyfin } export { queryLastfm, queryJellyfin }

View File

@@ -4,9 +4,10 @@
* @param {object} request Parent request object * @param {object} request Parent request object
* @param {number} code Status code to log, should be same as sent to client * @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 {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) { function logRequest(response,request,code,extra,authresponse) {
console.log(`${request.get("cf-connecting-ip") ?? request.ip} ${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 } 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", "version": "1.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"marked": "^14.1.3", "marked": "^14.1.3",
"nodemailer": "^6.9.15" "nodemailer": "^6.9.15",
"postgres": "^3.4.5"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.0.12", "@types/bun": "^1.0.12",
@@ -157,6 +159,28 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -470,9 +494,9 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "14.1.3", "version": "14.1.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.3.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
"integrity": "sha512-ZibJqTULGlt9g5k4VMARAktMAjXoVnnr+Y3aCqW1oDftcV4BA3UmrBifzXoZyenHRk75csiPu9iwsTj4VNBT0g==", "integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
@@ -600,6 +624,19 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",

View File

@@ -1,8 +1,10 @@
{ {
"dependencies": { "dependencies": {
"cookie-parser": "^1.4.7",
"express": "^4.18.2", "express": "^4.18.2",
"marked": "^14.1.3", "marked": "^14.1.3",
"nodemailer": "^6.9.15" "nodemailer": "^6.9.15",
"postgres": "^3.4.5"
}, },
"name": "enstrayedapi", "name": "enstrayedapi",
"version": "1.0.0", "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 { // select
db`select scopes,username from sessions where 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,username,scopes,expires,comment) values (${newToken},${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 }

19
routes/dynamic.js Normal file
View File

@@ -0,0 +1,19 @@
import { app, fs } from "../index.js"
app.get("/dynamic/icon/*", (rreq, rres) => {
try {
let file = fs.readFileSync(process.cwd()+`/website/dynamic/icons/${rreq.path.split("/")[3]}.svg`,"utf-8")
file = file.replace("<!--DYN_REPLACECOLOR-->", "#"+rreq.path.split("/")[4].slice(0,6))
rres.setHeader("Content-Type","image/svg+xml").send(file)
} catch {
rres.sendStatus(404)
}
})
app.get("/dynamic/background/*", (rreq,rres) => {
if (rreq.headers["accept"].includes("image/jxl")) {
rres.setHeader("Content-Type", "image/jxl").sendFile( `${process.cwd()}/website/dynamic/backgrounds/${rreq.path.split("/")[3]}.jxl`)
} else {
rres.setHeader("Content-Type", "image/jpeg").sendFile( `${process.cwd()}/website/dynamic/backgrounds/${rreq.path.split("/")[3]}.jpg`)
}
})

View File

@@ -1,5 +1,5 @@
import { app, globalConfig } from "../index.js" // Get globals from index 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 { logRequest } from "../liberals/logging.js"
import * as nodemailer from 'nodemailer' import * as nodemailer from 'nodemailer'
@@ -14,10 +14,10 @@ const transporter = nodemailer.createTransport({
}) })
app.post("/api/sendemail", (rreq,rres) => { app.post("/api/sendemail", (rreq,rres) => {
checkToken(rreq.get("Authorization"),"email").then(authRes => { checkTokenNew(rreq,"email").then(authRes => {
if (authRes === false) { if (authRes.result === false) {
rres.sendStatus(401) 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 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) rres.sendStatus(400)
} else { } else {
@@ -29,14 +29,14 @@ app.post("/api/sendemail", (rreq,rres) => {
text: rreq.body.message ?? "Message Not Set" text: rreq.body.message ?? "Message Not Set"
}).then(transportResponse => { }).then(transportResponse => {
if (transportResponse.response.slice(0,1) === "2") { 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) rres.status(200).send(transportResponse.response)
} else { } else {
logRequest(rres,rreq,400,transportResponse.response) logRequest(rres,rreq,400,transportResponse.response,authRes)
rres.status(400).send(transportResponse.response) rres.status(400).send(transportResponse.response)
} }
}).catch(transportError => { }).catch(transportError => {
logRequest(rres,rreq,500,transportError) logRequest(rres,rreq,500,transportError,authRes)
rres.sendStatus(500) rres.sendStatus(500)
}) })

View File

@@ -1,130 +1,73 @@
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 { checkToken, checkTokenNew } from "../liberals/auth.js"
import { logRequest } from "../liberals/logging.js" import { logRequest } from "../liberals/logging.js"
app.get("/api/etyd*", (rreq,rres) => { app.get("/api/etyd*", (rreq,rres) => {
fetch(`${process.env.API_DBHOST}/etyd${rreq.path.replace("/api/etyd","")}`,{ let userRequest = rreq.path.replace("/api/etyd/","")
headers: { "Authorization": `Basic ${btoa(process.env.API_DBCRED)}`} db`select content from etyd where url = ${userRequest}`.then(response => {
}).then(dbRes => { if (response.length == 0) {
if (dbRes.status == 404) { rres.status(404).send(`etyd.cc: URL "${userRequest}" was not found`)
rres.sendStatus(404)
} else { } else {
dbRes.json().then(dbRes => { rres.redirect(response[0].content)
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)
}
})
} }
}).catch(fetchError => { }).catch(dbError => {
logRequest(rres,rreq,500,fetchError) logRequest(rres,rreq,500,dbError)
rres.sendStatus(500) 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) { checkTokenNew(rreq,"etyd").then(authRes => {
// rres.sendStatus(400) if (authRes.result === false) {
// } else { rres.sendStatus(401) // Token not provided or invalid for this action
// checkToken(rreq.get("Authorization"),"etyd").then(authRes => { } else {
// if (authRes === false) { db`delete from etyd where url = ${rreq.path.replace("/api/etyd/","")} and owner = ${authRes.ownerId}`.then(dbRes => {
// rres.sendStatus(401) if (dbRes.count === 1) {
// } else if (authRes === true) { // Authorization successful 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)
})
}
})
// 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) { app.post("/api/etyd*", (rreq,rres) => {
// 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)
// })
// }) 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.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 export {app} // export routes to be imported by index for execution

View File

@@ -1,7 +1,5 @@
import { app, globalConfig, fs, globalVersion } from "../index.js" // Get globals from index import { app, globalConfig, fs, globalVersion } from "../index.js" // Get globals from index
import { execSync } from 'child_process' import { parsePosts } from "../liberals/directoryparsing.js"
import { checkToken } from "../liberals/auth.js"
import { logRequest } from "../liberals/logging.js"
import { marked } from "marked" import { marked } from "marked"
var timeSinceLastQuery = Date.now()-10000 var timeSinceLastQuery = Date.now()-10000
@@ -12,11 +10,15 @@ app.get("/", (rreq, rres) => {
rres.send(cachedResult) rres.send(cachedResult)
} else { } else {
let indexFile = fs.readFileSync(process.cwd()+"/website/templates/newindextemplate.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>`) cachedResult = indexFile.replace("<!--SSR_BLOGPOSTS-->",parsePosts()).replace("<!--SSR_APIVERSION-->",`<sup>API Version ${globalVersion}</sup>`)
rres.send(cachedResult) rres.send(cachedResult)
} }
}) })
// app.get("/", (rreq, rres) => {
// rres.sendFile(process.cwd()+"/website/templates/construction.html")
// })
app.get("/static/*", (rreq,rres) => { app.get("/static/*", (rreq,rres) => {
rres.sendFile(process.cwd()+"/website/static/"+rreq.url.replace("/static/","")) rres.sendFile(process.cwd()+"/website/static/"+rreq.url.replace("/static/",""))
}) })
@@ -39,28 +41,4 @@ 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 = ""
for (let x in files) {
if (files[x].endsWith(".html") === false && files[x].endsWith(".md") === false ) { break } // If file/dir is not .html or .md then ignore
let date = files[x].split("-")[0]
if (date < 10000000 || date > 99999999) { break } // If date does not fit ISO8601 format then ignore
date = date.replace(/.{2}/g,"$&-").replace("-","").slice(0,-1) // Insert a dash every 2 characters, remove the first dash, remove the last character
let name = files[x].slice(9).replace(/-/g," ").replace(".html","").replace(".md","") // Strip Date, replace seperator with space & remove file extension
result = `<li>${date} <a href="/posts/${files[x]}">${name}</a></li>`+result
}
return result
}
export {app} export {app}

48
routes/helpdesk.js Normal file
View File

@@ -0,0 +1,48 @@
import { app, fs } from "../index.js"
import { parseKbas } from "../liberals/directoryparsing.js"
import { marked } from "marked"
app.get("/helpdesk", (rreq, rres) => {
rres.sendFile(process.cwd()+"/website/helpdesk/templates/landing.html")
})
app.get("/helpdesk/articles", (rreq, rres) => {
let file = fs.readFileSync(process.cwd() + "/website/helpdesk/templates/article.html", "utf-8")
file = file.replace("<!--SSR_REPLACE_URL-->", `https://enstrayed.com${rreq.url}`)
file = file.replaceAll("<!--SSR_REPLACE_TITLE-->", "Knowledgebase")
file = file.replace("<!--SSR_REPLACE_BODY-->", parseKbas())
rres.send(file)
})
app.get("/helpdesk/articles/*", (rreq, rres) => {
if (rreq.url.endsWith(".md")) {
let file = fs.readFileSync(process.cwd() + "/website/helpdesk/templates/article.html", "utf-8")
file = file.replace("<!--SSR_REPLACE_URL-->", `https://enstrayed.com${rreq.url}`)
file = file.replaceAll("<!--SSR_REPLACE_TITLE-->", rreq.url.replace("/helpdesk/articles/", "").replace(/(-|_)/g, " ").replace(".md", ""))
file = file.replace("<!--SSR_REPLACE_BODY-->", marked.parse(fs.readFileSync(process.cwd() + "/website/helpdesk/kbas/" + rreq.url.replace("/helpdesk/articles/", ""), "utf-8")))
rres.send(file)
} else {
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) => {
fs.readFile(process.cwd()+"/website/helpdesk/forms/"+rreq.url.replace("/api/helpdesk/forms/","")+".json","utf-8", (error, data) => {
if (error) {
rres.status(400).send("Unable to retrieve requested form")
} else {
rres.type('json').send(data)
}
})
})
app.get("/helpdesk/static/*", (rreq,rres) => {
rres.sendFile(process.cwd()+"/website/helpdesk/static/"+rreq.url.replace("/helpdesk/static/",""))
})
export { app }

View File

@@ -1,6 +1,6 @@
import { app } from "../index.js" import { app } from "../index.js"
app.get("/api/ip", (rreq,rres) => { app.get("/ip", (rreq,rres) => {
let jsonResponse = { let jsonResponse = {
"IP": rreq.get("cf-connecting-ip") || rreq.ip, "IP": rreq.get("cf-connecting-ip") || rreq.ip,
"Country": rreq.get("cf-ipcountry") || "not_cloudflare", "Country": rreq.get("cf-ipcountry") || "not_cloudflare",

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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: 416 B

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff5a36" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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="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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 506 B

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff5a36" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 698 B

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff5a36" viewBox="2.5 2 11 11"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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="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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 489 B

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff5a36" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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="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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 431 B

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" viewBox="0 0 16 16">
<path d="m6.08.234.179.727a7.3 7.3 0 0 0-2.01.832l-.383-.643A7.9 7.9 0 0 1 6.079.234zm3.84 0L9.742.96a7.3 7.3 0 0 1 2.01.832l.388-.643A8 8 0 0 0 9.92.234m-8.77 3.63a8 8 0 0 0-.916 2.215l.727.18a7.3 7.3 0 0 1 .832-2.01l-.643-.386zM.75 8a7 7 0 0 1 .081-1.086L.091 6.8a8 8 0 0 0 0 2.398l.74-.112A7 7 0 0 1 .75 8m11.384 6.848-.384-.64a7.2 7.2 0 0 1-2.007.831l.18.728a8 8 0 0 0 2.211-.919M15.251 8q0 .547-.082 1.086l.74.112a8 8 0 0 0 0-2.398l-.74.114q.082.54.082 1.086m.516 1.918-.728-.18a7.3 7.3 0 0 1-.832 2.012l.643.387a8 8 0 0 0 .917-2.219m-6.68 5.25c-.72.11-1.453.11-2.173 0l-.112.742a8 8 0 0 0 2.396 0l-.112-.741zm4.75-2.868a7.2 7.2 0 0 1-1.537 1.534l.446.605a8 8 0 0 0 1.695-1.689zM12.3 2.163c.587.432 1.105.95 1.537 1.537l.604-.45a8 8 0 0 0-1.69-1.691zM2.163 3.7A7.2 7.2 0 0 1 3.7 2.163l-.45-.604a8 8 0 0 0-1.691 1.69l.604.45zm12.688.163-.644.387c.377.623.658 1.3.832 2.007l.728-.18a8 8 0 0 0-.916-2.214M6.913.831a7.3 7.3 0 0 1 2.172 0l.112-.74a8 8 0 0 0-2.396 0zM2.547 14.64 1 15l.36-1.549-.729-.17-.361 1.548a.75.75 0 0 0 .9.902l1.548-.357zM.786 12.612l.732.168.25-1.073A7.2 7.2 0 0 1 .96 9.74l-.727.18a8 8 0 0 0 .736 1.902l-.184.79zm3.5 1.623-1.073.25.17.731.79-.184c.6.327 1.239.574 1.902.737l.18-.728a7.2 7.2 0 0 1-1.962-.811zM8 1.5a6.5 6.5 0 0 0-6.498 6.502 6.5 6.5 0 0 0 .998 3.455l-.625 2.668L4.54 13.5a6.502 6.502 0 0 0 6.93-11A6.5 6.5 0 0 0 8 1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#ff5a36" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="<!--DYN_REPLACECOLOR-->" 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"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 559 B

After

Width:  |  Height:  |  Size: 575 B

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,12 @@
{
"form": {
"name": {
"type": "text",
"content": "What is your name?"
},
"message": {
"type": "bigtext",
"content": "Please enter your message below."
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
{
"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"
}

View File

@@ -0,0 +1,11 @@
# Change your ECLS password
1. Login to your ECLS account at https://login.enstrayed.com
2. Select the settings icon in the top right of your screen <br>
![Screenshot showing where the settings icon is on the screen](https://i.enstrayed.com/20250714-555ab544.jpg)
3. Enter your new password twice and press 'Save' to change your password <br>
![Screenshot showing the password change screen](https://i.enstrayed.com/20250714-55e8b1a8.jpg)
## I can't login to my ECLS account
[Click here for information about how to reset your ECLS password](https://enstrayed.com/helpdesk/articles/ECLS-Reset_Password.md)

View File

@@ -0,0 +1,9 @@
# Reset your ECLS Password
**Important!** If you use Discord to sign into ECLS, you should change your password through the user interface instead of using account recovery.
You can visit [this page](https://login.enstrayed.com/if/flow/recovery/) to have a password reset link sent to the email associated with your ECLS account.
**Please make sure to check your spam folder.**
If you are unable to access that email address, [open a ticket](/helpdesk/ticket/new) with the topic "ECLS Account Recovery" instead.

View File

@@ -0,0 +1,10 @@
# What is EOES?
EOES, short for Enstrayed Orenco Email System, is the mail server that handles all mail for enstrayed.com.
## Why did a message from @enstrayed.com end up in spam?
The short and oversimplified answer is that Email services provided by large tech coporations, such as Google (Gmail/Workspace) and Microsoft (Outlook/Exchange), attempt to fight spam by turning Email into an exclusive club. Only companies that pay them, or have enough money to pay people to negotiate with them, are allowed to freely send Emails to them.
The more nuanced answer is that they pretty much don't have a choice. Email is a very old standard, with its protocols being first introduced in the 1980s. There have been revisions and improvements since then, however these can only help so much. Because of this, fighting spam is a cat and mouse game, even moreso at the scale companies like Google and Microsoft deal with.

View File

@@ -0,0 +1,7 @@
# Logging into Jellyfin
Jellyfin uses your ECLS username and password to login.
* If you have two-factor authentication enabled, you will not need to append a code to the password field to login.
* If you have not previously used Jellyfin, you will need to login once for your account to be automatically created.
* For devices like TVs, it's recommended to use Quick Connect to login instead. You can access Quick Connect in Jellyfin by selecting the profile icon in the top right of your screen and selecting 'Quick Connect' from the list.

View File

@@ -0,0 +1,121 @@
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;
padding: 1em 2em;
}
.headerbarTitle {
font-size: xx-large;
color: black;
text-decoration: none;
}
.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;
}
.helpdeskScopeOnly {
display: none;
}
.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);
}
#formarea > div > input {
width: 50%;
}
#formarea > div > textarea {
width: 100%;
}
/* Article Specific */
.articlecontent {
background-color: #fff;
padding: 2em;
margin: 2em 0 2em;
max-width: 100ch;
}
.articlecontent > h1 {
margin: 0 0 0.5em;
}
.articlecontent > ol > li {
margin: 0 0 0.2em;
}
.articlecontent img {
max-width: 100%;
}

View File

@@ -0,0 +1,68 @@
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}`
if (jsonRes.scopes.includes("helpdesk")) {
let targetElements = document.getElementsByClassName("helpdeskScopeOnly")
// cant do a for loop since targetElements dynamically updates, this is easier but stupid
while (targetElements.length > 0) {
targetElements[0].classList.toggle("helpdeskScopeOnly")
}
}
} else {
globalLoggedIn = false
document.getElementById("loginButton").innerText = `Login`
}
})
})
})
function loginFunction() {
if (globalLoggedIn === true) {
fetch(`/api/auth/logout`).then(fetchRes => {
if (fetchRes.status === 200) {
location.reload()
} 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}`
if (jsonRes.scopes.includes("helpdesk")) {
let targetElements = document.getElementsByClassName("helpdeskScopeOnly")
while (targetElements.length > 0) {
targetElements[0].classList.toggle("helpdeskScopeOnly")
}
}
} else {
useGlobalDialog("Error", `You are not logged in. Please try logging in again.`)
}
clearInterval(loginWatcher);
})
})
}
}, 500);
}
}

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enstrayed Helpdesk - <!--SSR_REPLACE_TITLE--></title>
<link rel="stylesheet" href="/helpdesk/static/helpdesk.css">
<script src="/helpdesk/static/login.js"></script>
</head>
<body>
<div class="headerbar">
<a href="/helpdesk" class="headerbarTitle">Enstrayed Helpdesk</a>
<a href="/helpdesk/ticket/new">New Request</a>
<a href="/helpdesk/articles">Knowledgebase</a>
<div class="headerbarright">
<button id="loginButton" onclick="loginFunction()">Login</button>
</div>
</div>
<div class="maincontent">
<div class="articlecontent">
<a href="/helpdesk/articles">Return to Knowledgebase</a>
<!--SSR_REPLACE_BODY-->
</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,76 @@
<!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="/helpdesk/static/helpdesk.css">
<script src="/helpdesk/static/login.js"></script>
</head>
<body>
<div class="headerbar">
<a href="/helpdesk" class="headerbarTitle">Enstrayed Helpdesk</a>
<a href="/helpdesk/ticket/new">New Request</a>
<a href="/helpdesk/articles">Knowledgebase</a>
<a href="/helpdesk/ticket/list" class="helpdeskScopeOnly">Ticket List</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>
<p>If you are needing assistance, please reach out via a previously established<br>contact method or send an email to helpdesk (at) enstrayed (dot) com.</p>
<!-- <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="/dynamic/icon/arrow-right/366FFF" 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="/dynamic/icon/arrow-right/366FFF" 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="/dynamic/icon/external/366FFF" alt="">Click here to reset your ECLS password</a><br>
<a href=""><img src="/dynamic/icon/arrow-right/366FFF" 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="/dynamic/icon/arrow-right/366FFF" 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="/dynamic/icon/post/366FFF" alt="">Important ECLS FIDO2/Webauthn/Passkey Information</a>
</div>
<div>
<span>How do I login to Jellyfin?</span><br>
<a href=""><img src="/dynamic/icon/post/366FFF" alt="">Logging into Jellyfin</a>
</div>
<div>
<span>How do I change my ECLS password?</span><br>
<a href=""><img src="/dynamic/icon/post/366FFF" alt="">Change ECLS Password</a>
</div>
<div>
<span>How do I use federation features in Enstrayed Cloud?</span><br>
<a href=""><img src="/dynamic/icon/post/366FFF" 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,90 @@
<!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="/helpdesk/static/helpdesk.css">
<script src="/helpdesk/static/login.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('formSelection').value = 'none'
fetch(`/api/helpdesk/forms/manifest`).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)
}
})
})
document.getElementById('formSelection').addEventListener('change', function() {
document.getElementById("formarea").innerHTML = ""
fetch(`/api/helpdesk/forms/${this.value}`).then(fetchRes => {
fetchRes.json().then(jsonRes => {
for (x in jsonRes.form) {
let newElem
switch (jsonRes.form[x].type) {
case "text":
newElem = document.createElement('div')
newElem.innerHTML = `<label for="${"formarea_"+x}">${jsonRes.form[x].content}</label><br><input type="text" id="${"formarea_"+x}">`
document.getElementById("formarea").appendChild(newElem)
break;
case "bigtext":
newElem = document.createElement('div')
newElem.innerHTML = `<label for="${"formarea_"+x}">${jsonRes.form[x].content}</label><br><textarea id="${"formarea_"+x}" ></textarea>`
document.getElementById("formarea").appendChild(newElem)
break;
}
}
})
})
})
})
</script>
</head>
<body>
<div class="headerbar">
<a href="/helpdesk" class="headerbarTitle">Enstrayed Helpdesk</a>
<a>New Request</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">
<h1>This page is still a WORK IN PROGRESS and does not function.</h1>
<h2>New Request</h2>
<div>
<label for="formSelection">Please select a form: </label>
<select name="Form Selection" id="formSelection">
<option value="none" disabled selected>-- Choose From List --</option>
</select>
</div>
<hr>
<div id="formarea">
</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

@@ -1,2 +0,0 @@
# Knowledgebase Moved
As of 2025-01-11, all knowledgebase articles have been moved to [helpdesk.enstrayed.com](https://helpdesk.enstrayed.com).

View File

@@ -0,0 +1,57 @@
# Setting up the UniFi Network Controller on an Orange Pi Zero 3
This page covers the steps I went through to setup the UniFi Network controller on an Orange Pi Zero 3. For no reason obvious to me, this process was overcomplicated and very annoying.
# 2025-08 Update
This article is somewhat out of date now as Ubiquiti has [introduced UniFi OS](https://blog.ui.com/article/introducing-unifi-os-server) which changes how the Network controller is deployed. I have personally switched over to it and recommend it over all of this mess.
## Background / The Problem
I purchased the Orange Pi Zero 3 because it was cheap and I wanted to move my UniFi controller out of the Docker container on my main server. The idea was to create a DIY UniFi CloudKey, hence I called mine `shitty-cloudkey`. On the surface, this is rather trivial; Ubiquiti's apt repository includes `arm64` packages and the UniFi controller itself is a Java application, making it rather portable.
The problem is that the UniFi controller depends on MongoDB for its database, and [MongoDB's system requirements](https://www.mongodb.com/docs/manual/administration/production-notes/#arm64) require the ARM V8.4-A microarchitecture at minimum (in english: Cortex A73 cores or later). The Zero 3 has Cortex A53 cores, which do not have the instruction sets required. I never really investigated the reasoning for this, but given that this makes Mongo's published builds incompatible with the Raspberry Pi 4, I find it rather strange, since that doesn't really strike me as an especially old SBC?
## Part 1: Installing Armbian
I'll be honest, I normally don't mess around with SBCs so had no idea what the go-to distro was for RasPi clones. After what was probably less than 30 seconds of searching, I decided to use Armbian since it did exactly what I needed: Debian 12 ready to go.
1. the boring shit
* `winget install balena.etcher`
1. Download https://dl.armbian.com/orangepizero3/Bookworm_current_minimal
2. select the file, select your sd card, press flash, blah blah blah
2. the slightly less boring shit
1. plug in the SD card, ethernet & power, wait for it to startup
2. go to your dhcp server and find whatever IP it grabbed for itself
3. `ssh root@<that ip address>`, default password is `1234` (Thank you armbian devs for letting you do the install process remotely)
4. Go through the setup process, now is a great time to do a `apt update && apt upgrade`, set a hostname and possibly static IP address
5. reboot for good measure
## Part 2: Installing MongoDB the dumb way
1. Download and install `libssl1.1`, I don't actually know if this is required anymore but it stops apt from complaining
1. `curl -OL https://archive.debian.org/debian/pool/main/o/openssl/libssl1.1_1.1.1n-0+deb10u3_arm64.deb`
2. `sudo dpkg -i libs<tab complete>`
2. Download and install some old version of MongoDB I found; Won't actually be using the binary from this, but it gives us the systemd unit file and resolves the dependency in apt
1. `curl -OL https://repo.mongodb.org/apt/ubuntu/dists/bionic/mongodb-org/4.4/multiverse/binary-arm64/mongodb-org-server_4.4.29_arm64.deb`
2. `sudo dpkg -i mong<tab complete>`
3. Download a `mongod` binary that actually works from a [MongoDB employee's personal GitHub](https://github.com/themattman/mongodb-raspberrypi-binaries) yes really
* You may want to check for a more up to date build but w/e, select the one marked `pi4`
1. `curl -OL https://github.com/themattman/mongodb-raspberrypi-binaries/releases/download/r7.0.14-rpi-unofficial/mongodb.ce.pi4.r7.0.14.tar.gz`
2. `tar xvf mong<tab complete>`
3. `sudo mv mongod /usr/bin/mongod`
* Side note that I discovered later: UniFi manages the mongod process directly so you can keep the systemd service disabled.
## Part 3: Installing UniFi Network
This next section is loosly modified from Ubiquiti's official setup instructions: https://help.ui.com/hc/en-us/articles/220066768-Updating-and-Installing-Self-Hosted-UniFi-Network-Servers-Linux
1. `sudo apt-get update && sudo apt-get install ca-certificates apt-transport-https`
2. `echo 'deb [ arch=amd64,arm64 ] https://www.ui.com/downloads/unifi/debian stable ubiquiti' | sudo tee /etc/apt/sources.list.d/100-ubnt-unifi.list`
3. `curl -L -o /etc/apt/trusted.gpg.d/unifi-repo.gpg https://dl.ui.com/unifi/unifi-repo.gpg`
4. `sudo apt update`
5. `sudo apt install unifi`
## The End
UniFi should be up and running by this point, if it isn't then bash it with `sudo systemctl restart unifi` until it shows something at `https://<ip here>:8443`. If that still doesnt work, `sudo cat /usr/lib/unifi/logs/server.log` may provide a clue, or maybe it wont. FEW MORE THINGS:
* If you are migrating from another UniFi network install, FOR THE LOVE OF GOD DO NOT USE THE RESTORE BUTTON DURING THE INITIAL SETUP. Go through the setup process, *set your password*, and then go into Settings > System > Backups and use the restore button there.
* If you, like me, did the above and found out that everything breaks, you can factory reset it by changing `is_default=` to `true` in `/var/lib/unifi/system.properties`. Don't forget to change it back after you run through the setup again.
* I shat out this article at 1 AM so basically did no proof-reading or fact checking so the section about MongoDB's system requirements is probably wrong, LOL.
* Armbian has a lot of warnings about using community editions and I got a rather loud warning during my install saying that I was using an automated build that wasn't for production use. It wasn't marked as such on the website and frankly I don't care. I have to assume this is supposed to be normie-proofing but it, like this entire ordeal, strikes me as bizarre. Recommendation: make sure to take backups somewhat often.

View File

@@ -16,22 +16,14 @@
<body> <body>
<a href="/">Return to enstrayed.com</a> <a href="/">Return to enstrayed.com</a>
<h1>API Documentation</h1> <h1>API Documentation</h1>
<p>This page was last updated 2025-04-26.</p> <p>This page was last updated 2025-12-15.</p>
<p>Source Code & Issue Tracker: <a href="https://github.com/enstrayed/enstrayedapi">github.com/enstrayed/enstrayedapi</a></p>
<hr> <hr>
<h2>Attention: This page is overdue for a rewrite and the information currently presented may not be accurate.</h2>
<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"> <div class="inlineheader">
<h2>/api/nowplaying</h2> <h2>/api/nowplaying</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/nowplaying.js">nowplaying.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/nowplaying.js">nowplaying.js</a>
<div> <div>
<span>GET</span> <span>GET</span>
</div> </div>
@@ -41,7 +33,7 @@
<div class="inlineheader"> <div class="inlineheader">
<h2>/api/nowplayingbeta</h2> <h2>/api/nowplayingbeta</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/nowplaying.js">nowplaying.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/nowplaying.js">nowplaying.js</a>
<div> <div>
<span>GET</span> <span>GET</span>
</div> </div>
@@ -51,7 +43,7 @@
<div class="inlineheader"> <div class="inlineheader">
<h2>/api/etyd/*</h2> <h2>/api/etyd/*</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/etyd.js">etyd.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/etyd.js">etyd.js</a>
<div> <div>
<span>GET</span> <span>GET</span>
<span>POST</span> <span>POST</span>
@@ -72,7 +64,7 @@
<div class="inlineheader"> <div class="inlineheader">
<h2>/api/sendemail</h2> <h2>/api/sendemail</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/mailjet.js#L3">mailjet.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/email.js">email.js</a>
<div> <div>
<span>POST</span> <span>POST</span>
</div> </div>
@@ -92,8 +84,8 @@
}</code></pre> }</code></pre>
<div class="inlineheader"> <div class="inlineheader">
<h2>/api/ip</h2> <h2>/ip</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/ip.js">ip.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/ip.js">ip.js</a>
<div><span>GET</span></div> <div><span>GET</span></div>
</div> </div>
@@ -101,10 +93,25 @@
<div class="inlineheader"> <div class="inlineheader">
<h2>/api/headers</h2> <h2>/api/headers</h2>
<a href="https://github.com/Enstrayed/enstrayedapi/blob/main/routes/ip.js">ip.js</a> <a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/ip.js">ip.js</a>
<div><span>GET</span></div> <div><span>GET</span></div>
</div> </div>
<p>Returns all request headers in JSON.</p> <p>Returns all request headers in JSON.</p>
<div class="inlineheader">
<h2>/api/auth/whoami</h2>
<a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/auth.js">auth.js</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>/api/auth/logout</h2>
<a href="https://git.enstrayed.com/enstrayed/enstrayedapi/src/branch/main/routes/auth.js">auth.js</a>
<div><span>GET</span></div>
</div>
<p>Invalidates the token used to access the endpoint.</p>
</body> </body>
</html> </html>

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

@@ -1,14 +1,25 @@
body { html {
margin: 2em 0 0 0; background-color: #0f0f0f;
font-family: 'Segoe UI Variable', sans-serif; background-image: url('/dynamic/background/main');
background-size: cover;
background-position-y: 50%;
height: 100%;
}
background-color: #282828; body {
color: #F1F1F1; margin: 0;
font-family: 'Segoe UI Variable Text', sans-serif;
height: 100%;
display: grid; display: grid;
place-items: center; place-items: center;
backdrop-filter: blur(10px);
color: #fff;
} }
.mainContent { .mainContent {
@@ -18,8 +29,16 @@ body {
gap: 2.5em; gap: 2.5em;
padding: 2em; padding: 2em;
background-color: #202020; background-color: #0f0f0f;
box-shadow: 0 0 1em 0 #202020; box-shadow: 0 0 1em 0 #0f0f0f;
position: relative;
margin-top: 96px;
}
.mainContent > div > h1, h2 {
margin: 0 0 0.5em;
} }
.linkColumn { .linkColumn {
@@ -44,11 +63,10 @@ body {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 0.2em; gap: 0.2em;
max-width: 14em;
} }
#nowplaying .nowPlayingLine2 { #nowplaying .nowPlayingLine2 {
font-size: 1.4em; font-size: 1.25em;
} }
.apiVersion { .apiVersion {
@@ -60,21 +78,35 @@ body {
} }
.blogPostsList > ul { .blogPostsList > ul {
list-style-image: url('/static/icons/post.svg'); list-style-image: url('/dynamic/icon/post/7CB342');
list-style-position: inside; list-style-position: inside;
padding: 0; padding: 0;
margin: 0; margin: 0;
gap: 0.5em; }
.blogPostsList > ul > li {
margin: 0.5em 0 0;
font-family: Consolas, monospace;
}
.blogPostsList > ul > li > a {
font-family: 'Segoe UI Variable', sans-serif;
} }
a, a:link { a, a:link {
color: #FF5A36; color: #7CB342;
} }
a:hover { a:hover {
color: #ff8266; color: #9CCC65;
} }
a > img { a > img {
margin-right: 0.2em; margin-right: 0.2em;
}
#miauMiau {
position: absolute;
top: -90px;
right: 9%;
} }

View File

@@ -6,7 +6,8 @@ html {
body { body {
margin: 2em 0 0 0; margin: 2em 0 0 0;
font-family: 'Segoe UI Variable', sans-serif; font-family: 'Segoe UI Variable Text', sans-serif;
line-height: 1.3;
background-color: #202020; background-color: #202020;
color: #F1F1F1; color: #F1F1F1;
@@ -40,4 +41,23 @@ a:hover {
padding: 0.2rem; padding: 0.2rem;
color: white; color: white;
background-color: #f06445; background-color: #f06445;
}
p {
line-height: 120%;
}
code {
background: #181818;
line-height: 130%;
padding: 0 0.1rem 0;
font-family: Consolas, monospace;
}
pre code {
display: block;
overflow-x: auto;
white-space: pre-wrap;
background: #181818;
padding: 1rem;
} }

View File

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

Before

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

@@ -0,0 +1,43 @@
<!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;
font-family: sans-serif;
}
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/99999999-API-Documentation.html">here</a>.
</p>
</div>
</body>
</html>

View File

@@ -17,58 +17,54 @@
<script> <script>
fetch("/api/nowplayingbeta?format=html").then(response => response.text()).then(response => {document.getElementById("nowplaying").innerHTML = response}) fetch("/api/nowplayingbeta?format=html").then(response => response.text()).then(response => {document.getElementById("nowplaying").innerHTML = response})
</script> </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> </head>
<body> <body>
<div class="mainContent"> <div class="mainContent">
<img src="/static/snow-leopard.svg" alt="Snow Leopard Icon" width="96" id="miauMiau">
<div> <div>
<img src="/static/orange-globe.svg" alt="Globe Icon" width="64"> <!-- <img src="/static/orange-globe.svg" alt="Globe Icon" width="64"> -->
<h1>Welcome to Enstrayed.com</h1> <h1>Welcome to Enstrayed.com</h1>
<div class="linkColumn"> <div class="linkColumn">
<div> <div>
<span>I received an Email from @enstrayed.com</span><br> <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> <a href="/helpdesk/articles/EOES-What_is_EOES.md"><img src="/dynamic/icon/external/7CB342" alt="">Click here for information about EOES</a>
</div> </div>
<div> <div>
<span>I need help with an Enstrayed.com web service</span><br> <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> <a href="/helpdesk"><img src="/dynamic/icon/external/7CB342" alt="">Click here to visit the Helpdesk</a>
</div> </div>
<div> <div>
<a href="/urltoolbox"><img src="/static/icons/link.svg" alt="">URL Toolbox</a> <span>I'd like to get in contact</span><br>
<a href="https://twitter.com/enstrayed"><img src="/dynamic/icon/twitter/7CB342" alt="">@Enstrayed on Twitter</a>
</div> </div>
<!-- <div>
<a href="/urltoolbox"><img src="/dynamic/icon/link/7CB342" alt="">URL Toolbox</a>
</div> -->
<div> <div>
<a href="/posts/20240409-API-Documentation.html"><img src="/static/icons/link.svg" alt="">API Documentation</a> <a href="https://files.enstrayed.com/public/"><img src="/dynamic/icon/link/7CB342" alt="">Downloads</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>
</div> </div>
<div> <div>
<img src="/static/snow-leopard.svg" alt="Snow Leopard Icon" width="64"> <!-- <img src="/static/snow-leopard.svg" alt="Snow Leopard Icon" width="64"> -->
<h1>@Enstrayed</h1> <h1 id="blogPostsHeader">/posts/</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 id="nowplaying"></div>
<div class="blogPostsList"> <div class="blogPostsList">
<h2 id="blogPostsHeader" onclick="rerollBlogPostsHeaderText()">Notes</h2>
<ul> <ul>
<!--SSR_BLOGPOSTS--> <!--SSR_BLOGPOSTS-->
</ul> </ul>
</div> </div>
<div class="apiVersion">
<!--SSR_APIVERSION-->
</div>
</div> </div>
</div> </div>
<div id="nowplaying"></div>
</body> </body>
</html> </html>