diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2023-08-05 21:11:48 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2023-08-05 21:11:48 +0100 |
| commit | 18cc5f69129615850e48a995f7c3406b74d8d2f4 (patch) | |
| tree | 1fdc6eadae4b0a6da69319f9b6733379ced2e4c2 /app | |
| parent | 64c36dcef8ab1c0b985d79da627cecd30fd50336 (diff) | |
Redesign website
Diffstat (limited to 'app')
| -rw-r--r-- | app/directory.ts | 312 | ||||
| -rw-r--r-- | app/index.ts | 38 | ||||
| -rw-r--r-- | app/middlewares/blogs.ts | 17 | ||||
| -rw-r--r-- | app/middlewares/index.ts | 15 | ||||
| -rw-r--r-- | app/pages.ts | 87 | ||||
| -rw-r--r-- | app/routes/blog/router.ts | 19 | ||||
| -rw-r--r-- | app/routes/page/router.ts | 47 | ||||
| -rw-r--r-- | app/routes/special/router.ts | 79 | ||||
| -rw-r--r-- | app/routes/spotify/router.ts | 26 | ||||
| -rw-r--r-- | app/spotify/client.ts | 158 | ||||
| -rw-r--r-- | app/websocket/spotify.ts | 18 | ||||
| -rw-r--r-- | app/wikiparser.ts | 327 |
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 )`), '<$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* }}`), '|') - .replace(re(r`{{ \s* = \s* }}`), '=') - .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 )`), '<$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* }}`), '|') - .replace(re(r`{{ \s* = \s* }}`), '=') - .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, '"')); - 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; -} |
