Fixed ratelimiting and added docker support
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
25
.devcontainer/docker-compose.yml
Normal file
25
.devcontainer/docker-compose.yml
Normal 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
11
.dockerignore
Normal 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
15
Dockerfile
Normal 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
37
docker-compose.yml
Normal 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:
|
||||||
20
src/index.js
20
src/index.js
@@ -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
70
src/lib/logger.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user