diff --git a/backend/src/routes/links.ts b/backend/src/routes/links.ts new file mode 100644 index 0000000..899a4a4 --- /dev/null +++ b/backend/src/routes/links.ts @@ -0,0 +1,89 @@ +import express from 'express'; +import { HttpError } from '../middleware/errorHandler.js'; +import { store } from '../services/store.js'; +import { fileToApi } from '../utils/files.js'; +import { getPreviewKind, getPreviewMeta } from '../utils/preview.js'; +import * as gdrive from '../utils/gdrive.js'; +import { recordEventInDb } from '../services/analytics.js'; +import { rateLimit } from '../middleware/rateLimit.js'; + +const router = express.Router(); + +function isExpired(expiresAt: string) { + return new Date(expiresAt).getTime() <= Date.now(); +} + +router.get('/:linkId', async (req, res) => { + const ip = req.ip; + const userAgent = req.headers['user-agent'] as string; + const referrer = (req.headers.referer || req.headers.referrer) as string; + + const file = await store.update((db) => { + const row = db.files.find((f) => f.linkId === req.params.linkId && f.status === 'uploaded'); + if (!row) return null; + if (isExpired(row.expiresAt)) { + row.status = 'expired'; + row.deletedAt = new Date().toISOString(); + return null; + } + recordEventInDb(db, row.linkId, 'view', ip, userAgent, referrer); + return row; + }); + + if (!file) throw new HttpError(404, 'LINK_NOT_FOUND', 'Shared file not found or expired'); + res.json({ data: fileToApi(file) }); +}); + +// Limit downloads and previews to 60 per minute per IP to prevent egress flooding +const downloadLimiter = rateLimit(60, 60000); + +/** Increment download counter then redirect to Google Drive's direct download URL. */ +router.get('/:linkId/download', downloadLimiter, async (req, res) => { + const ip = req.ip; + const userAgent = req.headers['user-agent'] as string; + const referrer = (req.headers.referer || req.headers.referrer) as string; + + const file = await store.update((db) => { + const row = db.files.find((f) => f.linkId === req.params.linkId && f.status === 'uploaded'); + if (!row || isExpired(row.expiresAt)) return null; + recordEventInDb(db, row.linkId, 'download', ip, userAgent, referrer); + return row; + }); + + if (!file) throw new HttpError(404, 'LINK_NOT_FOUND', 'Shared file not found or expired'); + res.redirect(302, gdrive.downloadUrl(file.gdriveId)); +}); + +/** + * Stream preview content directly from Drive via rclone cat — no disk writes. + * Only available for supported types under the preview size limit. + * The browser follows this as a normal src= URL (img, video, audio, iframe, fetch). + */ +router.get('/:linkId/preview', downloadLimiter, async (req, res) => { + const db = await store.read(); + const file = db.files.find((f) => f.linkId === req.params.linkId && f.status === 'uploaded'); + if (!file || isExpired(file.expiresAt)) { + throw new HttpError(404, 'LINK_NOT_FOUND', 'Shared file not found or expired'); + } + + const preview = getPreviewMeta(file); + const kind = getPreviewKind(file.mimeType); + if (!preview.available || !kind) { + throw new HttpError(413, 'PREVIEW_NOT_AVAILABLE', 'Preview is not available for this file'); + } + + const contentType = kind === 'text' ? 'text/plain; charset=utf-8' : file.mimeType; + + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.originalName)}"`); + res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'"); + + const fileStream = await gdrive.getStream(file.gdriveId); + fileStream.pipe(res); + fileStream.on('error', () => { + if (!res.headersSent) res.status(502).json({ error: 'STREAM_ERROR', message: 'Could not stream file from storage' }); + }); +}); + +export { router as linksRouter };