aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/directory.ts312
-rw-r--r--app/index.ts38
-rw-r--r--app/middlewares/blogs.ts17
-rw-r--r--app/middlewares/index.ts15
-rw-r--r--app/pages.ts87
-rw-r--r--app/routes/blog/router.ts19
-rw-r--r--app/routes/page/router.ts47
-rw-r--r--app/routes/special/router.ts79
-rw-r--r--app/routes/spotify/router.ts26
-rw-r--r--app/spotify/client.ts158
-rw-r--r--app/websocket/spotify.ts18
-rw-r--r--app/wikiparser.ts327
12 files changed, 361 insertions, 782 deletions
diff --git a/app/directory.ts b/app/directory.ts
deleted file mode 100644
index 77acacf..0000000
--- a/app/directory.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import * as parser from './wikiparser.js';
-import { readFileSync } from 'fs';
-import glob from 'glob';
-import { logger } from './logger.js'
-
-/**
- * Build a page.
- *
- * @param path standard name for page
- * @returns newly built page, or undefined
- */
-export function buildPage(directory: PageDirectory, page: Page) {
- const result = parser.parse(directory, page.raw);
- const title = result.metadata.displayTitle ?? page.standardName;
- const content = `${result.metadata.notitle ? '' : `<h1>${title}</h1>`}${result.html}`;
-
- page.html = content;
- page.buildTime = Date.now();
- page.metadata.includeInNavbar = result.metadata.primary ?? false;
- page.metadata.sortOrder = result.metadata.sortOrder ?? -1;
- page.metadata.showTitle = !result.metadata.notitle ?? true;
- page.metadata.displayTitle = title;
-}
-
-/**
- * Convert a page name to a standard name.
- * A standard name is the key used by the page directory.
- *
- * @param name non-standard name for a page
- */
-export function convertNameToStandard(name: string): string {
- name = name.replace(/[^a-z0-9:]/gi, '_').toLowerCase();
- if (!name.includes(':')) {
- name = `main:${name}`;
- }
- return name;
-}
-
-/**
- * Convert a standard name to a file path.
- *
- * @param name standard name for a page
- */
-export function convertStandardToFilePath(name: string): string {
- const [first, second] = name.replace('main:', '').split(':');
- const [title, subpage] = ((second) ? second : first).split('.')
- const namespace = (second) ? first : undefined
-
- return `${namespace ? `${namespace}/` : ''}${title}${subpage ? `.${subpage}` : ''}.wiki`
-}
-
-export class PageDirectory {
- pages: Record<string, Page>;
- primaryPages: Page[];
- pagePath: string;
- lastBuild: number;
-
- constructor(root: string) {
- this.lastBuild = 0;
- this.pages = {};
- this.pagePath = root;
-
- this.rebuild();
- }
-
- /**
- * Build this page directory.
- *
- * @returns whether the directory was built
- */
- rebuild(): boolean {
- if (this.lastBuild + parseInt(process.env.REBUILD_COOLDOWN_MIN, 10) * 60 * 1000 > Date.now()) {
- return false;
- }
- for (const page in this.pages) {
- delete this.pages[page];
- }
-
- const pages = glob.sync(`**/*.wiki`, { cwd: this.pagePath })
-
- // Load page content
- pages.forEach(page => {
- page = convertNameToStandard(page.replace('.wiki', '').replace('/', ':'));
- this.pages[page] = {
- standardName: page,
- raw: this.loadRaw(page),
- buildTime: 0,
- metadata: {
- dependencies: new Set(),
- dependents: new Set(),
- errors: []
- }
- }
- });
-
- const dependencyGraph: Record<string, string[]> = {};
-
- Object.keys(this.pages).forEach(name => dependencyGraph[name] = Array.from(parser.findDependencies(this.pages[name].raw)).map(e => convertNameToStandard(e)));
-
- // Revursive dependency graph traversal function
- function traverseGraph(dependents: string[], current: string, dependencies: string[], recursionCount: number, pages: Record<string, Page>) {
- if (recursionCount > parseInt(process.env.PARSER_MAX_RECURSION, 10)) {
- throw new RecursionError('max recursion reached');
- }
-
- dependencies?.forEach(e => {
- pages[current]?.metadata.dependencies.add(e)
- if (e !== current) {
- pages[e]?.metadata.dependents.add(current);
- }
- });
-
- dependencies?.forEach((dependency: string) => {
- if (dependencyGraph[dependency]?.length != 0) {
- dependents.forEach((dependent: string) => {
- if (dependencyGraph[dependency]?.includes(dependent)) {
- throw new DependencyError(`circular dependency between ${dependent} and ${dependency}`, [dependent, dependency]);
- }
- });
- traverseGraph([...dependents, dependency], dependency, dependencyGraph[dependency], recursionCount + 1, pages);
- }
- });
- }
-
- // Catch circular dependencies and build dependency tree
- Object.keys(dependencyGraph).forEach(name => {
- try {
- traverseGraph([name], name, dependencyGraph[name], 1, this.pages);
- } catch (e) {
- if (e instanceof RecursionError) {
- this.pages[name].metadata.errors.push({
- identifier: 'max-recursion-reached',
- message: `maximum dependency depth of ${process.env.PARSER_MAX_RECURSION} reached`
- })
- logger.warn(`max recursion for ${name} reached`)
- } else if (e instanceof DependencyError) {
- if (e.pages.includes(name)) {
- this.pages[name].metadata.errors.push({
- identifier: 'circular-dependency',
- message: e.message
- })
- logger.warn(`${e.pages[0]} has a circular dependency with ${e.pages[1]}`)
- } else {
- logger.warn(`transclusions on page ${name} may not resolve due to dependency errors in its dependency tree`)
- }
- } else {
- throw e;
- }
- }
- });
-
- function recursiveBulld(pages: Record<string, Page>, current: Page, directory: PageDirectory, buildPage: (directory: PageDirectory, page: Page) => void) {
- if (current.metadata.errors.length == 0) {
- current.metadata.dependencies.forEach(dependency => {
- if (pages[dependency].buildTime == 0) {
- recursiveBulld(pages, pages[dependency], directory, buildPage);
- }
- });
- buildPage(directory, current)
- }
- }
-
- // Build pages in order
- const primaryPages = [];
- Object.keys(this.pages).forEach(name => {
- recursiveBulld(this.pages, this.pages[name], this, buildPage);
-
- if (this.pages[name].metadata.includeInNavbar) {
- primaryPages.push(this.pages[name]);
- }
- });
-
- // Sort primary pages
- primaryPages.sort((a, b) => {
- return a.metadata.sortOrder - b.metadata.sortOrder;
- });
- this.primaryPages = primaryPages;
- this.lastBuild = Date.now();
- return true;
- }
-
- /**
- * Get whether a page exists with this name.
- *
- * @param name standard name for page
- * @returns whether the page exists
- */
- exists(name: string): boolean {
- return !!this.pages[convertNameToStandard(name)];
- }
-
- /**
- * Get a page.
- *
- * @param name standard name for page
- * @returns page
- */
- get(name: string): Page {
- name = convertNameToStandard(name);
- const page = this.pages[name];
- if (!page) {
- return undefined;
- }
-
- return page;
- }
-
- /**
- * Get the raw wikitext for a page.
- *
- * @param name standard name for page
- * @returns raw wikitext
- */
- getRaw(name: string): string {
- name = convertNameToStandard(name);
- return this.pages[name]?.raw;
- }
-
- /**
- * Purge (rebuild) a page.
- *
- * @param name standard name for page
- * @returns whether the page was rebuilt
- */
- purge(name: string): boolean {
- name = convertNameToStandard(name);
- const page = this.pages[name];
- if (page) {
- if (page.buildTime + parseInt(process.env.PURGE_COOLDOWN_MIN, 10) * 60 * 1000 > Date.now()) {
- return false;
- } else {
- // delete this.pages[name];
- // if (this.buildPage(name)) {
- // return true;
- // }
- }
- }
- return false;
- }
-
- /**
- * Get all pages.
- *
- * @returns all pages
- */
- getPages(): Record<string, Page> {
- return this.pages;
- }
-
- /**
- * Get primary pages.
- *
- * @param current
- * @returns
- */
- getPrimaryPages(): Page[] {
- return this.primaryPages;
- }
-
- private loadRaw(name: string): string {
- name = convertNameToStandard(name);
- let data: string;
- try {
- data = readFileSync(`${this.pagePath}/${convertStandardToFilePath(name)}`, 'utf-8');
- } catch {
- return undefined;
- }
- return data;
- }
-}
-
-export type Page = {
- html?: string;
- raw?: string;
- standardName: string;
- buildTime: number;
- metadata: PageMetadata;
-};
-
-export type PageMetadata = {
- displayTitle?: string;
- sortOrder?: number;
- showTitle?: boolean;
- includeInNavbar?: boolean;
- dependencies: Set<string>;
- dependents: Set<string>;
- errors: PageError[];
-};
-
-export type PageError = {
- identifier: string;
- message: string;
-}
-
-export class DependencyError extends Error {
- pages: string[]
-
- constructor(message: string, pages: string[]) {
- super(message);
- this.pages = pages;
-
- Object.setPrototypeOf(this, DependencyError.prototype);
- }
-}
-
-export class RecursionError extends Error {
- constructor(message: string) {
- super(message);
-
- Object.setPrototypeOf(this, RecursionError.prototype);
- }
-}
diff --git a/app/index.ts b/app/index.ts
index 8514380..3558d1f 100644
--- a/app/index.ts
+++ b/app/index.ts
@@ -1,46 +1,56 @@
-import { PageDirectory } from './directory.js';
import express from 'express';
import dotenv from 'dotenv-defaults';
import * as page from './routes/page/router.js';
-import * as special from './routes/special/router.js';
-import { navbar } from './middlewares/index.js'
+import * as blog from './routes/blog/router.js';
import { logger } from './logger.js'
+import { PageDirectory } from './pages.js';
+// import { SpotifyClient } from './spotify/client.js';
+// import { WebSocketServer } from 'ws';
+// import * as spotifyauth from './routes/spotify/router.js';
+// import * as spotifyWs from './websocket/spotify.js';
+
+// TODO: Figure out Spotify's tedious auth flow
dotenv.config()
const app = express();
-const directory = new PageDirectory(process.env.PAGES_DIR);
-
app.set('view engine', 'ejs');
app.set('views', 'views');
-app.use(express.static('static'));
-
-app.use((req, res, next) => {
- res.locals.directory = directory;
- next();
-});
+app.use(express.static('static', {
+ etag: true,
+ maxAge: '1d'
+}));
+app.use(blog.router);
app.use(page.router);
-app.use(special.router);
+// app.use(spotifyauth.router);
-app.use(navbar, (req, res) => {
+app.use((req, res) => {
res.render('error.ejs', {
code: '404',
- navbar: res.locals.navbarHtml
});
});
const server = app.listen(process.env.PORT, () => {
logger.info(`App listening on port ${process.env.PORT}`);
});
+// const websocketServer: WebSocketServer = spotifyWs.createWebsocketServer(server);
const exit = () => {
logger.info('Stopping server...');
+ // websocketServer.clients.forEach(client => {
+ // client.terminate();
+ // });
+ // websocketServer.close();
server.close(() => {
process.exit(0);
})
}
+PageDirectory.rebuild('pages');
+
+// SpotifyClient.initialise();
+
process.on('SIGINT', exit);
process.on('SIGTERM', exit);
diff --git a/app/middlewares/blogs.ts b/app/middlewares/blogs.ts
new file mode 100644
index 0000000..8fd07c6
--- /dev/null
+++ b/app/middlewares/blogs.ts
@@ -0,0 +1,17 @@
+import { PageDirectory } from "../pages.js";
+
+export const blogs = ((req, res, next) => {
+ let blogs = [];
+ for (const page of Object.values(PageDirectory.pages)) {
+ if (page.route.startsWith('blog/')) {
+ blogs.push(page);
+ }
+ }
+
+ blogs.sort((a, b) => {
+ return b.metadata.date.getTime() - a.metadata.date.getTime();
+ });
+
+ res.locals.blogs = blogs;
+ next();
+});
diff --git a/app/middlewares/index.ts b/app/middlewares/index.ts
index d07726a..81431ca 100644
--- a/app/middlewares/index.ts
+++ b/app/middlewares/index.ts
@@ -1,18 +1,11 @@
-export const navbar = ((req, res, next) => {
- let navbar = '';
- res.locals.directory.primaryPages.forEach(page => {
- navbar += `<div class="navbar-element"><a href="/${page.standardName}"${(req.params.page ?? '' )== page.standardName ? ' class="highlight"' : ''}>${page.metadata.displayTitle}</a></div>`;
- })
- res.locals.navbarHtml = navbar;
- next();
-});
+import { PageDirectory } from "../pages.js";
export const page = ((req, res, next) => {
- const path = req.params.page ?? 'index';
+ const path = req.originalUrl == "/" ? 'index' : req.originalUrl.substring(1);
res.locals.path = path;
- const page = res.locals.directory.get(path);
-
+ const page = PageDirectory.get(path);
+
if (!page) {
next();
return;
diff --git a/app/pages.ts b/app/pages.ts
new file mode 100644
index 0000000..3bef4a9
--- /dev/null
+++ b/app/pages.ts
@@ -0,0 +1,87 @@
+import { readFileSync } from 'fs';
+import glob from 'glob';
+import { logger } from './logger.js'
+import { marked } from 'marked';
+import matter from 'gray-matter';
+
+export function buildPage(page: Page) {
+ logger.info(`Building ${page.path}`);
+ try {
+ const result = matter(page.raw);
+ const metadata = result.data;
+ const html = marked.parse(result.content, { mangle: false, headerIds: false });
+
+ page.html = html;
+ page.metadata = metadata;
+ page.buildTime = Date.now();
+ } catch (e) {
+ logger.error(`Failed to build page ${page.path}: ${e.message}`);
+ }
+}
+
+export namespace PageDirectory {
+ export const pages: Record<string, Page> = {};
+ export let lastBuild: number;
+
+ export const rebuild = (pagePath: string): boolean => {
+ for (const page in pages) {
+ delete pages[page];
+ }
+
+ const localPages = glob.sync(`**/*.{md,html}`, { cwd: pagePath })
+
+ // Load page content
+ localPages.forEach(page => {
+ let route = page.replace(/\.[^.]*$/,'')
+ let name = /[^/]*$/.exec(route)[0];
+ let path = `${pagePath}/${page}`
+ let raw: string;
+ try {
+ raw = loadRaw(path);
+ } catch (e) {
+ logger.error(`Failed to read page ${path}: ${e.message}`);
+ return;
+ }
+
+ pages[route] = {
+ route: route,
+ name: name,
+ path: path,
+ raw: raw,
+ buildTime: 0,
+ metadata: {
+ title: "A Page"
+ }
+ }
+ });
+
+ // Build pages
+ Object.values(pages).forEach(page => buildPage(page));
+
+ lastBuild = Date.now();
+ return true;
+ }
+
+ export function get(name: string): Page {
+ const page = pages[name];
+ if (!page) {
+ return undefined;
+ }
+
+ return page;
+ }
+
+ function loadRaw(path: string): string {
+ return readFileSync(`${path}`, 'utf-8');
+ }
+}
+
+export type Page = {
+ html?: string;
+ raw?: string;
+ route: string;
+ name: string;
+ path: string;
+ buildTime: number;
+ metadata: any;
+};
diff --git a/app/routes/blog/router.ts b/app/routes/blog/router.ts
new file mode 100644
index 0000000..bbd09d5
--- /dev/null
+++ b/app/routes/blog/router.ts
@@ -0,0 +1,19 @@
+import express from 'express';
+import { page } from '../../middlewares/index.js';
+import { blogs } from '../../middlewares/blogs.js';
+
+export const router = express.Router({ mergeParams: true });
+
+router.use('/blog/:page?', page);
+router.use('/blog/:page?', blogs);
+
+router.get('/blog/:page?', (req, res, next) => {
+ let page = res.locals.page;
+ let index = !page || res.locals.path === 'blog';
+
+ res.render('blog.ejs', {
+ index: index,
+ blogs: res.locals.blogs,
+ page: page,
+ });
+});
diff --git a/app/routes/page/router.ts b/app/routes/page/router.ts
index dc819f8..5c0a39b 100644
--- a/app/routes/page/router.ts
+++ b/app/routes/page/router.ts
@@ -1,51 +1,20 @@
import express from 'express';
-import { navbar, page } from '../../middlewares/index.js';
+import { page } from '../../middlewares/index.js';
export const router = express.Router({ mergeParams: true });
-router.use('/:page.wiki', page);
router.use('/:page?', page);
-router.get('/:page.wiki', (req, res, next) => {
- const page = res.locals.page;
-
- if (!page) {
- next();
- return;
- }
-
- res.type('text/plain');
- res.send(page.raw).end();
-});
-
-router.get('/:page?', navbar, (req, res, next) => {
- const page = res.locals.page;
-
+router.get('/:page?', (req, res, next) => {
+ let page = res.locals.page;
if (!page) {
next();
return;
- }
-
- let html: string;
- let title: string;
-
- if (page.metadata.errors.length != 0) {
- html = '<div class="box-red">This page could not be built due to the following errors:<br><ul>'
- page.metadata.errors.forEach(e => {
- html += `<li>${e.identifier}: ${e.message}</li>`
- });
- html += '</ul>Go <a href="/">home</a>?</div>'
- title = 'Page error'
- } else {
- html = page.html;
- title = page.metadata.displayTitle;
}
-
- res.render('page.ejs', {
- navbar: res.locals.navbarHtml,
- path: res.locals.path,
- content: html,
- title: title,
- buildTime: new Date(page.buildTime)
+
+ res.render('index.ejs', {
+ content: page.html,
+ stylesheets: page.metadata.stylesheets,
+ scripts: page.metadata.scripts,
});
});
diff --git a/app/routes/special/router.ts b/app/routes/special/router.ts
deleted file mode 100644
index 2015a35..0000000
--- a/app/routes/special/router.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import express from 'express';
-import { navbar, page } from '../../middlewares/index.js';
-import { logger } from './../../logger.js'
-
-export const router = express.Router({ mergeParams: true });
-
-router.use('/special/purge/:page?', page);
-router.use('/special/purge/:page/confirm', page);
-
-router.get('/special/purge/:page?', navbar, (req, res, next) => {
- const page = res.locals.page;
-
- if (!page) {
- next();
- return;
- }
-
- res.render('purge.ejs', {
- navbar: res.locals.navbarHtml,
- page: res.locals.path,
- buildTime: new Date(page.buildTime) ?? 'never',
- buildTimeRelative: Math.round((Date.now() - page.buildTime) / 1000 / 60)
- });
-});
-
-router.get('/special/purge/:page/confirm', (req, res, next) => {
- const page = res.locals.page;
-
- if (!page) {
- next();
- return;
- }
-
- logger.info(`Purge for page ${page.standardName} requested by ${req.headers['x-forwarded-for'] || req.socket.remoteAddress }`)
- if (res.locals.directory.purge(res.locals.path)) {
- res.status(200).send();
- } else {
- res.status(429).send();
- }
-});
-
-router.use('/special/info/:page?', page);
-
-router.get('/special/info/:page?', navbar, (req, res, next) => {
- const page = res.locals.page;
-
- if (!page) {
- next();
- return;
- }
-
- res.render('pageinfo.ejs', {
- navbar: res.locals.navbarHtml,
- standardName: page.standardName,
- displayTitle: page.metadata.displayTitle,
- buildTime: page.buildTime,
- primary: page.metadata.includeInNavbar,
- showTitle: page.metadata.showTitle,
- sortOrder: page.metadata.sortOrder,
- dependencies: page.metadata.dependencies,
- dependents: page.metadata.dependents,
- errors: page.metadata.errors,
- });
-});
-
-router.get('/special/rebuild', navbar, (req, res) => {
- res.render('rebuild.ejs', {
- navbar: res.locals.navbarHtml
- });
-});
-
-router.get('/special/rebuild/confirm', (req, res) => {
- logger.info(`Directory rebuild requested by ${req.headers['x-forwarded-for'] || req.socket.remoteAddress }`)
- if (res.locals.directory.rebuild()) {
- res.status(200).send();
- } else {
- res.status(429).send();
- }
-});
diff --git a/app/routes/spotify/router.ts b/app/routes/spotify/router.ts
new file mode 100644
index 0000000..faf8f6d
--- /dev/null
+++ b/app/routes/spotify/router.ts
@@ -0,0 +1,26 @@
+import express from 'express';
+
+export const router = express.Router({ mergeParams: true });
+
+router.get('/spotify/auth', (req, res, next) => {
+ let scope = 'user-read-currently-playing';
+ let params = new URLSearchParams();
+ params.append('response_type', 'code');
+ params.append('client_id', process.env.SPOTIFY_CLIENT_ID);
+ params.append('scope', scope);
+ params.append('redirect_uri', process.env.SPOTIFY_REDIRECT_URI);
+
+ res.redirect('https://accounts.spotify.com/authorize?' + params.toString());
+});
+
+router.get('/spotify/auth/callback', (req, res, next) => {
+ if (req.query.error) {
+ res.send('Error: ' + req.query.error);
+ return;
+ }
+ if (!req.query.code) {
+ res.send('No code');
+ return;
+ }
+ res.send('Your authentication code: ' + req.query.code);
+});
diff --git a/app/spotify/client.ts b/app/spotify/client.ts
new file mode 100644
index 0000000..2cdf527
--- /dev/null
+++ b/app/spotify/client.ts
@@ -0,0 +1,158 @@
+import axios from 'axios';
+import { logger } from '../logger.js';
+import { WebSocket } from 'ws';
+
+export namespace SpotifyClient {
+ let clients = new Set<WebSocket>();
+ let interval: NodeJS.Timeout;
+
+ let acceptingClients = false;
+ let authenticationFailed = false;
+
+ let accessToken: string;
+ let refreshToken: string;
+
+ export const addClient = (client: WebSocket) => {
+ if (acceptingClients) {
+ clients.add(client);
+ } else {
+ client.close();
+ }
+ }
+
+ const apiTokenUrl = 'https://accounts.spotify.com/api/token';
+
+ const spotifyClientHeaders = {
+ 'Authorization': 'Basic ' + Buffer.from(process.env.SPOTIFY_CLIENT_ID + ':' + process.env.SPOTIFY_CLIENT_SECRET).toString('base64'),
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+
+ const handleApiError = (err: any, verb: string) => {
+ if (err.response?.data?.error) {
+ logger.error(`Failed to ${verb} access token: ${err.message}: ${err.response.data.error}`);
+ } else {
+ logger.error(`Failed to get access token: ${err.message} (${err.response.status} ${err.response.statusText} ${err.response.data.error})`);
+ }
+ accessToken = undefined;
+ refreshToken = undefined;
+ }
+
+ export const requestAccessToken = async () => {
+ logger.info('Requesting access token from Spotify');
+ await axios.post(apiTokenUrl, {
+ grant_type: 'authorization_code',
+ code: process.env.SPOTIFY_AUTH_CODE,
+ redirect_uri: process.env.SPOTIFY_REDIRECT_URI,
+ },
+ { headers: spotifyClientHeaders,
+ }).then(res => {
+ logger.info('Authenticated with Spotify');
+ accessToken = res.data.access_token;
+ }).catch(err => {
+ handleApiError(err, 'request');
+ });
+ }
+
+ export const refreshAccessToken = async () => {
+ logger.info('Refreshing access token from Spotify');
+ await axios.post(apiTokenUrl, {
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ },
+ { headers: spotifyClientHeaders,
+ }).then(res => {
+ logger.info('Refreshed access token from Spotify');
+ accessToken = res.data.access_token;
+ }).catch(err => {
+ handleApiError(err, 'refresh');
+ });
+ }
+
+
+ export const initialise = async () => {
+ if (!accessToken) {
+ await requestAccessToken();
+ if (!accessToken) {
+ logger.error('Failed to get access token, giving up permanently');
+ authenticationFailed = true;
+ return;
+ }
+ }
+ await updateTimeout();
+ acceptingClients = true;
+ }
+
+ const updateTimeout = async () => {
+ await update();
+ interval = setTimeout(updateTimeout, 5000);
+ }
+
+ export const update = async () => {
+ if (authenticationFailed) {
+ return;
+ }
+ clients.forEach(client => {
+ if (client.readyState !== WebSocket.OPEN) {
+ clients.delete(client);
+ }
+ });
+ if (clients.size === 0) {
+ return;
+ }
+ await axios.get('https://api.spotify.com/v1/me/player/currently-playing', {
+ headers: {
+ 'Authorization': 'Bearer ' + accessToken,
+ }
+ }).then(async (res) => {
+ if (res.status === 401) {
+ logger.info('Access token expired, refreshing');
+ await refreshAccessToken();
+ if (!accessToken) {
+ authenticationFailed = true;
+ logger.error('Failed to get access token, giving up permanently');
+ stop();
+ return;
+ }
+ await update();
+ return;
+ }
+ if (res.status !== 200) {
+ logger.error(`Failed to get current song: ${res.status} ${res.statusText}`);
+ return;
+ }
+ try {
+ let song = res.data.item.name;
+ let duration = res.data.item.duration_ms;
+ let artist = res.data.item.artists[0].name;
+ let time = res.data.progress_ms;
+ let album = res.data.item.album.name;
+ let albumImage = res.data.item.album.images[0].url;
+ clients.forEach(client => {
+ client.send(JSON.stringify({
+ song: song,
+ artist: artist,
+ time: time,
+ duration: duration,
+ album: album,
+ albumImage: albumImage,
+ }));
+ });
+ } catch (err) {
+ logger.error(`Failed to parse and send current song: ${err.message}`);
+ }
+ }).catch(err => {
+ if (err.response?.data?.error?.message) {
+ logger.error(`Failed to get current song: ${err.message}: ${err.response.data.error.message}`);
+ } else {
+ logger.error(`Failed to get current song: ${err.message} (${err.response.status} ${err.response.statusText} ${err.response.data.error})`);
+ }
+ });
+ }
+
+ export const stop = () => {
+ clearInterval(interval);
+ acceptingClients = false;
+ clients.forEach(client => client.close());
+ }
+
+}
diff --git a/app/websocket/spotify.ts b/app/websocket/spotify.ts
new file mode 100644
index 0000000..4b81fe0
--- /dev/null
+++ b/app/websocket/spotify.ts
@@ -0,0 +1,18 @@
+import { Server } from 'http';
+import { WebSocketServer } from 'ws';
+import { SpotifyClient } from '../spotify/client.js';
+
+export const createWebsocketServer = (server: Server): WebSocketServer => {
+ const wss = new WebSocketServer({ noServer: true });
+ server.on('upgrade', (req, socket, head) => {
+ wss.handleUpgrade(req, socket, head, (ws) => {
+ wss.emit('connection', ws, req)
+ })
+ })
+
+ wss.on('connection', (ws) => {
+ SpotifyClient.addClient(ws);
+ });
+
+ return wss;
+}
diff --git a/app/wikiparser.ts b/app/wikiparser.ts
deleted file mode 100644
index c131a22..0000000
--- a/app/wikiparser.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-/*
- * This file is a modified version of Nixinova/Wikity, whose license is given below:
- * Original: https://www.npmjs.com/package/wikity
- *
- * > ISC License
- * >
- * > Copyright © 2021 Nixinova
- * >
- * > Permission to use, copy, modify, and/or distribute this software for any purpose with or
- * > without fee is hereby granted, provided that the above copyright notice and this
- * > permission notice appear in all copies.
- * >
- * > The software is provided "as is" and the author disclaims all warranties with regard to
- * > this software including all implied warranties of merchantability and fitness. In no
- * > event shall the author be liable for any special, direct, indirect, or consequential
- * > damages or any damages whatsoever resulting from loss of use, data or profits, whether
- * > in an action of contract, negligence or other tortious action, arising out of or in
- * > connection with the use or performance of this software.
- *
- * Additonally, this project and my modifications are also licensed under the ISC license.
- */
-import dateFormat from 'dateformat';
-import htmlEscape from 'escape-html';
-import { PageDirectory } from './directory';
-
-export class Result {
- public html: string;
- public metadata: any;
-
- constructor(html: string, metadata: any) {
- this.html = html;
- this.metadata = metadata;
- }
-}
-
-const re = (regex, flag = 'mgi') => {
- return RegExp(regex.replace(/ /g, '').replace(/\|\|.+?\|\|/g, ''), flag);
-};
-const r = String.raw;
-const arg = r`\s*([^|}]+?)\s*`;
-
-export function findDependencies(data: string): Set<string> {
- const pages = new Set<string>();
-
- let outText = data;
- for (let l = 0, last = ''; l < parseInt(process.env.PARSER_MAX_RECURSION, 10); l++) {
- if (last === outText) break; last = outText;
-
- outText = outText
- // Remove non-template magic words
- .replace(re(r`<(/?) \s* (?= script|link|meta|iframe|frameset|object|embed|applet|form|input|button|textarea )`), '&lt;$1')
- .replace(re(r`(?<= <[^>]+ ) (\bon(\w+))`), 'data-$2')
- .replace(/<!--[^]+?-->/g, '')
- .replace(re(r`{{ \s* displayTitle: ([^}]+) }}`), '')
- .replace(re(r`{{ \s* navbarSortOrder: ([^}]+) }}`), '')
- .replace(re(r`{{ \s* ! \s* }}`), '&vert;')
- .replace(re(r`{{ \s* = \s* }}`), '&equals;')
- .replace(re(r`{{ \s* [Rr]eflist \s* }}`), '<references/>')
- .replace(re(r`{{ \s* #? urlencode: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? urldecode: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? lc: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? uc: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? lcfirst: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? ucfirst: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? len: ${arg} }}`), '')
- .replace(re(r`{{ \s* #? pos: ${arg} \|${arg} (?: \s*\|${arg} )? }}`), '')
- .replace(re(r`{{ \s* #? sub: ${arg} \|${arg} (?:\|${arg})? }}`), '')
- .replace(re(r`{{ \s* #? padleft: ${arg} \|${arg} \|${arg} }}`), '')
- .replace(re(r`{{ \s* #? padright: ${arg} \|${arg} \|${arg} }}`), '')
- .replace(re(r`{{ \s* #? replace: ${arg} \|${arg} \|${arg} }}`), '')
- .replace(re(r`{{ \s* #? explode: ${arg} \|${arg} \|${arg} }}`), '')
- .replace(re(r`{{ \s* (#\w+) \s* : \s* ( [^{}]+ ) \s* }} ( ?!} )`), '')
-
- // Templates: {{template}}
- .replace(re(r`{{ \s* ([^#}|]+?) (\|[^}]+)? }} (?!})`), (_, title, params = '') => {
- if (/{{/.test(params)) return _;
- const page = title.includes(':') ? title : `Template:${title}`
- pages.add(page);
- return '';
- })
- }
- return pages;
-}
-
-export function parse(directory: PageDirectory, data: string): Result {
- const vars = {};
- const metadata: any = {};
- const nowikis = [];
- const refs = [];
-
- let nowikiCount = 0;
- let rawExtLinkCount = 0;
- let refCount = 0;
-
- let outText = data;
-
- for (let l = 0, last = ''; l < parseInt(process.env.PARSER_MAX_RECURSION, 10); l++) {
- if (last === outText) break; last = outText;
-
- outText = outText
-
- // Nowiki: <nowiki></nowiki>
- .replace(re(r`<nowiki> ([^]+?) </nowiki>`), (_, m) => `%NOWIKI#${nowikis.push(m), nowikiCount++}%`)
-
- // Sanitise unacceptable HTML
- .replace(re(r`<(/?) \s* (?= script|link|meta|iframe|frameset|object|embed|applet|form|input|button|textarea )`), '&lt;$1')
- .replace(re(r`(?<= <[^>]+ ) (\bon(\w+))`), 'data-$2')
-
- // Comments: <!-- -->
- .replace(/<!--[^]+?-->/g, '')
-
- // Lines: ----
- .replace(/^-{4,}/gm, '<hr>')
-
- // Metadata: displayTitle, __NOTOC__, etc
- .replace(re(r`{{ \s* displayTitle: ([^}]+) }}`), (_, title) => (metadata.displayTitle = title, ''))
- .replace(re(r`{{ \s* navbarSortOrder: ([^}]+) }}`), (_, order) => (metadata.sortOrder = parseInt(order, 10), ''))
- .replace(re(r`__NOINDEX__`), () => (metadata.noindex = true, ''))
- .replace(re(r`__NOTOC__`), () => (metadata.notoc = true, ''))
- .replace(re(r`__FORCETOC__`), () => (metadata.toc = true, ''))
- .replace(re(r`__TOC__`), () => (metadata.toc = true, '<toc></toc>'))
- .replace(re(r`__PRIMARY__`), () => (metadata.primary = true, ''))
- .replace(re(r`__NOTITLE__`), () => (metadata.notitle = true, ''))
-
- // Magic words: {{!}}, {{reflist}}, etc
- .replace(re(r`{{ \s* ! \s* }}`), '&vert;')
- .replace(re(r`{{ \s* = \s* }}`), '&equals;')
- .replace(re(r`{{ \s* [Rr]eflist \s* }}`), '<references/>')
-
- // String functions: {{lc:}}, {{ucfirst:}}, {{len:}}, etc
- .replace(re(r`{{ \s* #? urlencode: ${arg} }}`), (_, m) => encodeURI(m))
- .replace(re(r`{{ \s* #? urldecode: ${arg} }}`), (_, m) => decodeURI(m))
- .replace(re(r`{{ \s* #? lc: ${arg} }}`), (_, m) => m.toLowerCase())
- .replace(re(r`{{ \s* #? uc: ${arg} }}`), (_, m) => m.toUpperCase())
- .replace(re(r`{{ \s* #? lcfirst: ${arg} }}`), (_, m) => m[0].toLowerCase() + m.substr(1))
- .replace(re(r`{{ \s* #? ucfirst: ${arg} }}`), (_, m) => m[0].toUpperCase() + m.substr(1))
- .replace(re(r`{{ \s* #? len: ${arg} }}`), (_, m) => m.length)
- .replace(re(r`{{ \s* #? pos: ${arg} \|${arg} (?: \s*\|${arg} )? }}`), (_, find, str, n = 0) => find.substr(n).indexOf(str))
- .replace(re(r`{{ \s* #? sub: ${arg} \|${arg} (?:\|${arg})? }}`), (_, str, from, len) => str.substr(+from - 1, +len))
- .replace(re(r`{{ \s* #? padleft: ${arg} \|${arg} \|${arg} }}`), (_, str, n, char) => str.padStart(+n, char))
- .replace(re(r`{{ \s* #? padright: ${arg} \|${arg} \|${arg} }}`), (_, str, n, char) => str.padEnd(+n, char))
- .replace(re(r`{{ \s* #? replace: ${arg} \|${arg} \|${arg} }}`), (_, str, find, rep) => str.split(find).join(rep))
- .replace(re(r`{{ \s* #? explode: ${arg} \|${arg} \|${arg} }}`), (_, str, delim, pos) => str.split(delim)[+pos])
-
- // Parser functions: {{#if:}}, {{#switch:}}, etc
- .replace(re(r`{{ \s* (#\w+) \s* : \s* ( [^{}]+ ) \s* }} ( ?!} )`), (_, name, content) => {
- if (/{{\s*#/.test(content)) return _;
- const args = content.trim().split(/\s*\|\s*/);
- switch (name) {
- case '#if':
- return (args[0] ? args[1] : args[2]) || '';
- case '#ifeq':
- return (args[0] === args[1] ? args[2] : args[3]) || '';
- case '#vardefine':
- vars[args[0]] = args[1] || '';
- return '';
- case '#var':
- if (re(r`{{ \s* #vardefine \s* : \s* ${args[0]}`).test(outText)) return _; // wait until var is set
- return vars[args[0]] || args[1] || '';
- case '#switch':
- return args.slice(1)
- .map(arg => arg.split(/\s*=\s*/))
- .filter(duo => args[0] === duo[0].replace('#default', args[0]))[0][1];
- case '#time':
- case '#date':
- case '#datetime':
- return dateFormat(args[1] ? new Date(args[1]) : new Date(), args[0]);
- }
- })
-
- // Templates: {{template}}
- .replace(re(r`{{ \s* ([^#}|]+?) (\|[^}]+)? }} (?!})`), (_, title, params = '') => {
- if (/{{/.test(params)) return _;
- const page = title.includes(':') ? title : `Template:${title}`
-
- // Retrieve template content
- const content = directory.get(page);
- if (!content?.html) {
- return `<a class="internal-link redlink" title="${title}" href="/${page}">Template:${title}</a>`;
- }
-
- // Remove non-template sections
- let raw = content.raw
- .replace(/<noinclude>.*?<\/noinclude>/gs, '')
- .replace(/.*<(includeonly|onlyinclude)>|<\/(includeonly|onlyinclude)>.*/gs, '');
-
- // Substitite arguments
- const argMatch = (arg) => re(r`{{{ \s* ${arg} (?:\|([^}]*))? \s* }}}`);
- const args = params.split('|').slice(1);
- for (const i in args) {
- const parts = args[i].split('=');
- const [arg, val] = parts[1] ? [parts[0], ...parts.slice(1)] : [(+i + 1) + '', parts[0]];
- raw = raw.replace(argMatch(arg), (_, m) => val || m || '');
- }
- for (let i = 1; i <= 10; i++) {
- raw = raw.replace(argMatch(arg), '$2');
- }
-
- return raw;
- })
-
- // Images: [[File:Image.png|options|caption]]
- .replace(re(r`\[\[ (?:File|Image): (.+?) (\|.+?)? \]\]`), (_, file, params) => {
- if (/{{/.test(params)) return _;
- const path = file.trim().replace(/ /g, '_');
- let caption = '';
- const imageData: any = {};
- const imageArgs = params?.split('|').map((arg) => arg.replace(/"/g, '&quot;'));
- if (imageArgs) {
- for (const param of imageArgs) {
- if (['left', 'right', 'center', 'none'].includes(param)) {
- imageData.float = param;
- }
- if (['baseline', 'sub', 'super', 'top', 'text-bottom', 'middle', 'bottom', 'text-bottom'].includes(param)) {
- imageData.align = param;
- }
- else if (['border', 'frameless', 'frame', 'framed', 'thumb', 'thumbnail'].includes(param)) {
- imageData.type = { framed: 'frame', thumbnail: 'thumb' }[param] || param;
- if (imageData.type === 'thumb') imageData.hasCaption = true;
- }
- else if (param.endsWith('px')) {
- param.replace(/(?:(\w+)?(x))?(\w+)px/, (_, size1, auto, size2) => {
- if (size1) Object.assign(imageData, { width: size1, height: size2 });
- else if (auto) Object.assign(imageData, { width: 'auto', height: size2 });
- else Object.assign(imageData, { width: size2, height: 'auto' });
- return '';
- });
- }
- else if (param.startsWith('upright=')) {
- imageData.width = +param.replace('upright=', '') * 300;
- }
- else if (param.startsWith('link=')) {
- imageData.link = param.replace('link=', '');
- }
- else if (param.startsWith('alt=')) {
- imageData.alt = param.replace('alt=', '');
- }
- else if (param.startsWith('style=')) {
- imageData.style = param.replace('style=', '');
- }
- else if (param.startsWith('class=')) {
- imageData.class = param.replace('class=', '');
- }
- else {
- caption = param;
- }
- }
- }
- let content = `
- <figure
- class="${imageData.class || ''} image-container image-${imageData.type || 'default'}"
- style="float:${imageData.float || 'none'};vertical-align:${imageData.align || 'unset'};${imageData.style || ''}"
- >
- <img
- src="/image/${path}"
- alt="${imageData.alt || file}"
- width="${imageData.width || 300}"
- height="${imageData.height || 300}"
- >
- ${imageData.hasCaption ? `<figcaption>${caption}</figcaption>` : ''}
- </figure>
- `;
- if (imageData.link) content = `<a href="/${imageData.link}" title="${imageData.link}">${content}</a>`;
- return content;
- })
-
- // Markup: '''bold''' and '''italic'''
- .replace(re(r`''' ([^']+?) '''`), '<b>$1</b>')
- .replace(re(r`'' ([^']+?) ''`), '<i>$1</i>')
-
- // Headings: ==heading==
- .replace(re(r`^ (=+) \s* (.+?) \s* \1 \s* $`), (_, lvl, txt) => `<h${lvl.length} id="${encodeURI(txt.replace(/ /g, '_'))}">${txt}</h${lvl.length}>`)
-
- // Internal links: [[Page]] and [[Page|Text]]
- .replace(re(r`\[\[ ([^\]|]+?) \]\]`), '<a class="internal-link" title="$1" href="$1">$1</a>')
- .replace(re(r`\[\[ ([^\]|]+?) \| ([^\]]+?) \]\]`), '<a class="internal-link" title="$1" href="/$1">$2</a>')
- .replace(re(r`(</a>)([a-z]+)`), '$2$1')
-
- // External links: [href Page] and just [href]
- .replace(re(r`\[ ((?:\w+:)?\/\/ [^\s\]]+) (\s [^\]]+?)? \]`), (_, href, txt) => `<a class="external-link" href="${href}">${txt || '[' + (++rawExtLinkCount) + ']'}</a>`)
-
- // Bulleted list: *item
- .replace(re(r`^ (\*+) (.+?) $`), (_, lvl, txt) => `${'<ul>'.repeat(lvl.length)}<li>${txt}</li>${'</ul>'.repeat(lvl.length)}`)
- .replace(re(r`</ul> (\s*?) <ul>`), '$1')
-
- // Numbered list: #item
- .replace(re(r`^ (#+) (.+?) $`), (_, lvl, txt) => `${'<ol>'.repeat(lvl.length)}<li>${txt}</li>${'</ol>'.repeat(lvl.length)}`)
- .replace(re(r`</ol> (\s*?) <ol>`), '$1')
-
- // Definition list: ;head, :item
- .replace(re(r`^ ; (.+) $`), '<dl><dt>$1</dt></dl>')
- .replace(re(r`^ (:+) (.+?) $`), (_, lvl, txt) => `${'<dl>'.repeat(lvl.length)}<dd>${txt}</dd>${'</dl>'.repeat(lvl.length)}`)
- .replace(re(r`</dl> (\s*?) <dl>`), '$1')
-
- // Tables: {|, |+, !, |-, |, |}
- .replace(re(r`^ \{\| (.*?) $`), (_, attrs) => `<table ${attrs}><tr>`)
- .replace(re(r`^ ! ([^]+?) (?= \n^[!|] )`), (_, content) => `<th>${content}</th>`)
- .replace(re(r`^ \|\+ (.*?) $`), (_, content) => `<caption>${content}</caption>`)
- .replace(re(r`^ \|[^-+}] ([^]*?) (?= \n^[!|] )`), (_, content) => `<td>${content}</td>`)
- .replace(re(r`^ \|- (.*?) $`), (_, attrs) => `</tr><tr ${attrs}>`)
- .replace(re(r`^ \|\}`), '</tr></table>')
-
- // References: <ref></ref>, <references/>
- .replace(re(r`<ref> (.+?) </ref>`), (_, text) => {
- refs.push(text);
- refCount++;
- return `<sup><a id="cite-${refCount}" class="ref" href="#ref-${refCount}">[${refCount}]</a></sup>`;
- })
- .replace(re(r`<references \s* /?>`), '<ol>' + refs.map((ref, i) =>
- `<li id="ref-${+i + 1}"> <a href="#cite-${+i + 1}">↑</a> ${ref} </li>`).join('\n') + '</ol>'
- )
-
- // Nonstandard: ``code`` and ```code blocks```
- .replace(re(r` \`\`\` ([^\`]+?) \`\`\` `), '<pre class="code-block">$1</pre>')
- .replace(re(r` <pre> ([^\`]+?) </pre> `), '<pre class="code-block">$1</pre>')
-
- // Spacing
- .replace(/(\r?\n){2}/g, '\n</p><p>\n')
-
- // Restore nowiki contents
- .replace(/%NOWIKI#(\d+)%/g, (_, n) => htmlEscape(nowikis[n]));
- }
- metadata.buildTime = new Date();
-
- const result = new Result(outText, metadata);
- return result;
-}