Fixed ratelimiting and added docker support

This commit is contained in:
callum5892
2026-03-15 14:56:55 +00:00
parent b1b9d5772e
commit c797a7cba1
10 changed files with 233 additions and 13 deletions

View File

@@ -1,12 +1,16 @@
{ {
"name": "binit.app Dev Container", "name": "Minipaste Dev Container",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", "dockerComposeFile": "docker-compose.yml",
"forwardPorts": [3000], "service": "app",
"portsAttributes": { "workspaceFolder": "/workspace",
"3000": { "forwardPorts": [3000, 9000, 9001],
"label": "App", "portsAttributes": {
"onAutoForward": "notify" "3000": {
} "label": "App",
}, "onAutoForward": "notify"
"postCreateCommand": "npm install" },
} "9000": { "label": "MinIO S3 API" },
"9001": { "label": "MinIO Console" }
},
"postCreateCommand": "npm install"
}

View File

@@ -0,0 +1,25 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:24-bookworm
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
- minio
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
volumes:
minio-data:

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
npm-debug.log*
.git
.gitignore
.devcontainer
Dockerfile
docker-compose.yml
.env
.env.*
log
*.log

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:24-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
RUN mkdir -p /app/log
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: binit-app
env_file:
- .env
environment:
MINIO_ENDPOINT: minio
MINIO_PORT: 9000
MINIO_USE_SSL: "false"
PORT: 3000
ports:
- "3000:3000"
depends_on:
- minio
restart: unless-stopped
volumes:
- ./log:/app/log
minio:
image: minio/minio:latest
container_name: binit-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
ports:
- "9000:9000"
- "9001:9001"
restart: unless-stopped
volumes:
- minio-data:/data
volumes:
minio-data:

View File

@@ -4,6 +4,7 @@ const path = require('path');
const helmet = require('helmet'); const helmet = require('helmet');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const { ensureBucket } = require('./lib/minio'); const { ensureBucket } = require('./lib/minio');
const { createRequestLogger, logError } = require('./lib/logger');
const pastesRouter = require('./routes/pastes'); const pastesRouter = require('./routes/pastes');
const app = express(); const app = express();
@@ -31,7 +32,20 @@ if (process.env.TRUST_PROXY === 'true') {
} }
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: false, contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
},
},
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
})); }));
@@ -53,6 +67,7 @@ const readPasteLimiter = rateLimit({
app.use(express.json({ limit: '110kb' })); app.use(express.json({ limit: '110kb' }));
app.use(express.urlencoded({ extended: false, limit: '110kb' })); app.use(express.urlencoded({ extended: false, limit: '110kb' }));
app.use(createRequestLogger());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/pastes', readPasteLimiter); app.use('/api/pastes', readPasteLimiter);
@@ -71,7 +86,7 @@ app.get('/p/:id', (req, res) => {
}); });
// Short raw URL for plain-text retrieval. // Short raw URL for plain-text retrieval.
app.get('/raw/:id', (req, res) => { app.get('/raw/:id', readPasteLimiter, (req, res) => {
req.url = `/${req.params.id}/raw`; req.url = `/${req.params.id}/raw`;
pastesRouter.handle(req, res); pastesRouter.handle(req, res);
}); });
@@ -82,6 +97,7 @@ app.use((req, res) => res.status(404).json({ error: 'Not found.' }));
// Global error handler // Global error handler
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
console.error(err); console.error(err);
logError(err, req);
res.status(500).json({ error: 'Internal server error.' }); res.status(500).json({ error: 'Internal server error.' });
}); });

70
src/lib/logger.js Normal file
View File

