From 9891d1f0baf6ec459b2706bce81b56ba46e7bebc Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Mon, 13 Jul 2020 10:21:28 -0700 Subject: [PATCH] Begin implementing a reporting mechanism --- app/api.js | 17 ++- app/controller.js | 21 ++++ app/fileReceiver.js | 2 +- app/main.css | 74 +++---------- app/routes.js | 1 + app/ui/archiveTile.js | 30 +++++- app/ui/copyDialog.js | 6 +- app/ui/download.js | 52 +++++++-- app/ui/downloadCompleted.js | 5 +- app/ui/downloadDialog.js | 58 ++++++++++ app/ui/footer.js | 15 --- app/ui/notFound.js | 5 + app/ui/report.js | 132 +++++++++++++++++++++++ app/ui/shareDialog.js | 6 +- package-lock.json | 188 +++++++++++++++++++++++++++++---- package.json | 5 +- public/locales/en-US/send.ftl | 32 +++++- server/amplitude.js | 23 ++++ server/bin/dev.js | 2 +- server/config.js | 7 +- server/keychain.js | 53 ++++++++++ server/metadata.js | 3 + server/middleware/auth.js | 2 +- server/routes/delete.js | 2 +- server/routes/download.js | 5 +- server/routes/exists.js | 3 + server/routes/index.js | 90 ++++++++-------- server/routes/metadata.js | 4 + server/routes/pages.js | 7 +- server/routes/report.js | 39 +++++++ server/storage/index.js | 25 ++++- server/storage/redis.js | 2 + tailwind.config.js | 8 ++ test/backend/delete-tests.js | 10 +- test/backend/metadata-tests.js | 2 +- test/backend/storage-tests.js | 7 +- webpack.config.js | 2 +- 37 files changed, 762 insertions(+), 183 deletions(-) create mode 100644 app/ui/downloadDialog.js create mode 100644 app/ui/report.js create mode 100644 server/keychain.js create mode 100644 server/routes/report.js diff --git a/app/api.js b/app/api.js index fd29db77..caace3f8 100644 --- a/app/api.js +++ b/app/api.js @@ -127,10 +127,10 @@ export async function metadata(id, keychain) { return { size: meta.size, ttl: data.ttl, - iv: meta.iv, name: meta.name, type: meta.type, - manifest: meta.manifest + manifest: meta.manifest, + flagged: data.flagged }; } throw new Error(result.response.status); @@ -438,3 +438,16 @@ export async function getConstants() { throw new Error(response.status); } + +export async function reportLink(id, key, reason) { + const response = await fetch( + getApiUrl(`/api/report/${id}`), + post({ key, reason }) + ); + + if (response.ok) { + return; + } + + throw new Error(response.status); +} diff --git a/app/controller.js b/app/controller.js index 2107a8f1..e8d97523 100644 --- a/app/controller.js +++ b/app/controller.js @@ -1,5 +1,6 @@ import FileSender from './fileSender'; import FileReceiver from './fileReceiver'; +import { reportLink } from './api'; import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils'; import * as metrics from './metrics'; import { bytes, locale } from './utils'; @@ -306,6 +307,26 @@ export default function(state, emitter) { render(); }); + emitter.on('report', async ({ reason }) => { + try { + const file = state.fileInfo; + if (!file) { + // TODO + emitter.emit('pushState', '/error'); + return render(); + } + await reportLink(file.id, file.secretKey, reason); + render(); + } catch (err) { + console.error(err); + if (err.message === '404') { + state.fileInfo = { reported: true }; + return render(); + } + emitter.emit('pushState', '/error'); + } + }); + setInterval(() => { // poll for updates of the upload list if (!state.modal && state.route === '/') { diff --git a/app/fileReceiver.js b/app/fileReceiver.js index 262b19e1..60942a18 100644 --- a/app/fileReceiver.js +++ b/app/fileReceiver.js @@ -47,9 +47,9 @@ export default class FileReceiver extends Nanobus { const meta = await metadata(this.fileInfo.id, this.keychain); this.fileInfo.name = meta.name; this.fileInfo.type = meta.type; - this.fileInfo.iv = meta.iv; this.fileInfo.size = +meta.size; this.fileInfo.manifest = meta.manifest; + this.fileInfo.flagged = meta.flagged; this.state = 'ready'; } diff --git a/app/main.css b/app/main.css index 01a7dd44..7eb6ab26 100644 --- a/app/main.css +++ b/app/main.css @@ -55,6 +55,12 @@ body { @apply bg-blue-70; } +.btn:disabled { + @apply bg-grey-transparent; + + cursor: not-allowed; +} + .checkbox { @apply leading-normal; @apply select-none; @@ -138,21 +144,6 @@ footer li:hover { text-decoration: underline; } -.feedback-link { - background-color: #000; - background-image: url('../assets/feedback.svg'); - background-position: 0.125rem 0.25rem; - background-repeat: no-repeat; - background-size: 1.125rem; - color: #fff; - display: block; - font-size: 0.75rem; - line-height: 0.75rem; - padding: 0.375rem 0.375rem 0.375rem 1.25rem; - text-indent: 0.125rem; - white-space: nowrap; -} - .link-blue { @apply text-blue-60; } @@ -175,6 +166,10 @@ footer li:hover { height: unset; } +.dl-bg { + filter: grayscale(1) opacity(0.15); +} + .main { display: flex; position: relative; @@ -322,6 +317,10 @@ select { @apply bg-blue-50; } + .btn:disabled { + @apply bg-grey-80; + } + .link-blue { @apply text-blue-40; } @@ -392,48 +391,3 @@ select { .signin:hover:active { transform: scale(0.9375); } - -/* begin signin button color experiment */ - -.white-blue { - @apply border-blue-60; - @apply border-2; - @apply text-blue-60; -} - -.white-blue:hover, -.white-blue:focus { - @apply bg-blue-60; - @apply text-white; -} - -.blue { - @apply bg-blue-60; - @apply text-white; -} - -.white-violet { - @apply border-violet; - @apply border-2; - @apply text-violet; -} - -.white-violet:hover, -.white-violet:focus { - @apply bg-violet; - @apply text-white; - - background-image: var(--violet-gradient); -} - -.violet { - @apply bg-violet; - @apply text-white; -} - -.violet:hover, -.violet:focus { - background-image: var(--violet-gradient); -} - -/* end signin button color experiment */ diff --git a/app/routes.js b/app/routes.js index 1ba8d412..dc697e48 100644 --- a/app/routes.js +++ b/app/routes.js @@ -14,6 +14,7 @@ module.exports = function(app = choo({ hash: true })) { emit('authenticate', state.query.code, state.query.state); }); app.route('/login', body(require('./ui/home'))); + app.route('/report', body(require('./ui/report'))); app.route('*', body(require('./ui/notFound'))); return app; }; diff --git a/app/ui/archiveTile.js b/app/ui/archiveTile.js index 35ca0f9d..d6adeee5 100644 --- a/app/ui/archiveTile.js +++ b/app/ui/archiveTile.js @@ -482,6 +482,11 @@ module.exports.empty = function(state, emit) { > ${state.translate('addFilesButton')} +

