From 62131f38b108b4a90aaffe9cb3a8db78b102e54f Mon Sep 17 00:00:00 2001 From: DaddyParodz Date: Fri, 3 Oct 2025 01:25:12 +0200 Subject: [PATCH] feat: add self-hosting server and docker setup --- .dockerignore | 8 ++ Dockerfile | 16 +++ README.md | 28 +++++ docker-compose.yml | 8 ++ package.json | 15 ++- server/index.js | 272 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 server/index.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1d7d56b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.DS_Store +.git +.gitignore +.vscode +netlify/edge-functions/*.ts +functions diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..343dc21 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-bookworm-slim AS base +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY docs ./docs +COPY server ./server +COPY netlify ./netlify +COPY build-v2.js ./build-v2.js + +ENV NODE_ENV=production +ENV PORT=8080 + +EXPOSE 8080 +CMD ["npm", "start"] diff --git a/README.md b/README.md index d2c6cfb..9176180 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,31 @@ Learn more at: [about.bitty.site](http://about.bitty.site) How it works: [how.bitty.site](http://how.bitty.site) For more info: [wiki.bitty.site](https://github.com/alcor/itty-bitty/wiki/) + +## Self-hosting + +### Local runtime prerequisites +- Node.js 20 or newer +- npm 9 or newer + +### Run locally +1. Install dependencies with `npm install`. +2. Start the server with `npm run dev`. +3. Visit `http://localhost:8080`. + +Optional environment variables: +- `PORT`: server port (defaults to `8080`). +- `UA_ARRAY`: comma separated list of User-Agents to block with HTTP 401. +- `REQUEST_LOG`: set to `silent` to disable HTTP request logging. + +### Docker workflow +1. Build the image locally: `docker build -t itty-bitty .`. +2. Run the container: `docker run --rm -d -p 8080:8080 --name itty-bitty itty-bitty`. +3. Open `http://localhost:8080/` in your browser. +4. Stop it with `docker rm -f itty-bitty` (or let `--rm` clean up if you stop the container normally). + +### Docker Compose +``` +docker compose up --build -d +``` +Stop the stack with `docker compose down` when you are done. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64c4f83 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + itty-bitty: + build: . + ports: + - "8080:8080" + environment: + NODE_ENV: production + restart: unless-stopped diff --git a/package.json b/package.json index a08e6da..849a38b 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,22 @@ { + "name": "itty-bitty", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "node server/index.js", + "dev": "NODE_ENV=development node server/index.js", + "build": "node build-v2.js" + }, "dependencies": { "brotli-wasm": "^1.1.0", + "compression": "^1.7.4", + "express": "^4.19.2", + "morgan": "^1.10.0", "sharp": "^0.31.3" }, "devDependencies": { - "netlify-cli": "^11.5.1" + "netlify-cli": "^11.5.1", + "terser": "^5.44.0", + "uglifycss": "^0.0.29" } } diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..9c61e69 --- /dev/null +++ b/server/index.js @@ -0,0 +1,272 @@ +const path = require('path'); +const express = require('express'); +const compression = require('compression'); +const morgan = require('morgan'); +const sharp = require('sharp'); + +const PORT = process.env.PORT || 8080; +const DOCS_DIR = path.resolve(__dirname, '..', 'docs'); +const METADATA_BOTS = [ + 'Twitterbot', + 'curl', + 'facebookexternalhit', + 'Slackbot-LinkExpanding', + 'Discordbot', + 'snapchat', + 'Googlebot', +]; +const blockedAgents = (process.env.UA_ARRAY || '') + .split(',') + .map((ua) => ua.trim()) + .filter(Boolean); + +const app = express(); +app.disable('x-powered-by'); + +if (process.env.REQUEST_LOG !== 'silent') { + app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); +} + +app.use(compression()); + +app.use((req, res, next) => { + const ua = req.get('user-agent') || ''; + if (blockedAgents.some((blocked) => ua.includes(blocked))) { + res.status(401).end(); + return; + } + next(); +}); + +app.use((req, res, next) => { + if (req.path.startsWith('/render/') || req.path.startsWith('/js/')) { + res.setHeader('Access-Control-Allow-Origin', '*'); + } + next(); +}); + +app.get('/.netlify/functions/rasterize*', async (req, res, next) => { + try { + const payload = extractRasterizePayload(req.originalUrl); + if (!payload) { + res.status(400).json({ error: 'Missing payload' }); + return; + } + + let svg = fromBase64(payload) ?? safeDecodeURIComponent(payload); + if (!svg.startsWith('${svg}`; + } + + const jpegBuffer = await sharp(Buffer.from(svg)) + .resize({ width: 1200, withoutEnlargement: true }) + .jpeg() + .toBuffer(); + + res.setHeader('Cache-Control', 'public, max-age=300'); + res.type('image/jpeg'); + res.send(jpegBuffer); + } catch (error) { + next(error); + } +}); + +app.get('*', (req, res, next) => { + if (req.path === '/' || !req.path.endsWith('/')) { + next(); + return; + } + + const userAgent = req.get('user-agent') || ''; + const isMetadataBot = METADATA_BOTS.some((bot) => userAgent.includes(bot)); + if (!isMetadataBot) { + next(); + return; + } + + try { + const info = pathToMetadata(req.path); + const html = renderMetadataDocument(info); + res.type('html').send(html); + } catch (error) { + next(error); + } +}); + +app.use(express.static(DOCS_DIR, { + extensions: ['html'], + setHeaders(res, filePath) { + if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } else { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, +})); + +app.get('*', (req, res) => { + res.sendFile(path.join(DOCS_DIR, 'index.html')); +}); + +app.use((err, req, res, next) => { + // eslint-disable-next-line no-console + console.error('Unhandled error', err); + res.status(500).json({ error: 'Internal Server Error' }); +}); + +app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`itty.bitty self-host listening on port ${PORT}`); +}); + +function extractRasterizePayload(originalUrl) { + const basePath = '/.netlify/functions/rasterize'; + if (!originalUrl.startsWith(basePath)) { + return ''; + } + + const [pathPart, queryPart] = originalUrl.split('?'); + if (queryPart) { + return queryPart.replace(/=/g, ''); + } + + const suffix = pathPart.slice(basePath.length); + if (!suffix) { + return ''; + } + + return suffix.replace(/^\//, '').replace(/=/g, ''); +} + +function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value); + } catch (error) { + return value; + } +} + +function fromBase64(value) { + try { + return Buffer.from(value, 'base64').toString('utf8'); + } catch (error) { + return null; + } +} + +function decodePrettyComponent(component) { + if (!component) { + return ''; + } + + const replacements = { + '---': ' - ', + '--': '-', + '-': ' ', + }; + + return safeDecodeURIComponent( + component.replace(/-+/g, (match) => replacements[match] ?? '-') + ); +} + +function decodeURL(value) { + if (!value || value.startsWith('http')) { + return value; + } + const cleaned = value.replace(/=/g, ''); + return fromBase64(cleaned) ?? safeDecodeURIComponent(value); +} + +function pathToMetadata(pathname) { + const segments = pathname.substring(1).split('/'); + const info = { title: decodePrettyComponent(segments.shift()) }; + + for (let i = 0; i < segments.length; i += 2) { + const key = segments[i]; + const value = segments[i + 1]; + if (!key || !value) { + continue; + } + if (key === 'd') { + info[key] = decodePrettyComponent(value); + } else if (value.includes('%')) { + info[key] = safeDecodeURIComponent(value); + } else { + info[key] = value; + } + } + + return info; +} + +function renderMetadataDocument(info) { + const content = ['']; + + if (info.title) { + content.push(`${info.title}`); + content.push(mProp('og:title', info.title)); + } + if (info.s) { + content.push(mProp('og:site_name', info.s)); + } + if (info.t) { + content.push(mProp('og:type', info.t)); + } + if (info.d) { + content.push(mProp('og:description', info.d)); + content.push(mName('description', info.d)); + } + if (info.c) { + content.push(mName('theme-color', `#${info.c}`)); + } + if (info.i) { + let image = decodeURL(info.i); + if (image && !image.startsWith('http')) { + image = `/.netlify/functions/rasterize/${image}`; + } + if (image) { + content.push(mProp('og:image', image)); + if (info.iw) { + content.push(mProp('og:image:width', info.iw)); + } + if (info.ih) { + content.push(mProp('og:image:height', info.ih)); + } + content.push(mName('twitter:card', 'summary_large_image')); + } + } + if (info.v) { + const video = decodeURL(info.v); + if (video) { + content.push(mProp('og:video', video)); + if (info.vw) { + content.push(mProp('og:video:width', info.vw)); + } + if (info.vh) { + content.push(mProp('og:video:height', info.vh)); + } + } + } + if (info.f) { + if (info.f.length > 9) { + const favicon = decodeURL(info.f); + if (favicon) { + content.push(``); + } + } else { + const codepoints = Array.from(info.f).map((char) => char.codePointAt(0).toString(16)); + content.push(``); + } + } + + return content.join('\n'); +} + +function mProp(property, content) { + return ``; +} + +function mName(name, content) { + return ``; +}