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)}
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/home.js b/app/ui/home.js
index cfa38564..aa12bedc 100644
--- a/app/ui/home.js
+++ b/app/ui/home.js
@@ -5,6 +5,9 @@ const modal = require('./modal');
const intro = require('./intro');
module.exports = function(state, emit) {
+ if (state.user.loginRequired && !state.user.loggedIn) {
+ emit('signup-cta', 'required');
+ }
const archives = state.storage.files
.filter(archive => !archive.expired)
.map(archive => archiveTile(state, emit, archive));
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`
+
+
+
+ `;
+ }
+ return html`
+
+
+
+
+ ${state.translate('reportFile')}
+
+
+ ${state.translate('reportDescription')}
+
+
+
+
+
+ `;
+
+ 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..a6cd0c4b 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',
@@ -150,9 +155,14 @@ const conf = convict({
default: `${tmpdir()}${path.sep}send-${randomBytes(4).toString('hex')}`,
env: 'FILE_DIR'
},
+ fxa_required: {
+ format: Boolean,
+ default: true,
+ env: 'FXA_REQUIRED'
+ },
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..c5c27c8d 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);
@@ -114,7 +117,7 @@ module.exports = function(app) {
app.get(`/api/metadata/:id${ID_REGEX}`, auth.hmac, require('./metadata'));
app.get('/api/filelist/:id([\\w-]{16})', auth.fxa, filelist.get);
app.post('/api/filelist/:id([\\w-]{16})', auth.fxa, filelist.post);
- app.post('/api/upload', auth.fxa, require('./upload'));
+ // app.post('/api/upload', auth.fxa, require('./upload'));
app.post(`/api/delete/:id${ID_REGEX}`, auth.owner, require('./delete'));
app.post(`/api/password/:id${ID_REGEX}`, auth.owner, require('./password'));
app.post(
@@ -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/routes/ws.js b/server/routes/ws.js
index 32ea7905..4d89d875 100644
--- a/server/routes/ws.js
+++ b/server/routes/ws.js
@@ -46,7 +46,8 @@ module.exports = function(ws, req) {
!auth ||
timeLimit <= 0 ||
timeLimit > maxExpireSeconds ||
- dlimit > maxDownloads
+ dlimit > maxDownloads ||
+ (config.fxa_required && !user)
) {
ws.send(
JSON.stringify({
diff --git a/server/state.js b/server/state.js
index 6947a721..914ffe31 100644
--- a/server/state.js
+++ b/server/state.js
@@ -15,7 +15,11 @@ module.exports = async function(req) {
try {
authConfig = await getFxaConfig();
authConfig.client_id = config.fxa_client_id;
+ authConfig.fxa_required = config.fxa_required;
} catch (e) {
+ if (config.auth_required) {
+ throw new Error('fxa_required is set but no config was found');
+ }
// continue without accounts
}
}
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
}