mirror of
https://github.com/arfct/itty-bitty.git
synced 2026-03-11 08:54:33 +00:00
feat: add self-hosting server and docker setup
This commit is contained in:
parent
a4092b710c
commit
62131f38b1
6 changed files with 346 additions and 1 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
netlify/edge-functions/*.ts
|
||||
functions
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -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"]
|
||||
28
README.md
28
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.
|
||||
|
|
|
|||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
services:
|
||||
itty-bitty:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
restart: unless-stopped
|
||||
15
package.json
15
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
272
server/index.js
Normal file
272
server/index.js
Normal file
|
|
@ -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')) {
|
||||
svg = `<svg xmlns="http://www.w3.org/2000/svg">${svg}</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 = ['<meta charset="UTF-8">'];
|
||||
|
||||
if (info.title) {
|
||||
content.push(`<title>${info.title}</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(`<link rel="icon" type="image/png" href="${favicon}">`);
|
||||
}
|
||||
} else {
|
||||
const codepoints = Array.from(info.f).map((char) => char.codePointAt(0).toString(16));
|
||||
content.push(`<link rel="icon" type="image/png" href="https://fonts.gstatic.com/s/e/notoemoji/14.0/${codepoints.join('_')}/128.png">`);
|
||||
}
|
||||
}
|
||||
|
||||
return content.join('\n');
|
||||
}
|
||||
|
||||
function mProp(property, content) {
|
||||
return `<meta property="${property}" content="${content}"/>`;
|
||||
}
|
||||
|
||||
function mName(name, content) {
|
||||
return `<meta name="${name}" content="${content}"/>`;
|
||||
}
|
||||
Loading…
Reference in a new issue