diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6b226ae..e26c5aa 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,16 @@ { - "name": "binit.app Dev Container", - "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", - "forwardPorts": [3000], - "portsAttributes": { - "3000": { - "label": "App", - "onAutoForward": "notify" - } - }, - "postCreateCommand": "npm install" -} + "name": "Minipaste Dev Container", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace", + "forwardPorts": [3000, 9000, 9001], + "portsAttributes": { + "3000": { + "label": "App", + "onAutoForward": "notify" + }, + "9000": { "label": "MinIO S3 API" }, + "9001": { "label": "MinIO Console" } + }, + "postCreateCommand": "npm install" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..ee0ec30 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..11a9237 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +npm-debug.log* +.git +.gitignore +.devcontainer +Dockerfile +docker-compose.yml +.env +.env.* +log +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97d35a0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..987f2cb --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/src/index.js b/src/index.js index a3b3065..6ea28ae 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ const path = require('path'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const { ensureBucket } = require('./lib/minio'); +const { createRequestLogger, logError } = require('./lib/logger'); const pastesRouter = require('./routes/pastes'); const app = express(); @@ -31,7 +32,20 @@ if (process.env.TRUST_PROXY === 'true') { } 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, })); @@ -53,6 +67,7 @@ const readPasteLimiter = rateLimit({ app.use(express.json({ limit: '110kb' })); app.use(express.urlencoded({ extended: false, limit: '110kb' })); +app.use(createRequestLogger()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/api/pastes', readPasteLimiter); @@ -71,7 +86,7 @@ app.get('/p/:id', (req, res) => { }); // 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`; pastesRouter.handle(req, res); }); @@ -82,6 +97,7 @@ app.use((req, res) => res.status(404).json({ error: 'Not found.' })); // Global error handler app.use((err, req, res, _next) => { console.error(err); + logError(err, req); res.status(500).json({ error: 'Internal server error.' }); }); diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 0000000..cc56043 --- /dev/null +++ b/src/lib/logger.js @@ -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, +}; \ No newline at end of file diff --git a/src/public/paste.html b/src/public/paste.html index b4fcd51..d8f5e7b 100644 --- a/src/public/paste.html +++ b/src/public/paste.html @@ -21,6 +21,8 @@
+ + Raw diff --git a/src/public/paste.js b/src/public/paste.js index eb1c435..f6135de 100644 --- a/src/public/paste.js +++ b/src/public/paste.js @@ -6,6 +6,12 @@ const pasteLangEl = document.getElementById('paste-lang'); const copyBtn = document.getElementById('copy-btn'); const downloadBtn = document.getElementById('download-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\//, '')}`; @@ -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', () => { const lang = pasteLangEl.classList.contains('hidden') ? null : pasteLangEl.textContent.trim(); const ext = extForLang(lang); diff --git a/src/public/style.css b/src/public/style.css index b700e03..5824074 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -308,6 +308,27 @@ textarea#content::placeholder { 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 { background: var(--surface); border: 1px solid var(--border);