mirror of
https://gitlab.com/timvisee/send.git
synced 2025-12-07 22:50:53 +03:00
Begin implementing a reporting mechanism
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
53
server/keychain.js
Normal file
53
server/keychain.js
Normal file
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
39
server/routes/report.js
Normal file
39
server/routes/report.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user