@@ -0,0 +1,70 @@
const fs = require('fs');
const path = require('path');
const LOG_DIR = path.resolve(__dirname, '../../log');
function ensureLogDir() {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
}
function dayStamp(date = new Date()) {
return date.toISOString().slice(0, 10);
}
function logPath(kind, date = new Date()) {
return path.join(LOG_DIR, `${kind}-${dayStamp(date)}.log`);
}
function appendLine(filePath, line) {
fs.appendFile(filePath, `${line}\n`, err => {
if (err) {
console.error('Failed to write log:', err.message);
}
});
}
function createRequestLogger() {
ensureLogDir();
return (req, res, next) => {
const started = Date.now();
res.on('finish', () => {
const durationMs = Date.now() - started;
const line = [
new Date().toISOString(),
req.ip || '-',
req.method,
req.originalUrl,
res.statusCode,
`${durationMs}ms`,
req.get('user-agent') || '-',
].join(' | ');
appendLine(logPath('requests'), line);
});
next();
};
}
function logError(err, req) {
ensureLogDir();
const line = [
new Date().toISOString(),
req.ip || '-',
req.method,
req.originalUrl,
err && err.stack ? err.stack.replace(/\n/g, ' \\n ') : String(err),
].join(' | ');
appendLine(logPath('errors'), line);
}
module.exports = {
createRequestLogger,
logError,
};

View File

@@ -21,6 +21,8 @@
<span id="paste-date" class="paste-date"></span> <span id="paste-date" class="paste-date"></span>
<span id="paste-expiry" class="paste-date"></span> <span id="paste-expiry" class="paste-date"></span>
<div class="paste-meta-actions"> <div class="paste-meta-actions">
<button id="paste-url-btn" type="button" class="meta-action meta-url-btn" title="Click to copy paste URL"></button>
<span id="paste-url-status" class="paste-url-status" aria-live="polite"></span>
<a id="raw-btn" href="#" target="_blank" class="meta-action">Raw</a> <a id="raw-btn" href="#" target="_blank" class="meta-action">Raw</a>
<button id="download-btn" type="button" class="meta-action">Download</button> <button id="download-btn" type="button" class="meta-action">Download</button>
<button id="copy-btn" type="button" class="meta-action">Copy</button> <button id="copy-btn" type="button" class="meta-action">Copy</button>

View File

@@ -6,6 +6,12 @@ const pasteLangEl = document.getElementById('paste-lang');
const copyBtn = document.getElementById('copy-btn'); const copyBtn = document.getElementById('copy-btn');
const downloadBtn = document.getElementById('download-btn'); const downloadBtn = document.getElementById('download-btn');
const rawBtn = document.getElementById('raw-btn'); const rawBtn = document.getElementById('raw-btn');
const pasteUrlBtn = document.getElementById('paste-url-btn');
const pasteUrlStatus = document.getElementById('paste-url-status');
const pasteUrl = window.location.href;
pasteUrlBtn.textContent = pasteUrl;
rawBtn.href = `/raw/${location.pathname.replace(/^\/p\//, '')}`; rawBtn.href = `/raw/${location.pathname.replace(/^\/p\//, '')}`;
@@ -136,6 +142,19 @@ copyBtn.addEventListener('click', () => {
}); });
}); });
pasteUrlBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(pasteUrl);
pasteUrlStatus.textContent = 'Copied!';
} catch {
pasteUrlStatus.textContent = 'Unable to copy';
}
setTimeout(() => {
pasteUrlStatus.textContent = '';
}, 2000);
});
downloadBtn.addEventListener('click', () => { downloadBtn.addEventListener('click', () => {
const lang = pasteLangEl.classList.contains('hidden') ? null : pasteLangEl.textContent.trim(); const lang = pasteLangEl.classList.contains('hidden') ? null : pasteLangEl.textContent.trim();
const ext = extForLang(lang); const ext = extForLang(lang);

View File

@@ -308,6 +308,27 @@ textarea#content::placeholder { color: var(--text-muted); }
color: var(--text-muted); color: var(--text-muted);
} }
.meta-url-btn {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
font-family: var(--mono);
}
.meta-url-btn:hover {
color: var(--text);
}
.paste-url-status {
font-size: 0.78rem;
color: var(--accent);
min-width: 3.8rem;
text-align: center;
}
#paste-content { #paste-content {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);