From cbf41d5022c961ddb6ea3e2e5c1ab90061c0786e Mon Sep 17 00:00:00 2001 From: portakal Date: Tue, 16 Jun 2026 15:19:37 +0300 Subject: [PATCH] fix: prewiev Bot expires links --- src/routes/download.js | 29 ++++++++++++++++++++++++++++- src/views.js | 13 +++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/routes/download.js b/src/routes/download.js index a3a7067..539586e 100644 --- a/src/routes/download.js +++ b/src/routes/download.js @@ -4,7 +4,7 @@ import { pipeline } from 'node:stream/promises'; import express from 'express'; import { queryOne, execute } from '../db.js'; import { rawPath, gzPath } from '../storage.js'; -import { gonePage } from '../views.js'; +import { gonePage, downloadLandingPage } from '../views.js'; import { ah } from '../util.js'; import log from '../log.js'; @@ -15,8 +15,35 @@ function contentDisposition(name) { return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; } +// GET shows a landing page and does NOT consume the link. This is deliberate: chat +// apps and email scanners (WhatsApp, Slack, iMessage, ...) fetch the URL to build a +// preview, and if GET burned the token the link would be "used" before the human +// clicks. The one-time consumption happens on the POST below. router.get('/d/:token', ah(async (req, res) => { const token = req.params.token || ''; + const row = await queryOne( + `SELECT f.original_name, f.size_bytes, f.is_bundle, dl.used, dl.expires_at > NOW() AS valid + FROM download_links dl JOIN files f ON f.id = dl.file_id + WHERE dl.token = ?`, + [token] + ); + if (!row || row.used || !row.valid) { + return res.status(410).send(gonePage()); + } + res.send( + downloadLandingPage({ + token, + name: row.original_name, + size: row.size_bytes, + isBundle: row.is_bundle, + }) + ); +})); + +// POST performs the actual one-time download (triggered by the button on the landing +// page). Preview bots issue GET, never POST, so the link survives until a real click. +router.post('/d/:token', ah(async (req, res) => { + const token = req.params.token || ''; // Atomically reserve the one-time link: succeeds only if unused AND unexpired. const result = await execute( diff --git a/src/views.js b/src/views.js index fbf4b13..b6510a5 100644 --- a/src/views.js +++ b/src/views.js @@ -203,6 +203,19 @@ export function dashboardPage({ user, files, disk, flash, newLink }) { return layout({ title: 'My files', body, user, flash, active: 'dash' }); } +export function downloadLandingPage({ token, name, size, isBundle }) { + const body = `
+

You've received a file

+

${esc(name)}

+

${humanBytes(size)}${isBundle ? ' · zip bundle' : ''}

+
+ +
+

This is a one-time link — it works once, then expires.

+
`; + return layout({ title: 'Download', body, user: null }); +} + export function gonePage() { return messagePage({ title: 'Link unavailable',