Merge pull request #4 from Enstrayed/main

Merge changes into new auth branch
This commit was merged in pull request #4.
This commit is contained in:
Nathan Ritchie
2024-07-06 10:56:53 -07:00
committed by GitHub
12 changed files with 204 additions and 205 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ node_modules/
config.json config.json
bun.lockb bun.lockb
GITVERSION GITVERSION
todo.txt
proto.js

View File

@@ -1,9 +0,0 @@
FROM node:20
WORKDIR /app
RUN git clone https://github.com/enstrayed/enstrayedapi .
RUN git config --global --add safe.directory /app
RUN git show --oneline -s >> GITVERSION
RUN npm install
ENTRYPOINT [ "node", "index.js" ]

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# Enstrayed API
This repository contains the code for my personal web API written in JavaScript using the Express framework.
## Documentation
This file contains documentation relevant for development and deployment, but not necessarily usage. Information for all endpoints is available [on my website](https://enstrayed.com/posts/20240409-API-Documentation.html).
## Issues
If you would like to report a bug or security issue, please open a GitHub issue. If you are the operator of a service this application accesses, use the contact information provided during registration with your service to contact me directly.
## Configuration
On startup, this application will look for two files. If either cannot be read, it will exit with an error code.
1. `config.json` contains settings required for operation and API keys used for calling external services.
2. `GITVERSION` contains the commit that was cloned when the container was built.
<details> <summary>Configuration Example</summary>
* `couchdb.host`: Hostname/IP address and port of a CouchDB server.
* `couchdb.authorization`: Username & password used to access the CouchDB server, in HTTP Basic authentication format, e.g. `username:password`.
* `blog.postsDirectory`: Directory that will be parsed when calling /blogposts. If running in Docker this directory will need to be mounted to the container.
* `blog.postsDirUrl`: Location of the posts directory on the web server.
* `nowplaying.*.target`: Set to the Last.fm/Jellyfin username to query for playback information.
```json
{
"startup": {
"apiPort": 8081,
"routesDir": "./routes"
},
"couchdb": {
"host": "hazeldale:5984",
"authorization": ""
},
"mailjet": {
"apiKey": "",
"senderAddress": "apinotifications@enstrayed.com",
"senderName": "API Notifications",
"authKeysDoc": "mailjet"
},
"blog": {
"postsDirectory": "C:/Users/natha/Downloads/proto/posts",
"postsDirUrl": "/posts"
},
"nowplaying": {
"lastfm": {
"apiKey": "",
"target": "enstrayed"
},
"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:20
WORKDIR /app
RUN git clone https://github.com/enstrayed/enstrayedapi .
RUN git config --global --add safe.directory /app
RUN git show --oneline -s >> GITVERSION
RUN npm install
ENTRYPOINT [ "node", "index.js" ]
```
<details> <summary>Docker Compose File</summary>
```yaml
---
services:
enstrayedapi:
build:
context: .
image: enstrayedapi
container_name: enstrayedapi
restart: unless-stopped
volumes:
- ./config.json:/app/config.json
```
</details>
## License
If for whatever reason you want to, you are free to adapt this code for your own projects or as reference. However, this software is provided as-is with no warranty or agreement to support it.

View File

@@ -1,36 +0,0 @@
{
"startup": {
"apiPort": 8081,
"routesDir": "./routes"
},
"couchdb": {
"host": "hazeldale:5984",
"authorization": ""
},
"cider": {
"targetHosts": ["localhost:10769"],
"authKeysDoc": "cider"
},
"mailjet": {
"apiKey": "",
"senderAddress": "apinotifications@enstrayed.com",
"senderName": "API Notifications",
"authKeysDoc": "mailjet"
},
"etyd": {
"randomHexLength": 6,
"authKeyInDb": "apiAuthKeys.etyd"
},
"blog": {
"postsDirectory": "C:/Users/natha/Downloads/proto/posts",
"postsDirUrl": "/posts"
}
}

View File

@@ -1,101 +0,0 @@
const { app, db, globalConfig } = require("../index.js") // Get globals from index
var timeSinceLastCiderQuery = Date.now()-2000;
var currentListening = {}
var currentListeningHtml = ""
app.get("/cider", (rreq,rres) => {
rres.send("<span>Cider endpoint is temporarily unavailable.</span>")
})
// app.get("/cider", (rreq,rres) => { // GET current listening from target
// if (Date.now() < timeSinceLastCiderQuery+2000) {
// rres.send(currentListening); // If it has been <2 seconds since the last request, return the cached result.
// } else {
// getCurrentListening(globalConfig.cider.targetHosts[0],"json").then(funcRes => {
// if (funcRes == 1) {
// rres.sendStatus(503) // If there was a problem getting the upstream JSON, return 503 Service Unavailable.
// } else {
// // Required (I think?) because of CORS.
// currentListening = funcRes
// rres.send(funcRes)
// }
// })
// }
// })
// app.post("/cider", (rreq,rres) => { // POST stop listening on cider target
// fetch(`http://${globalConfig.couchdb.host}/apiauthkeys/${globalConfig.cider.authKeysDoc}`, {
// headers: {
// "Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`
// }
// }).then(dbRes => dbRes.json()).then(dbRes => {
// if (dbRes.status == 404) { // If document containing cider auth keys does not exist
// console.log(`ERROR: Could not find apiauthkeys/${globalConfig.mailjet.authKeysDoc}`)
// rres.sendStatus(500) // Refuse request
// } else {
// if (dbRes["content"][rreq.get("Authorization").split("_")[0]] === rreq.get("Authorization").split("_")[1]) {
// fetch(`http://${globalConfig.cider.targetHosts[0]}/stop`).then(fres => { // send GET /stop to cider target
// if (fres.status == 204) {
// console.log(`${rreq.get("cf-connecting-ip")} POST /cider returned 200 KEY:${rreq.get("Authorization")}`)
// rres.sendStatus(200) // if that works then 200
// } else {
// rres.sendStatus(500) // otherwise lol
// }
// }).catch(ferror => {
// rres.sendStatus(503) // and if a problem happens its probably cause cider target is unavailable
// })
// } else {
// console.log(`${rreq.get("cf-connecting-ip")} POST /cider returned 401`) // log ip of unauthorized requests
// rres.sendStatus(401) // received auth key was not in database
// }
// }
// })
// })
// 2024-04-10: Retrieves currentPlayingSong JSON from specified Cider host and
// returns JSON/HTML containing the useful bits if successful, returning 1 if not.
// async function getCurrentListening(host,contentType) { // Host should be hostname/ip & port only.
// timeSinceLastCiderQuery = Date.now(); // Save last time function was run, used to indicate when the cache needs refreshed.
// return await fetch(`http://${host}/currentPlayingSong`).then(fetchRes => {
// if (fetchRes.status == 502) {
// return 1 // If the upstream server returns 502 (Bad Gateway) then internally return 1, indicating error.
// } else {
// return fetchRes.json().then(jsonRes => {
// if (jsonRes.info.name == undefined) {
// return 1 // If Cider is running but not playing a song this check prevents an undefined variable error.
// } else {
// if (contentType === "json") {
// return {
// "songName": jsonRes.info.name,
// "artistName": jsonRes.info.artistName,
// "albumName": jsonRes.info.albumName,
// "songLinkUrl": jsonRes.info.url.songLink,
// "endtimeEpochInMs": jsonRes.info.endTime,
// "artworkUrl": jsonRes.info.artwork.url.replace("{w}", jsonRes.info.artwork.width).replace("{h}", jsonRes.info.artwork.height)
// }
// } else if (contentType === "html") {
// return `<img src="${jsonRes.info.artwork.url.replace("{w}", jsonRes.info.artwork.width).replace("{h}", jsonRes.info.artwork.height)}" alt="Album Art" id="nowplaying-albumart" style="width: 10em;"> <div class="textlist"><p>I'm listening to</p><h3>${`${jsonRes.info.name} by ${jsonRes.info.artistName}`}</h3><p>from ${jsonRes.info.albumName}</p><a href="${jsonRes.info.url.songLink}" class="noindent">song.link</a></div>`
// } else {
// return 1
// }
// }
// })
// }
// }).catch(fetchError => {
// console.error("Error fetch()ing upstream Cider host: "+fetchError)
// return 1 // If something else happens then log it and return 1, indicating error.
// })
// }
module.exports = {app} // export routes to be imported by index for execution

View File

@@ -1,10 +0,0 @@
---
services:
enstrayedapi:
build:
context: .
image: enstrayedapi
container_name: enstrayedapi
restart: unless-stopped
volumes:
- ./config.json:/app/config.json

View File

@@ -1,11 +1,7 @@
const { globalConfig } = require("../index.js") const { globalConfig } = require("../index.js")
async function checkAuthorization(documentToUse,keyToCheck) { async function checkAuthorization(documentToUse,keyToCheck) {
return await fetch(`http://${globalConfig.couchdb.host}/apiauthkeys/${documentToUse}`, { return await fetch(`${globalConfig.couchdbHost}/apiauthkeys/${documentToUse}`).then(fetchRes => {
headers: {
"Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`
}
}).then(fetchRes => {
if (fetchRes.status === 404) { // If document doesnt exist fail gracefully if (fetchRes.status === 404) { // If document doesnt exist fail gracefully

26
liberals/nowplaying.js Normal file
View File

@@ -0,0 +1,26 @@
const { globalConfig } = require("../index.js")
async function queryLastfm() {
return await fetch(`https://ws.audioscrobbler.com/2.0/?format=json&method=user.getrecenttracks&limit=1&api_key=${globalConfig.nowplaying.lastfm.apiKey}&user=${globalConfig.nowplaying.lastfm.target}`).then(response => response.json()).then(response => {
if (response["recenttracks"] == undefined) {
return 1
} else {
if (response.recenttracks.track[0]["@attr"] == undefined) {
return 1
} else {
return {
"json": {
"songName": response.recenttracks.track[0].name,
"artistName": response.recenttracks.track[0].artist["#text"],
"albumName": response.recenttracks.track[0].album["#text"],
"artUrl": response.recenttracks.track[0].image[3]["#text"],
"link": response.recenttracks.track[0].url
},
"html": `<img src="${response.recenttracks.track[0].image[3]["#text"]}" alt="Album Art" style="width: 10em;"> <div class="textlist"> <p>I'm listening to</p> <h3>${response.recenttracks.track[0].name} by ${response.recenttracks.track[0].artist["#text"]}</h3> <p>from ${response.recenttracks.track[0].album["#text"]}</p> <a href="${response.recenttracks.track[0].url}" class="noindent">View on Last.fm</a></div>`
}
}
}
})
}
module.exports = { queryLastfm }

56
package-lock.json generated
View File

@@ -9,22 +9,43 @@
"version": "1.0.0", "version": "1.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2"
"typescript": "^5.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.0.12",
"@types/node": "^20.12.3" "@types/node": "^20.12.3"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/bun": {
"version": "20.12.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.3.tgz", "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.5.tgz",
"integrity": "sha512-sD+ia2ubTeWrOu+YMF+MTAB7E+O7qsMqAbMfW7DG3K1URwhZ5hN1pLlRVGbf4wDFzSfikL05M17EyorS86jShw==", "integrity": "sha512-7RprVDMF+1o+EWSo7F1+iJpkfNz+Ikw9K//vwambcY+D1QHXfb9l7jWY1hSBfuFEkW9yFAhkMzP2uTi1pQXoqw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.1.14"
}
},
"node_modules/@types/node": {
"version": "20.12.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
"integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -65,6 +86,17 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/bun-types": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.14.tgz",
"integrity": "sha512-esfxOvECTkjEuUEHBOoOo590Qggf4b9cz5h29AOB2SKt3yZwG3LbAX4iIYwWZX7GnO7vaY5hIdcQygwN0xGdNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "~20.12.8",
"@types/ws": "~8.5.10"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -679,18 +711,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typescript": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",

View File

@@ -2,13 +2,10 @@
"dependencies": { "dependencies": {
"express": "^4.18.2" "express": "^4.18.2"
}, },
"name": "api", "name": "enstrayedapi",
"version": "1.0.0", "version": "1.0.0",
"description": "api.enstrayed.com", "description": "EnstrayedAPI",
"main": "index.js", "main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/enstrayed/enstrayedapi.git" "url": "git+https://github.com/enstrayed/enstrayedapi.git"
@@ -18,7 +15,6 @@
"bugs": { "bugs": {
"url": "https://github.com/enstrayed/enstrayedapi/issues" "url": "https://github.com/enstrayed/enstrayedapi/issues"
}, },
"homepage": "https://api.enstrayed.com",
"devDependencies": { "devDependencies": {
"@types/bun": "^1.0.12", "@types/bun": "^1.0.12",
"@types/node": "^20.12.3" "@types/node": "^20.12.3"

View File

@@ -2,11 +2,7 @@ const { app, globalConfig } = require("../index.js") // Get globals from index
const { checkToken } = require("../liberals/auth.js") const { checkToken } = require("../liberals/auth.js")
app.get("/etyd*", (rreq,rres) => { app.get("/etyd*", (rreq,rres) => {
fetch(`http://${globalConfig.couchdb.host}/etyd${rreq.path.replace("/etyd","")}`, { fetch(`${globalConfig.couchdbHost}/etyd${rreq.path.replace("/etyd","")}`).then(dbRes => {
headers: {
"Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`
}
}).then(dbRes => {
if (dbRes.status == 404) { if (dbRes.status == 404) {
rres.sendStatus(404) rres.sendStatus(404)
} else { } else {
@@ -35,21 +31,16 @@ app.delete("/etyd*", (rreq,rres) => {
rres.sendStatus(401) rres.sendStatus(401)
} else if (authRes === true) { // Authorization successful } else if (authRes === true) { // Authorization successful
fetch(`http://${globalConfig.couchdb.host}/etyd${rreq.path.replace("/etyd", "")}`, { fetch(`${globalConfig.couchdbHost}/etyd${rreq.path.replace("/etyd", "")}`).then(dbRes => {
headers: {
"Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`
}
}).then(dbRes => {
if (dbRes.status == 404) { if (dbRes.status == 404) {
rres.sendStatus(404) rres.sendStatus(404)
} else { } else {
dbRes.json().then(dbRes => { dbRes.json().then(dbRes => {
fetch(`http://${globalConfig.couchdb.host}/etyd${rreq.path.replace("/etyd", "")}`, { fetch(`${globalConfig.couchdbHost}/etyd${rreq.path.replace("/etyd", "")}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
"Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`,
"If-Match": dbRes["_rev"] // Using the If-Match header is easiest for deleting entries in couchdb "If-Match": dbRes["_rev"] // Using the If-Match header is easiest for deleting entries in couchdb
} }
}).then(fetchRes => { }).then(fetchRes => {
@@ -89,10 +80,7 @@ app.post("/etyd*", (rreq,rres) => {
if (rreq.body["url"] == undefined) { if (rreq.body["url"] == undefined) {
rres.sendStatus(400) rres.sendStatus(400)
} else { } else {
fetch(`http://${globalConfig.couchdb.host}/etyd${rreq.path.replace("/etyd", "")}`, { fetch(`${globalConfig.couchdbHost}/etyd${rreq.path.replace("/etyd", "")}`, {
headers: {
"Authorization": `Basic ${btoa(globalConfig.couchdb.authorization)}`
},
method: "PUT", method: "PUT",
body: JSON.stringify({ body: JSON.stringify({
"content": { "content": {

View File

@@ -1,11 +1,33 @@
const { app, globalConfig } = require("../index.js") const { app, globalConfig } = require("../index.js")
const { queryLastfm } = require("../liberals/nowplaying.js")
var timeSinceLastLastfmQuery = Date.now()-5000
var cachedLastfmResult = {}
const notPlayingAnythingPlaceholder = {
"json": {
"playing": false
},
"html": `<span>I'm not currently listening to anything.</span>`
}
app.get("/nowplaying", (rreq,rres) => { app.get("/nowplaying", (rreq,rres) => {
if (rreq.query.format === "html") {
rres.send("<span>The /nowplaying endpoint is currently under construction.</span>") if (Date.now() < timeSinceLastLastfmQuery+5000) {
rres.send(cachedLastfmResult[rreq.query.format] ?? cachedLastfmResult.json)
} else { } else {
rres.send({"message":"The /nowplaying endpoint is currently under construction."}) timeSinceLastLastfmQuery = Date.now()
queryLastfm().then(response => {
if (response == 1) {
cachedLastfmResult = notPlayingAnythingPlaceholder
} else {
cachedLastfmResult = response
} }
rres.send(cachedLastfmResult[rreq.query.format] ?? cachedLastfmResult.json)
})
}
}) })
module.exports = {app} module.exports = {app}