Toothless' TSS Bot
Coming soon
TSS analytics are getting tucked away for a little bit. Check back soon.
import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import obfuscatorPlugin from 'rollup-plugin-obfuscator' const OBFUSCATOR_OPTIONS = { compact: true, controlFlowFlattening: false, deadCodeInjection: false, debugProtection: false, disableConsoleOutput: false, identifierNamesGenerator: 'hexadecimal', log: false, numbersToExpressions: false, renameGlobals: false, selfDefending: false, simplify: true, splitStrings: false, stringArray: true, stringArrayCallsTransform: false, stringArrayEncoding: ['base64'], stringArrayRotate: true, stringArrayShuffle: true, stringArrayThreshold: 0.75, transformObjectKeys: false, unicodeEscapeSequence: false, } function obfuscate() { const factory = obfuscatorPlugin.default || obfuscatorPlugin const inner = factory({ global: true, options: OBFUSCATOR_OPTIONS }) return { ...inner, apply: 'build', enforce: 'post' } } const MAX_TEAM_NAME_LENGTH = 80 function expandHome(filePath) { if (filePath === '~') return os.homedir() if (filePath.startsWith(`~${path.sep}`)) return path.join(os.homedir(), filePath.slice(2)) if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2)) return filePath } function devSongHistoryPath(env) { const storageDir = path.resolve(expandHome(env.UPTIME_STORAGE_DIR || '~/tsswebstorage')) const fileName = path.basename(env.LASTFM_HISTORY_FILE || 'lastfm-song-of-day.json') return path.join(storageDir, fileName) } function readDevSongHistory(env) { try { return JSON.parse(fs.readFileSync(devSongHistoryPath(env), 'utf8')) } catch { return { picks: [] } } } function writeDevSongHistory(env, history) { const filePath = devSongHistoryPath(env) fs.mkdirSync(path.dirname(filePath), { recursive: true }) fs.writeFileSync(filePath, JSON.stringify(history, null, 2)) } function normalizeLastfmTrack(track) { const artist = track?.artist?.['#text'] || track?.artist?.name || '' const name = track?.name || '' if (!artist || !name) return null const images = Array.isArray(track.image) ? track.image : [] const image = [...images].reverse().find((item) => item?.['#text']) let imageUrl = '' try { const parsed = new URL(image?.['#text'] || '') if ( parsed.protocol === 'https:' && (parsed.hostname === 'lastfm.freetls.fastly.net' || parsed.hostname.endsWith('.lastfm.freetls.fastly.net')) ) { imageUrl = parsed.toString() } } catch { imageUrl = '' } return { id: `${artist.toLowerCase()}::${name.toLowerCase()}`, artist, name, album: track?.album?.['#text'] || '', url: track?.url || '', image: imageUrl, played_at: track?.date?.uts ? Number(track.date.uts) : null, } } function normalizeComparable(value) { return String(value || '') .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/&/g, ' and ') .replace(/[^a-z0-9]+/g, ' ') .trim() } function previewUrl(value) { if (!value) return '' try { const parsed = new URL(value) if (parsed.protocol === 'http:') parsed.protocol = 'https:' if (parsed.protocol !== 'https:') return '' if (!parsed.hostname.endsWith('.dzcdn.net') && !parsed.hostname.endsWith('.deezer.com')) return '' if (!parsed.pathname.endsWith('.mp3')) return '' return parsed.toString() } catch { return '' } } async function findDeezerPreview(track) { if (!track?.artist || !track?.name) return null const url = new URL('https://api.deezer.com/search/track') url.searchParams.set('q', `${track.artist} ${track.name}`) url.searchParams.set('limit', '5') const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status}`) const body = await response.json() const candidates = Array.isArray(body?.data) ? body.data : [] const targetArtist = normalizeComparable(track.artist) const targetTitle = normalizeComparable(track.name) const matched = candidates.find((candidate) => { const candidateArtist = normalizeComparable(candidate?.artist?.name) const candidateTitle = normalizeComparable(candidate?.title_short || candidate?.title) if (!candidateArtist || !candidateTitle) return false return ( candidateTitle === targetTitle && (candidateArtist === targetArtist || candidateArtist.includes(targetArtist) || targetArtist.includes(candidateArtist)) ) }) || candidates[0] const preview = previewUrl(matched?.preview) if (!preview) return null return { preview_url: preview, preview_provider: 'Deezer', preview_track_url: matched?.link || '', } } async function devSongOfTheDay(env) { const apiKey = env.LASTFM_API_KEY || '' const username = env.LASTFM_USERNAME || '' if (!apiKey || !username) { return { status: 503, body: { configured: false, error: 'Last.fm is not configured' } } } const date = new Date().toISOString().slice(0, 10) const history = readDevSongHistory(env) const existing = (history.picks || []).find((pick) => pick.date === date) if (existing?.track) { if (!existing.track.preview_url) { try { const preview = await findDeezerPreview(existing.track) if (preview) { existing.track = { ...existing.track, ...preview } writeDevSongHistory(env, history) } } catch { // The card still works without a preview. } } return { status: 200, body: { configured: true, date, track: existing.track } } } const to = Math.floor(Date.now() / 1000) const from = to - 7 * 24 * 60 * 60 const url = new URL('https://ws.audioscrobbler.com/2.0/') url.searchParams.set('method', 'user.getrecenttracks') url.searchParams.set('user', username) url.searchParams.set('api_key', apiKey) url.searchParams.set('format', 'json') url.searchParams.set('from', String(from)) url.searchParams.set('to', String(to)) url.searchParams.set('limit', '200') const response = await fetch(url) if (!response.ok) throw new Error(`HTTP ${response.status}`) const data = await response.json() const tracks = (data?.recenttracks?.track || []).map(normalizeLastfmTrack).filter(Boolean) const uniqueTracks = Array.from(new Map(tracks.map((track) => [track.id, track])).values()) if (!uniqueTracks.length) { return { status: 503, body: { configured: true, date, error: 'No Last.fm tracks found from the last week' } } } const previousIds = new Set((history.picks || []).map((pick) => pick.track?.id).filter(Boolean)) const available = uniqueTracks.filter((track) => !previousIds.has(track.id)) const pool = available.length ? available : uniqueTracks let track = pool[crypto.randomInt(0, pool.length)] try { const preview = await findDeezerPreview(track) if (preview) track = { ...track, ...preview } } catch { // Preview playback is opportunistic; metadata should still render. } const nextHistory = { picks: [ ...(history.picks || []).filter((pick) => pick.date !== date), { date, track }, ] .sort((a, b) => String(a.date).localeCompare(String(b.date))) .slice(-366), } writeDevSongHistory(env, nextHistory) return { status: 200, body: { configured: true, date, track } } } function sri() { return { name: 'sri', apply: 'build', enforce: 'post', transformIndexHtml: { order: 'post', handler(html, ctx) { const bundle = ctx?.bundle if (!bundle) return html const hashFor = (fileName) => { const asset = bundle[fileName] if (!asset) return null const source = asset.type === 'asset' ? asset.source : asset.code const buf = Buffer.isBuffer(source) ? source : Buffer.from(source, 'utf8') return `sha384-${crypto.createHash('sha384').update(buf).digest('base64')}` } const tagPattern = /<(?:script|link)\b[^>]*\b(?:src|href)=["'](\/[^"']+)["'][^>]*>/g return html.replace(tagPattern, (tag, url) => { if (/\bintegrity=/.test(tag)) return tag const fileName = url.replace(/^\//, '').split('?')[0].split('#')[0] const integrity = hashFor(fileName) if (!integrity) return tag const closing = tag.endsWith('/>') ? ' />' : '>' const body = tag.slice(0, -closing.length) let out = `${body} integrity="${integrity}"` if (!/\bcrossorigin\b/.test(tag)) out += ' crossorigin="anonymous"' return `${out}${closing}` }) }, }, } } function isAllowedApiUrl(req) { const url = new URL(req.url, 'http://localhost') const params = url.searchParams if (url.pathname === '/api/viewers' && (req.method === 'GET' || req.method === 'HEAD')) return true if (url.pathname === '/api/viewers/event' && req.method === 'POST') return true if (url.pathname === '/api/song-of-day' && (req.method === 'GET' || req.method === 'HEAD')) return true if (req.method !== 'GET' && req.method !== 'HEAD') return false if (url.pathname === '/api/tss/leaderboard/teams') { const keys = [...params.keys()] const limit = Number(params.get('limit') || 100) return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100 } if (url.pathname === '/api/tss/teams/resolve') { const keys = [...params.keys()] const name = params.get('name') || '' return keys.every((key) => key === 'name') && name.length >= 2 && name.length <= MAX_TEAM_NAME_LENGTH } if (url.pathname === '/api/tss/teams/search') { const keys = [...params.keys()] const query = params.get('q') || params.get('name') || '' const limit = Number(params.get('limit') || 10) return ( keys.every((key) => ['q', 'name', 'limit'].includes(key)) && query.length >= 2 && query.length <= MAX_TEAM_NAME_LENGTH && Number.isInteger(limit) && limit >= 1 && limit <= 20 ) } if ([...params.keys()].length) return false try { const match = url.pathname.match(/^\/api\/tss\/teams\/([^/]+)(?:\/(history|games))?$/) const teamName = match ? decodeURIComponent(match[1]) : '' return Boolean(teamName) && teamName.length <= MAX_TEAM_NAME_LENGTH } catch { return false } } function apiGuard(env) { return { name: 'api-guard', configureServer(server) { server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/')) { next() return } const url = new URL(req.url, 'http://localhost') if (url.pathname === '/api/song-of-day' && req.method === 'GET') { try { const result = await devSongOfTheDay(env) res.writeHead(result.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', }) res.end(JSON.stringify(result.body)) } catch (error) { res.writeHead(502, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', }) res.end(JSON.stringify({ error: 'Last.fm request failed', detail: error.message })) } return } if (isAllowedApiUrl(req)) { next() return } res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'x-content-type-options': 'nosniff', }) res.end(JSON.stringify({ error: 'API route not found' })) }) }, } } function comingSoonHtml() { return `
Toothless' TSS Bot
TSS analytics are getting tucked away for a little bit. Check back soon.