+ ${state.translate('trustWarningMessage')} +

${upsell} `; @@ -517,13 +522,27 @@ module.exports.preview = function(state, emit) { `; return html` -
+
${archiveInfo(archive)} ${details}
+
+ + +
+ `; + } if (!state.capabilities.streamDownload && state.fileInfo.size > BIG_SIZE) { return noStreams(state, emit); } return html`
-

- ${state.translate('downloadTitle')} -

-

- ${state.translate('downloadDescription')} -

- ${archiveTile.preview(state, emit)} +

+ ${state.translate('downloadTitle')} +

+

+ ${state.translate('downloadDescription')} +

+

+ ${state.translate('downloadConfirmDescription')} +

+ +
+ `; } @@ -83,7 +115,7 @@ module.exports = function(state, emit) {
${state.modal && modal(state, emit)}
${content}
diff --git a/app/ui/downloadCompleted.js b/app/ui/downloadCompleted.js index c357c5f1..1dfec45a 100644 --- a/app/ui/downloadCompleted.js +++ b/app/ui/downloadCompleted.js @@ -10,7 +10,7 @@ module.exports = function(state) {

${state.translate('downloadFinish')}

- +

${state.translate('trySendDescription')}

@@ -19,6 +19,9 @@ module.exports = function(state) { >${state.translate('sendYourFilesLink')}

+

+ ${state.translate('reportFile')} +

`; }; diff --git a/app/ui/downloadDialog.js b/app/ui/downloadDialog.js new file mode 100644 index 00000000..cd072222 --- /dev/null +++ b/app/ui/downloadDialog.js @@ -0,0 +1,58 @@ +const html = require('choo/html'); + +module.exports = function() { + return function(state, emit, close) { + const archive = state.fileInfo; + return html` + +

+ ${state.translate('downloadConfirmTitle')} +

+

+ ${state.translate('downloadConfirmDescription')} +

+
+ + +
+ + ${state.translate('reportFile')} +
+ `; + + function toggleDownloadEnabled(event) { + event.stopPropagation(); + const checked = event.target.checked; + const btn = document.getElementById('download-btn'); + btn.disabled = !checked; + } + + function download(event) { + event.preventDefault(); + close(); + event.target.disabled = true; + emit('download', archive); + } + }; +}; diff --git a/app/ui/footer.js b/app/ui/footer.js index 1b16c5d2..e90a74f7 100644 --- a/app/ui/footer.js +++ b/app/ui/footer.js @@ -1,7 +1,5 @@ const html = require('choo/html'); const Component = require('choo/component'); -const version = require('../../package.json').version; -const { browserName } = require('../utils'); class Footer extends Component { constructor(name, state) { @@ -15,8 +13,6 @@ class Footer extends Component { createElement() { const translate = this.state.translate; - const browser = browserName(); - const feedbackUrl = `https://qsurvey.mozilla.com/s3/Firefox-Send-Product-Feedback?ver=${version}&browser=${browser}`; return html` `; diff --git a/app/ui/notFound.js b/app/ui/notFound.js index f3cd4b9f..d0364572 100644 --- a/app/ui/notFound.js +++ b/app/ui/notFound.js @@ -21,6 +21,11 @@ module.exports = function(state, emit) { >${state.translate('sendYourFilesLink')}

+

+ ${state.translate('reportFile')} +

`; diff --git a/app/ui/report.js b/app/ui/report.js new file mode 100644 index 00000000..f6f32536 --- /dev/null +++ b/app/ui/report.js @@ -0,0 +1,132 @@ +const html = require('choo/html'); +const raw = require('choo/html/raw'); +const assets = require('../../common/assets'); + +const REPORTABLES = ['Malware', 'Pii', 'Abuse']; + +module.exports = function(state, emit) { + let submitting = false; + const file = state.fileInfo; + if (!file) { + return html` +
+
+

+ ${state.translate('reportUnknownDescription')} +

+
+
+ `; + } + if (file.reported) { + return html` +
+
+

+ ${state.translate('reportedTitle')} +

+

+ ${state.translate('reportedDescription')} +

+ +

+ ${state.translate('okButton')} +

+
+
+ `; + } + return html` +
+
+
+

+ ${state.translate('reportFile')} +

+

+ ${state.translate('reportDescription')} +

+
+
+
    + ${REPORTABLES.map( + reportable => + html` +
  • + +
  • + ` + )} +
  • + ${raw( + replaceLinks(state.translate('reportReasonCopyright'), [ + 'https://www.mozilla.org/about/legal/report-infringement/' + ]) + )} +
  • +
+
+ +
+
+
+
+ `; + + function optionChanged(event) { + event.stopPropagation(); + const button = event.currentTarget.nextElementSibling; + button.disabled = false; + } + + function report(event) { + event.stopPropagation(); + event.preventDefault(); + if (submitting) { + return; + } + submitting = true; + state.fileInfo.reported = true; + const form = event.target; + emit('report', { reason: form.reason.value }); + } + + function replaceLinks(str, urls) { + let i = 0; + const s = str.replace( + /([^<]+)<\/a>/g, + (m, v) => `${v}` + ); + return `

${s}

`; + } +}; diff --git a/app/ui/shareDialog.js b/app/ui/shareDialog.js index de6f6f9c..a85633ec 100644 --- a/app/ui/shareDialog.js +++ b/app/ui/shareDialog.js @@ -9,11 +9,9 @@ module.exports = function(name, url) {

${state.translate('notifyUploadEncryptDone')}

-

+

${state.translate('shareLinkDescription')}
- ${name} + ${name}

+ [one] I trust the person who sent this file + *[other] I trust the person who sent these files + } +# This string has a special case for '1' and [other] (default). If necessary for +# your language, you can add {$count} to your translations and use the +# standard CLDR forms, or only use the form for [other] if both strings should +# be identical. +reportFile = + { $count -> + [one] Report this file as suspicious + *[other] Report these files as suspicious + } +reportDescription = Help us understand what’s going on. What do you think is wrong with these files? +reportUnknownDescription = Please go to the url of the link you wish to report and click “{ reportFile }”. +reportButton = Report +reportReasonMalware = These files contain malware or are part of a phishing attack. +reportReasonPii = These files contain personally identifiable information about me. +reportReasonAbuse = These files contain illegal or abusive content. +reportReasonCopyright = To report copyright or trademark infringement, use the process described at this page. +reportedTitle = Files Reported +reportedDescription = Thank you. We have received your report on these files. diff --git a/server/amplitude.js b/server/amplitude.js index 787026f5..5a20ed27 100644 --- a/server/amplitude.js +++ b/server/amplitude.js @@ -96,6 +96,28 @@ function statDeleteEvent(data) { return sendBatch([event]); } +function statReportEvent(data) { + const loc = location(data.ip); + const event = { + session_id: -1, + country: loc.country, + region: loc.state, + user_id: userId(data.id, data.owner), + app_version: pkg.version, + time: truncateToHour(Date.now()), + event_type: 'server_report', + event_properties: { + reason: data.reason, + agent: data.agent, + download_limit: data.dlimit, + download_count: data.download_count, + ttl: data.ttl + }, + event_id: data.download_count + 1 + }; + return sendBatch([event]); +} + function clientEvent(event, ua, language, session_id, deltaT, platform, ip) { const loc = location(ip); const ep = event.event_properties || {}; @@ -173,6 +195,7 @@ module.exports = { statUploadEvent, statDownloadEvent, statDeleteEvent, + statReportEvent, clientEvent, sendBatch }; diff --git a/server/bin/dev.js b/server/bin/dev.js index f1a1dec1..f5c7ce95 100644 --- a/server/bin/dev.js +++ b/server/bin/dev.js @@ -14,7 +14,7 @@ module.exports = function(app, devServer) { expressWs(wsapp, null, { perMessageDeflate: false }); routes(wsapp); wsapp.ws('/api/ws', require('../routes/ws')); - wsapp.listen(8081, config.listen_address); + wsapp.listen(1338, config.listen_address); assets.setMiddleware(devServer.middleware); app.use(morgan('dev', { stream: process.stderr })); diff --git a/server/config.js b/server/config.js index 77c01c8d..db392ecd 100644 --- a/server/config.js +++ b/server/config.js @@ -120,6 +120,11 @@ const conf = convict({ default: '', env: 'SENTRY_DSN' }, + sentry_host: { + format: String, + default: 'https://sentry.prod.mozaws.net', + env: 'SENTRY_HOST' + }, env: { format: ['production', 'development', 'test'], default: 'development', @@ -152,7 +157,7 @@ const conf = convict({ }, fxa_url: { format: 'url', - default: 'https://send-fxa.dev.lcip.org', + default: 'http://localhost:3030', env: 'FXA_URL' }, fxa_client_id: { diff --git a/server/keychain.js b/server/keychain.js new file mode 100644 index 00000000..e7dc0156 --- /dev/null +++ b/server/keychain.js @@ -0,0 +1,53 @@ +const { Crypto } = require('@peculiar/webcrypto'); +const crypto = new Crypto(); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +module.exports = class Keychain { + constructor(secretKeyB64) { + if (secretKeyB64) { + this.rawSecret = new Uint8Array(Buffer.from(secretKeyB64, 'base64')); + } else { + throw new Error('key is required'); + } + this.secretKeyPromise = crypto.subtle.importKey( + 'raw', + this.rawSecret, + 'HKDF', + false, + ['deriveKey'] + ); + this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) { + return crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: new Uint8Array(), + info: encoder.encode('metadata'), + hash: 'SHA-256' + }, + secretKey, + { + name: 'AES-GCM', + length: 128 + }, + false, + ['decrypt'] + ); + }); + } + + async decryptMetadata(ciphertext) { + const metaKey = await this.metaKeyPromise; + const plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: new Uint8Array(12), + tagLength: 128 + }, + metaKey, + ciphertext + ); + return JSON.parse(decoder.decode(plaintext)); + } +}; diff --git a/server/metadata.js b/server/metadata.js index 1c599316..e772d7e4 100644 --- a/server/metadata.js +++ b/server/metadata.js @@ -7,6 +7,9 @@ class Metadata { this.metadata = obj.metadata; this.auth = obj.auth; this.nonce = obj.nonce; + this.flagged = !!obj.flagged; + this.dead = !!obj.dead; + this.key = obj.key; } } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 133b0992..2e3ecfc4 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -46,7 +46,7 @@ module.exports = { if (id && ownerToken) { try { req.meta = await storage.metadata(id); - if (!req.meta) { + if (!req.meta || req.meta.dead) { return res.sendStatus(404); } const metaOwner = Buffer.from(req.meta.owner, 'utf8'); diff --git a/server/routes/delete.js b/server/routes/delete.js index c0b70bd1..20556486 100644 --- a/server/routes/delete.js +++ b/server/routes/delete.js @@ -6,7 +6,7 @@ module.exports = async function(req, res) { const id = req.params.id; const meta = req.meta; const ttl = await storage.ttl(id); - await storage.del(id); + await storage.kill(id); res.sendStatus(200); statDeleteEvent({ id, diff --git a/server/routes/download.js b/server/routes/download.js index a17c52b7..5a654397 100644 --- a/server/routes/download.js +++ b/server/routes/download.js @@ -7,6 +7,9 @@ module.exports = async function(req, res) { const id = req.params.id; try { const meta = req.meta; + if (meta.dead || meta.flagged) { + return res.sendStatus(404); + } const fileStream = await storage.get(id); let cancelled = false; @@ -33,7 +36,7 @@ module.exports = async function(req, res) { }); try { if (dl >= dlimit) { - await storage.del(id); + await storage.kill(id); } else { await storage.incrementField(id, 'dl'); } diff --git a/server/routes/exists.js b/server/routes/exists.js index da49c019..5f4fdeee 100644 --- a/server/routes/exists.js +++ b/server/routes/exists.js @@ -3,6 +3,9 @@ const storage = require('../storage'); module.exports = async (req, res) => { try { const meta = await storage.metadata(req.params.id); + if (!meta || meta.dead) { + return res.sendStatus(404); + } res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); res.send({ requiresPassword: meta.pwd diff --git a/server/routes/index.js b/server/routes/index.js index 7cb64e75..52cc7c85 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -32,55 +32,57 @@ module.exports = function(app) { }); if (!IS_DEV) { let csp = { - directives: { - defaultSrc: ["'self'"], - connectSrc: [ - "'self'", - 'wss://*.dev.lcip.org', - 'wss://*.send.nonprod.cloudops.mozgcp.net', - config.base_url.replace(/^https:\/\//, 'wss://'), - 'https://*.dev.lcip.org', - 'https://accounts.firefox.com', - 'https://*.accounts.firefox.com', - 'https://sentry.prod.mozaws.net' - ], - imgSrc: [ - "'self'", - 'https://*.dev.lcip.org', - 'https://firefoxusercontent.com', - 'https://secure.gravatar.com' - ], - scriptSrc: [ - "'self'", - function(req) { - return `'nonce-${req.cspNonce}'`; - } - ], - formAction: ["'none'"], - frameAncestors: ["'none'"], - objectSrc: ["'none'"], - reportUri: '/__cspreport__' - } + directives: { + defaultSrc: ["'self'"], + connectSrc: [ + "'self'", + config.base_url.replace(/^https:\/\//, 'wss://') + ], + imgSrc: ["'self'"], + scriptSrc: [ + "'self'", + function(req) { + return `'nonce-${req.cspNonce}'`; + } + ], + formAction: ["'none'"], + frameAncestors: ["'none'"], + objectSrc: ["'none'"], + reportUri: '/__cspreport__' } - - csp.directives.connectSrc.push(config.base_url.replace(/^https:\/\//,'wss://')) - if(config.fxa_csp_oauth_url != ""){ - csp.directives.connectSrc.push(config.fxa_csp_oauth_url) + }; + if (config.fxa_client_id) { + csp.directives.connectSrc.push('https://accounts.firefox.com'); + csp.directives.connectSrc.push('https://*.accounts.firefox.com'); + csp.directives.imgSrc.push('https://firefoxusercontent.com'); + csp.directives.imgSrc.push('https://secure.gravatar.com'); } - if(config.fxa_csp_content_url != "" ){ - csp.directives.connectSrc.push(config.fxa_csp_content_url) + if (config.sentry_id) { + csp.directives.connectSrc.push(config.sentry_host); } - if(config.fxa_csp_profile_url != "" ){ - csp.directives.connectSrc.push(config.fxa_csp_profile_url) + if ( + config.base_url.test(/^https:\/\/.*\.dev\.lcip\.org$/) || + config.base_url.test( + /^https:\/\/.*\.send\.nonprod\.cloudops\.mozgcp\.net$/ + ) + ) { + csp.directives.connectSrc.push('https://*.dev.lcip.org'); + csp.directives.imgSrc.push('https://*.dev.lcip.org'); } - if(config.fxa_csp_profileimage_url != ""){ - csp.directives.imgSrc.push(config.fxa_csp_profileimage_url) + if (config.fxa_csp_oauth_url != '') { + csp.directives.connectSrc.push(config.fxa_csp_oauth_url); + } + if (config.fxa_csp_content_url != '') { + csp.directives.connectSrc.push(config.fxa_csp_content_url); + } + if (config.fxa_csp_profile_url != '') { + csp.directives.connectSrc.push(config.fxa_csp_profile_url); + } + if (config.fxa_csp_profileimage_url != '') { + csp.directives.imgSrc.push(config.fxa_csp_profileimage_url); } - - app.use( - helmet.contentSecurityPolicy(csp) - ); + app.use(helmet.contentSecurityPolicy(csp)); } app.use(function(req, res, next) { @@ -101,6 +103,7 @@ module.exports = function(app) { app.get('/oauth', language, pages.blank); app.get('/legal', language, pages.legal); app.get('/login', language, pages.index); + app.get('/report', language, pages.blank); app.get('/app.webmanifest', language, require('./webmanifest')); app.get(`/download/:id${ID_REGEX}`, language, pages.download); app.get('/unsupported/:reason', language, pages.unsupported); @@ -124,6 +127,7 @@ module.exports = function(app) { require('./params') ); app.post(`/api/info/:id${ID_REGEX}`, auth.owner, require('./info')); + app.post(`/api/report/:id${ID_REGEX}`, require('./report')); app.post('/api/metrics', require('./metrics')); app.get('/__version__', function(req, res) { // eslint-disable-next-line node/no-missing-require diff --git a/server/routes/metadata.js b/server/routes/metadata.js index 2e50537c..c42fb6a8 100644 --- a/server/routes/metadata.js +++ b/server/routes/metadata.js @@ -4,9 +4,13 @@ module.exports = async function(req, res) { const id = req.params.id; const meta = req.meta; try { + if (meta.dead && !meta.flagged) { + return res.sendStatus(404); + } const ttl = await storage.ttl(id); res.send({ metadata: meta.metadata, + flagged: !!meta.flagged, finalDownload: meta.dl + 1 === meta.dlimit, ttl }); diff --git a/server/routes/pages.js b/server/routes/pages.js index 9fe6e530..c1de6529 100644 --- a/server/routes/pages.js +++ b/server/routes/pages.js @@ -23,14 +23,17 @@ module.exports = { const id = req.params.id; const appState = await state(req); try { - const { nonce, pwd } = await storage.metadata(id); + const { nonce, pwd, dead, flagged } = await storage.metadata(id); + if (dead && !flagged) { + return next(); + } res.set('WWW-Authenticate', `send-v1 ${nonce}`); res.send( stripEvents( routes().toString( `/download/${id}`, Object.assign(appState, { - downloadMetadata: { nonce, pwd } + downloadMetadata: { nonce, pwd, flagged } }) ) ) diff --git a/server/routes/report.js b/server/routes/report.js new file mode 100644 index 00000000..88b2d4a5 --- /dev/null +++ b/server/routes/report.js @@ -0,0 +1,39 @@ +const storage = require('../storage'); +const Keychain = require('../keychain'); +const { statReportEvent } = require('../amplitude'); + +module.exports = async function(req, res) { + try { + const id = req.params.id; + const meta = await storage.metadata(id); + if (meta.flagged) { + return res.sendStatus(200); + } + try { + const key = req.body.key; + const keychain = new Keychain(key); + const metadata = await keychain.decryptMetadata( + Buffer.from(meta.metadata, 'base64') + ); + if (metadata.manifest) { + storage.flag(id, key); + statReportEvent({ + id, + ip: req.ip, + owner: meta.owner, + reason: req.body.reason, + download_limit: meta.dlimit, + download_count: meta.dl, + agent: req.ua.browser.name || req.ua.ua.substring(0, 6) + }); + return res.sendStatus(200); + } + res.sendStatus(400); + } catch (e) { + console.error(e); + res.sendStatus(400); + } + } catch (e) { + res.sendStatus(404); + } +}; diff --git a/server/storage/index.js b/server/storage/index.js index 3e46c5c1..4b2db284 100644 --- a/server/storage/index.js +++ b/server/storage/index.js @@ -33,7 +33,15 @@ class DB { } async getPrefixedId(id) { - const prefix = await this.redis.hgetAsync(id, 'prefix'); + const [prefix, dead, flagged] = await this.redis.hmgetAsync( + id, + 'prefix', + 'dead', + 'flagged' + ); + if (dead || flagged) { + throw new Error('id not available'); + } return `${prefix}-${id}`; } @@ -51,9 +59,10 @@ class DB { const prefix = getPrefix(expireSeconds); const filePath = `${prefix}-${id}`; await this.storage.set(filePath, file); - this.redis.hset(id, 'prefix', prefix); if (meta) { - this.redis.hmset(id, meta); + this.redis.hmset(id, { prefix, ...meta }); + } else { + this.redis.hset(id, 'prefix', prefix); } this.redis.expire(id, expireSeconds); } @@ -66,6 +75,16 @@ class DB { this.redis.hincrby(id, key, increment); } + kill(id) { + this.redis.hset(id, 'dead', 1); + } + + async flag(id, key) { + // this.redis.persist(id); + this.redis.hmset(id, { flagged: 1, key }); + this.redis.sadd('flagged', id); + } + async del(id) { const filePath = await this.getPrefixedId(id); this.storage.del(filePath); diff --git a/server/storage/redis.js b/server/storage/redis.js index 3118aadc..7e9cc23d 100644 --- a/server/storage/redis.js +++ b/server/storage/redis.js @@ -23,6 +23,8 @@ module.exports = function(config) { client.ttlAsync = promisify(client.ttl); client.hgetallAsync = promisify(client.hgetall); client.hgetAsync = promisify(client.hget); + client.hmgetAsync = promisify(client.hmget); client.pingAsync = promisify(client.ping); + client.existsAsync = promisify(client.exists); return client; }; diff --git a/tailwind.config.js b/tailwind.config.js index 470695f5..b657c728 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -259,6 +259,14 @@ module.exports = { full: '100%', screen: '100vh' }, + flex: { + '1': '1 1 0%', + auto: '1 1 auto', + initial: '0 1 auto', + none: 'none', + half: '0 0 50%', + full: '0 0 100%' + }, minWidth: { '0': '0', full: '100%' diff --git a/test/backend/delete-tests.js b/test/backend/delete-tests.js index 2984b34b..cd353d95 100644 --- a/test/backend/delete-tests.js +++ b/test/backend/delete-tests.js @@ -2,7 +2,7 @@ const sinon = require('sinon'); const proxyquire = require('proxyquire').noCallThru(); const storage = { - del: sinon.stub(), + kill: sinon.stub(), ttl: sinon.stub() }; @@ -24,19 +24,19 @@ const delRoute = proxyquire('../../server/routes/delete', { describe('/api/delete', function() { afterEach(function() { - storage.del.reset(); + storage.kill.reset(); }); - it('calls storage.del with the id parameter', async function() { + it('calls storage.kill with the id parameter', async function() { const req = request('x'); const res = response(); await delRoute(req, res); - sinon.assert.calledWith(storage.del, 'x'); + sinon.assert.calledWith(storage.kill, 'x'); sinon.assert.calledWith(res.sendStatus, 200); }); it('sends a 404 on failure', async function() { - storage.del.returns(Promise.reject(new Error())); + storage.kill.returns(Promise.reject(new Error())); const res = response(); await delRoute(request('x'), res); sinon.assert.calledWith(res.sendStatus, 404); diff --git a/test/backend/metadata-tests.js b/test/backend/metadata-tests.js index 9208b912..b47f3013 100644 --- a/test/backend/metadata-tests.js +++ b/test/backend/metadata-tests.js @@ -6,7 +6,7 @@ const storage = { length: sinon.stub() }; -function request(id, meta) { +function request(id, meta = {}) { return { params: { id }, meta diff --git a/test/backend/storage-tests.js b/test/backend/storage-tests.js index 9f8408cf..2199e424 100644 --- a/test/backend/storage-tests.js +++ b/test/backend/storage-tests.js @@ -133,7 +133,12 @@ describe('Storage', function() { }; await storage.set('x', null, m); const meta = await storage.metadata('x'); - assert.deepEqual(meta, m); + assert.deepEqual(meta, { + ...m, + dead: false, + flagged: false, + key: undefined + }); }); }); }); diff --git a/webpack.config.js b/webpack.config.js index 0f49c234..a9ddad26 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -207,7 +207,7 @@ const web = { host: '0.0.0.0', proxy: { '/api/ws': { - target: 'ws://localhost:8081', + target: 'ws://localhost:1338', ws: true, secure: false }