From 408f50ebc7cc3c0887d1e77a5c04508517dc151e Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Mon, 12 Nov 2018 15:43:51 +0100 Subject: (ffmpeg) force pixel format yuv420p (#1394) --- server/helpers/ffmpeg-utils.ts | 1 + 1 file changed, 1 insertion(+) (limited to 'server') diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index a108d46a0..8b9045038 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -310,6 +310,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol .outputOption('-level 3.1') // 3.1 is the minimal ressource allocation for our highest supported resolution .outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorythm, 16 is optimal B-frames for it .outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16 + .outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video) .outputOption('-map_metadata -1') // strip all metadata .outputOption('-movflags faststart') -- cgit v1.2.3 From 1cf8aca11dcfec76e72cdb0edfb49bfc2bf4fdd8 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 9 Nov 2018 19:32:10 +0100 Subject: Rename context stats to state I guess it refers to the VideoState enum used here as `state` instead. --- server/helpers/activitypub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'server') diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index 278010e78..b0bcfe824 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -24,7 +24,7 @@ function activityPubContextify (data: T) { sensitive: 'as:sensitive', language: 'sc:inLanguage', views: 'sc:Number', - stats: 'sc:Number', + state: 'sc:Number', size: 'sc:Number', fps: 'sc:Number', commentsEnabled: 'sc:Boolean', -- cgit v1.2.3 From 891bc4f8bfa6ed8517dfffc9445db608a73d4917 Mon Sep 17 00:00:00 2001 From: BO41 Date: Tue, 13 Nov 2018 12:11:33 +0100 Subject: change video type --- server/lib/client-html.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'server') diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index fc013e0c3..006b25bfd 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -116,7 +116,7 @@ export class ClientHtml { 'og:video:url': embedUrl, 'og:video:secure_url': embedUrl, - 'og:video:type': 'text/html', + 'og:video:type': 'video/mp4', 'og:video:width': EMBED_SIZE.width, 'og:video:height': EMBED_SIZE.height, -- cgit v1.2.3 From fb651cf2d426e7429a7c571ab7d93becd63ad116 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 14 Nov 2018 08:18:50 +0100 Subject: Revert change og video type --- server/lib/client-html.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'server') diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 006b25bfd..fc013e0c3 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -116,7 +116,7 @@ export class ClientHtml { 'og:video:url': embedUrl, 'og:video:secure_url': embedUrl, - 'og:video:type': 'video/mp4', + 'og:video:type': 'text/html', 'og:video:width': EMBED_SIZE.width, 'og:video:height': EMBED_SIZE.height, -- cgit v1.2.3 From b83b8dd5aef03084133c5983de6f312e7d1654b8 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Wed, 14 Nov 2018 15:27:47 +0100 Subject: add cli option to run without client --- server/tests/api/server/index.ts | 1 + server/tests/api/server/no-client.ts | 36 ++++++++++++++++++++++++++++++++++++ server/tests/utils/server/servers.ts | 4 ++-- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 server/tests/api/server/no-client.ts (limited to 'server') diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts index eeb8b7a28..78ab7e18b 100644 --- a/server/tests/api/server/index.ts +++ b/server/tests/api/server/index.ts @@ -6,3 +6,4 @@ import './jobs' import './reverse-proxy' import './stats' import './tracker' +import './no-client' diff --git a/server/tests/api/server/no-client.ts b/server/tests/api/server/no-client.ts new file mode 100644 index 000000000..6d6ce8532 --- /dev/null +++ b/server/tests/api/server/no-client.ts @@ -0,0 +1,36 @@ +import 'mocha' +import * as request from 'supertest' +import { + flushTests, + killallServers, + ServerInfo +} from '../../utils/index' +import { runServer } from '../../utils/server/servers' + +describe('Start and stop server without web client routes', function () { + let server: ServerInfo + + before(async function () { + this.timeout(30000) + + await flushTests() + + server = await runServer(1, {}, ['--no-client']) + }) + + it('Should fail getting the client', function () { + const req = request(server.url) + .get('/') + + return req.expect(404) + }) + + after(async function () { + killallServers([ server ]) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/utils/server/servers.ts b/server/tests/utils/server/servers.ts index 3c946db27..f358a21f1 100644 --- a/server/tests/utils/server/servers.ts +++ b/server/tests/utils/server/servers.ts @@ -69,7 +69,7 @@ function flushTests () { }) } -function runServer (serverNumber: number, configOverride?: Object) { +function runServer (serverNumber: number, configOverride?: Object, args = []) { const server: ServerInfo = { app: null, serverNumber: serverNumber, @@ -115,7 +115,7 @@ function runServer (serverNumber: number, configOverride?: Object) { } return new Promise(res => { - server.app = fork(join(__dirname, '..', '..', '..', '..', 'dist', 'server.js'), [], options) + server.app = fork(join(__dirname, '..', '..', '..', '..', 'dist', 'server.js'), args, options) server.app.stdout.on('data', function onStdout (data) { let dontContinue = false -- cgit v1.2.3 From df66d81583e07ce049daeeef1edc6a87b57b3684 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Oct 2018 11:38:48 +0200 Subject: Add compatibility with other Linked Signature algorithms --- server/helpers/custom-jsonld-signature.ts | 4 +- server/helpers/peertube-crypto.ts | 71 +++++++- server/initializers/constants.ts | 2 +- .../handlers/utils/activitypub-http-utils.ts | 13 +- server/middlewares/activitypub.ts | 5 +- server/tests/activitypub.ts | 35 ---- server/tests/api/activitypub/client.ts | 35 ++++ server/tests/api/activitypub/helpers.ts | 182 +++++++++++++++++++++ server/tests/api/activitypub/index.ts | 3 + .../json/mastodon/bad-body-http-signature.json | 93 +++++++++++ .../json/mastodon/bad-http-signature.json | 93 +++++++++++ .../activitypub/json/mastodon/bad-public-key.json | 3 + .../json/mastodon/create-bad-signature.json | 81 +++++++++ .../api/activitypub/json/mastodon/create.json | 81 +++++++++ .../activitypub/json/mastodon/http-signature.json | 93 +++++++++++ .../api/activitypub/json/mastodon/public-key.json | 3 + .../json/peertube/announce-without-context.json | 13 ++ .../activitypub/json/peertube/invalid-keys.json | 6 + .../tests/api/activitypub/json/peertube/keys.json | 4 + server/tests/api/activitypub/security.ts | 180 ++++++++++++++++++++ server/tests/api/index-4.ts | 1 + server/tests/index.ts | 1 - server/tests/utils/index.ts | 2 + server/tests/utils/miscs/sql.ts | 29 ++++ server/tests/utils/miscs/stubs.ts | 14 ++ server/tests/utils/requests/activitypub.ts | 43 +++++ 26 files changed, 1038 insertions(+), 52 deletions(-) delete mode 100644 server/tests/activitypub.ts create mode 100644 server/tests/api/activitypub/client.ts create mode 100644 server/tests/api/activitypub/helpers.ts create mode 100644 server/tests/api/activitypub/index.ts create mode 100644 server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json create mode 100644 server/tests/api/activitypub/json/mastodon/bad-http-signature.json create mode 100644 server/tests/api/activitypub/json/mastodon/bad-public-key.json create mode 100644 server/tests/api/activitypub/json/mastodon/create-bad-signature.json create mode 100644 server/tests/api/activitypub/json/mastodon/create.json create mode 100644 server/tests/api/activitypub/json/mastodon/http-signature.json create mode 100644 server/tests/api/activitypub/json/mastodon/public-key.json create mode 100644 server/tests/api/activitypub/json/peertube/announce-without-context.json create mode 100644 server/tests/api/activitypub/json/peertube/invalid-keys.json create mode 100644 server/tests/api/activitypub/json/peertube/keys.json create mode 100644 server/tests/api/activitypub/security.ts create mode 100644 server/tests/utils/miscs/sql.ts create mode 100644 server/tests/utils/miscs/stubs.ts create mode 100644 server/tests/utils/requests/activitypub.ts (limited to 'server') diff --git a/server/helpers/custom-jsonld-signature.ts b/server/helpers/custom-jsonld-signature.ts index e4f28018e..27a187db1 100644 --- a/server/helpers/custom-jsonld-signature.ts +++ b/server/helpers/custom-jsonld-signature.ts @@ -1,5 +1,5 @@ import * as AsyncLRU from 'async-lru' -import * as jsonld from 'jsonld/' +import * as jsonld from 'jsonld' import * as jsig from 'jsonld-signatures' const nodeDocumentLoader = jsonld.documentLoaders.node() @@ -17,4 +17,4 @@ jsonld.documentLoader = (url, cb) => { jsig.use('jsonld', jsonld) -export { jsig } +export { jsig, jsonld } diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts index 8ef7b1359..ab9ec077e 100644 --- a/server/helpers/peertube-crypto.ts +++ b/server/helpers/peertube-crypto.ts @@ -1,9 +1,12 @@ import { Request } from 'express' import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers' import { ActorModel } from '../models/activitypub/actor' -import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey } from './core-utils' -import { jsig } from './custom-jsonld-signature' +import { bcryptComparePromise, bcryptGenSaltPromise, bcryptHashPromise, createPrivateKey, getPublicKey, sha256 } from './core-utils' +import { jsig, jsonld } from './custom-jsonld-signature' import { logger } from './logger' +import { cloneDeep } from 'lodash' +import { createVerify } from 'crypto' +import { buildDigest } from '../lib/job-queue/handlers/utils/activitypub-http-utils' const httpSignature = require('http-signature') @@ -30,21 +33,36 @@ async function cryptPassword (password: string) { // HTTP Signature -function isHTTPSignatureVerified (httpSignatureParsed: any, actor: ActorModel) { +function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean { + if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) { + return buildDigest(rawBody.toString()) === req.headers['digest'] + } + + return true +} + +function isHTTPSignatureVerified (httpSignatureParsed: any, actor: ActorModel): boolean { return httpSignature.verifySignature(httpSignatureParsed, actor.publicKey) === true } -function parseHTTPSignature (req: Request) { - return httpSignature.parse(req, { authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME }) +function parseHTTPSignature (req: Request, clockSkew?: number) { + return httpSignature.parse(req, { authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, clockSkew }) } // JSONLD -function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any) { +async function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any): Promise { + if (signedDocument.signature.type === 'RsaSignature2017') { + // Mastodon algorithm + const res = await isJsonLDRSA2017Verified(fromActor, signedDocument) + // Success? If no, try with our library + if (res === true) return true + } + const publicKeyObject = { '@context': jsig.SECURITY_CONTEXT_URL, id: fromActor.url, - type: 'CryptographicKey', + type: 'CryptographicKey', owner: fromActor.url, publicKeyPem: fromActor.publicKey } @@ -69,6 +87,44 @@ function isJsonLDSignatureVerified (fromActor: ActorModel, signedDocument: any) }) } +// Backward compatibility with "other" implementations +async function isJsonLDRSA2017Verified (fromActor: ActorModel, signedDocument: any) { + function hash (obj: any): Promise { + return jsonld.promises + .normalize(obj, { + algorithm: 'URDNA2015', + format: 'application/n-quads' + }) + .then(res => sha256(res)) + } + + const signatureCopy = cloneDeep(signedDocument.signature) + Object.assign(signatureCopy, { + '@context': [ + 'https://w3id.org/security/v1', + { RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' } + ] + }) + delete signatureCopy.type + delete signatureCopy.id + delete signatureCopy.signatureValue + + const docWithoutSignature = cloneDeep(signedDocument) + delete docWithoutSignature.signature + + const [ documentHash, optionsHash ] = await Promise.all([ + hash(docWithoutSignature), + hash(signatureCopy) + ]) + + const toVerify = optionsHash + documentHash + + const verify = createVerify('RSA-SHA256') + verify.update(toVerify, 'utf8') + + return verify.verify(fromActor.publicKey, signedDocument.signature.signatureValue, 'base64') +} + function signJsonLDObject (byActor: ActorModel, data: any) { const options = { privateKeyPem: byActor.privateKey, @@ -82,6 +138,7 @@ function signJsonLDObject (byActor: ActorModel, data: any) { // --------------------------------------------------------------------------- export { + isHTTPSignatureDigestValid, parseHTTPSignature, isHTTPSignatureVerified, isJsonLDSignatureVerified, diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 28d51068b..9aadbe824 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -535,7 +535,7 @@ const ACTIVITY_PUB_ACTOR_TYPES: { [ id: string ]: ActivityPubActorType } = { const HTTP_SIGNATURE = { HEADER_NAME: 'signature', ALGORITHM: 'rsa-sha256', - HEADERS_TO_SIGN: [ 'date', 'host', 'digest', '(request-target)' ] + HEADERS_TO_SIGN: [ '(request-target)', 'host', 'date', 'digest' ] } // --------------------------------------------------------------------------- diff --git a/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts b/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts index fd9c74341..4961d4502 100644 --- a/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts +++ b/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts @@ -38,15 +38,20 @@ async function buildSignedRequestOptions (payload: Payload) { } } -function buildGlobalHeaders (body: object) { - const digest = 'SHA-256=' + sha256(JSON.stringify(body), 'base64') - +function buildGlobalHeaders (body: any) { return { - 'Digest': digest + 'Digest': buildDigest(body) } } +function buildDigest (body: any) { + const rawBody = typeof body === 'string' ? body : JSON.stringify(body) + + return 'SHA-256=' + sha256(rawBody, 'base64') +} + export { + buildDigest, buildGlobalHeaders, computeBody, buildSignedRequestOptions diff --git a/server/middlewares/activitypub.ts b/server/middlewares/activitypub.ts index 1ec888477..01e5dd24e 100644 --- a/server/middlewares/activitypub.ts +++ b/server/middlewares/activitypub.ts @@ -53,7 +53,8 @@ function executeIfActivityPub (fun: RequestHandler | RequestHandler[]) { export { checkSignature, - executeIfActivityPub + executeIfActivityPub, + checkHttpSignature } // --------------------------------------------------------------------------- @@ -94,7 +95,7 @@ async function checkHttpSignature (req: Request, res: Response) { async function checkJsonLDSignature (req: Request, res: Response) { const signatureObject: ActivityPubSignature = req.body.signature - if (!signatureObject.creator) { + if (!signatureObject || !signatureObject.creator) { res.sendStatus(403) return false } diff --git a/server/tests/activitypub.ts b/server/tests/activitypub.ts deleted file mode 100644 index 53a04d363..000000000 --- a/server/tests/activitypub.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* tslint:disable:no-unused-expression */ - -import * as chai from 'chai' -import 'mocha' -import { flushTests, killallServers, makeActivityPubGetRequest, runServer, ServerInfo, setAccessTokensToServers } from './utils' - -const expect = chai.expect - -describe('Test activitypub', function () { - let server: ServerInfo = null - - before(async function () { - this.timeout(30000) - - await flushTests() - - server = await runServer(1) - - await setAccessTokensToServers([ server ]) - }) - - it('Should return the account object', async function () { - const res = await makeActivityPubGetRequest(server.url, '/accounts/root') - const object = res.body - - expect(object.type).to.equal('Person') - expect(object.id).to.equal('http://localhost:9001/accounts/root') - expect(object.name).to.equal('root') - expect(object.preferredUsername).to.equal('root') - }) - - after(async function () { - killallServers([ server ]) - }) -}) diff --git a/server/tests/api/activitypub/client.ts b/server/tests/api/activitypub/client.ts new file mode 100644 index 000000000..5ca8bdfd3 --- /dev/null +++ b/server/tests/api/activitypub/client.ts @@ -0,0 +1,35 @@ +/* tslint:disable:no-unused-expression */ + +import * as chai from 'chai' +import 'mocha' +import { flushTests, killallServers, makeActivityPubGetRequest, runServer, ServerInfo, setAccessTokensToServers } from '../../utils' + +const expect = chai.expect + +describe('Test activitypub', function () { + let server: ServerInfo = null + + before(async function () { + this.timeout(30000) + + await flushTests() + + server = await runServer(1) + + await setAccessTokensToServers([ server ]) + }) + + it('Should return the account object', async function () { + const res = await makeActivityPubGetRequest(server.url, '/accounts/root') + const object = res.body + + expect(object.type).to.equal('Person') + expect(object.id).to.equal('http://localhost:9001/accounts/root') + expect(object.name).to.equal('root') + expect(object.preferredUsername).to.equal('root') + }) + + after(async function () { + killallServers([ server ]) + }) +}) diff --git a/server/tests/api/activitypub/helpers.ts b/server/tests/api/activitypub/helpers.ts new file mode 100644 index 000000000..610846247 --- /dev/null +++ b/server/tests/api/activitypub/helpers.ts @@ -0,0 +1,182 @@ +/* tslint:disable:no-unused-expression */ + +import 'mocha' +import { expect } from 'chai' +import { buildRequestStub } from '../../utils' +import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../../../helpers/peertube-crypto' +import { cloneDeep } from 'lodash' +import { buildSignedActivity } from '../../../helpers/activitypub' + +describe('Test activity pub helpers', function () { + describe('When checking the Linked Signature', function () { + + it('Should fail with an invalid Mastodon signature', async function () { + const body = require('./json/mastodon/create-bad-signature.json') + const publicKey = require('./json/mastodon/public-key.json').publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.false + }) + + it('Should fail with an invalid public key', async function () { + const body = require('./json/mastodon/create.json') + const publicKey = require('./json/mastodon/bad-public-key.json').publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.false + }) + + it('Should succeed with a valid Mastodon signature', async function () { + const body = require('./json/mastodon/create.json') + const publicKey = require('./json/mastodon/public-key.json').publicKey + const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } + + const result = await isJsonLDSignatureVerified(fromActor as any, body) + + expect(result).to.be.true + }) + + it('Should fail with an invalid PeerTube signature', async function () { + const keys = require('./json/peertube/invalid-keys.json') + const body = require('./json/peertube/announce-without-context.json') + + const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + const signedBody = await buildSignedActivity(actorSignature as any, body) + + const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } + const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + + expect(result).to.be.false + }) + + it('Should fail with an invalid PeerTube URL', async function () { + const keys = require('./json/peertube/keys.json') + const body = require('./json/peertube/announce-without-context.json') + + const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + const signedBody = await buildSignedActivity(actorSignature as any, body) + + const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9003/accounts/peertube' } + const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + + expect(result).to.be.false + }) + + it('Should succeed with a valid PeerTube signature', async function () { + const keys = require('./json/peertube/keys.json') + const body = require('./json/peertube/announce-without-context.json') + + const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + const signedBody = await buildSignedActivity(actorSignature as any, body) + + const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } + const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + + expect(result).to.be.true + }) + }) + + describe('When checking HTTP signature', function () { + it('Should fail with an invalid http signature', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(require('./json/mastodon/bad-http-signature.json')) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + req.headers.signature = 'Signature ' + req.headers.signature + + const parsed = parseHTTPSignature(req, 3600 * 365 * 3) + const publicKey = require('./json/mastodon/public-key.json').publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.false + }) + + it('Should fail with an invalid public key', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + req.headers.signature = 'Signature ' + req.headers.signature + + const parsed = parseHTTPSignature(req, 3600 * 365 * 3) + const publicKey = require('./json/mastodon/bad-public-key.json').publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.false + }) + + it('Should fail because of clock skew', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + req.headers.signature = 'Signature ' + req.headers.signature + + let errored = false + try { + parseHTTPSignature(req) + } catch { + errored = true + } + + expect(errored).to.be.true + }) + + it('Should fail without scheme', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + + let errored = false + try { + parseHTTPSignature(req, 3600 * 365 * 3) + } catch { + errored = true + } + + expect(errored).to.be.true + }) + + it('Should succeed with a valid signature', async function () { + const req = buildRequestStub() + req.method = 'POST' + req.url = '/accounts/ronan/inbox' + + const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) + req.body = mastodonObject.body + req.headers = mastodonObject.headers + req.headers.signature = 'Signature ' + req.headers.signature + + const parsed = parseHTTPSignature(req, 3600 * 365 * 3) + const publicKey = require('./json/mastodon/public-key.json').publicKey + + const actor = { publicKey } + const verified = isHTTPSignatureVerified(parsed, actor as any) + + expect(verified).to.be.true + }) + + }) + +}) diff --git a/server/tests/api/activitypub/index.ts b/server/tests/api/activitypub/index.ts new file mode 100644 index 000000000..de8a59978 --- /dev/null +++ b/server/tests/api/activitypub/index.ts @@ -0,0 +1,3 @@ +import './client' +import './helpers' +import './security' diff --git a/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json b/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json new file mode 100644 index 000000000..4e7bc3af5 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/server/tests/api/activitypub/json/mastodon/bad-http-signature.json b/server/tests/api/activitypub/json/mastodon/bad-http-signature.json new file mode 100644 index 000000000..098597db0 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/bad-http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl4wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/server/tests/api/activitypub/json/mastodon/bad-public-key.json b/server/tests/api/activitypub/json/mastodon/bad-public-key.json new file mode 100644 index 000000000..73d18b3ad --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/bad-public-key.json @@ -0,0 +1,3 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl77j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n" +} diff --git a/server/tests/api/activitypub/json/mastodon/create-bad-signature.json b/server/tests/api/activitypub/json/mastodon/create-bad-signature.json new file mode 100644 index 000000000..2cd037241 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/create-bad-signature.json @@ -0,0 +1,81 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T12:43:07Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T12:43:07Z", + "url": "http://localhost:3000/@ronan2/100939345950887698", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zerg

", + "contentMap": { + "en": "

@ronan zerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T12:43:08Z", + "signatureValue": "Vgr8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ==" + } +} diff --git a/server/tests/api/activitypub/json/mastodon/create.json b/server/tests/api/activitypub/json/mastodon/create.json new file mode 100644 index 000000000..0be271bb8 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/create.json @@ -0,0 +1,81 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T12:43:07Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T12:43:07Z", + "url": "http://localhost:3000/@ronan2/100939345950887698", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939345950887698", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zerg

", + "contentMap": { + "en": "

@ronan zerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T12:43:08Z", + "signatureValue": "VgR8nA0agPr9TcA4BlX+MWhmuE+rBcoIJLpnPbm3E5SnOCXbgjEfEaTLqfuzzkKNsR3PBbkvi3YWK4/DxJ0zmpzSB7yy4NRzluQMVQHqJiFKXAX3Sr3fIrK24xkWW9/F207c1NpFajSGbgnFKBdtFE0e5VqwSrSoOJkZukZW/2ATSnsyzblieuUmvTWpD0PqpUOsynPjw+RqZnqPn0cjw1z2Dm7ZRt3trnyMTXFYZw5U/YuqMY2kpadD6vq780md8kXlJIylxG6ZrlO2jz9fJdnfuVq43d4QFNsBm1K1r2WtNqX+i+wiqh+u3PjF4pzXtl/a3hJOH18IfZnK7I21mQ==" + } +} diff --git a/server/tests/api/activitypub/json/mastodon/http-signature.json b/server/tests/api/activitypub/json/mastodon/http-signature.json new file mode 100644 index 000000000..4e7bc3af5 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/http-signature.json @@ -0,0 +1,93 @@ +{ + "headers": { + "user-agent": "http.rb/3.3.0 (Mastodon/2.5.0; +http://localhost:3000/)", + "host": "localhost", + "date": "Mon, 22 Oct 2018 13:34:22 GMT", + "accept-encoding": "gzip", + "digest": "SHA-256=FEr5j2WSSfdEMcG3NTOXuGU0lUchfTJx4+BtUlWOwDk=", + "content-type": "application/activity+json", + "signature": "keyId=\"http://localhost:3000/users/ronan2#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest content-type\",signature=\"oLKbgxdFXdXsHJ3x/UsG9Svu7oa8Dyqiy6Jif4wqNuhAqRVMRaG18f+dd2OcfFX3XRGF8p8flZkU6vvoEQBauTwGRGcgXAJuKC1zYIWGk+PeiW8lNUnE4qGapWcTiFnIo7FKauNdsgqg/tvgs1pQIdHkDDjZMI64twP7sTN/4vG1PCq+kyqi/DM+ORLi/W7vFuLVHt2Iz7ikfw/R3/mMtS4FwLops+tVYBQ2iQ9DVRhTwLKVbeL/LLVB/tdGzNZ4F4nImBAQQ9I7WpPM6J/k+cBmoEbrUKs8ptx9gbX3OSsl5wlvPVMNzU9F9yb2MrB/Y/J4qssKz+LbiaktKGj7OQ==\"", + "content-length": "2815" + }, + "body": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "Hashtag": "as:Hashtag", + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "toot": "http://joinmastodon.org/ns#", + "Emoji": "toot:Emoji", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948/activity", + "type": "Create", + "actor": "http://localhost:3000/users/ronan2", + "published": "2018-10-22T13:34:18Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "object": { + "id": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "type": "Note", + "summary": null, + "inReplyTo": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "published": "2018-10-22T13:34:18Z", + "url": "http://localhost:3000/@ronan2/100939547203370948", + "attributedTo": "http://localhost:3000/users/ronan2", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://localhost:3000/users/ronan2/followers", + "http://localhost:9000/accounts/ronan" + ], + "sensitive": false, + "atomUri": "http://localhost:3000/users/ronan2/statuses/100939547203370948", + "inReplyToAtomUri": "http://localhost:9000/videos/watch/90e6f8ed-b369-423c-b0c8-f44e5350c752", + "conversation": "tag:localhost:3000,2018-10-19:objectId=72:objectType=Conversation", + "content": "

@ronan zergzerg

", + "contentMap": { + "en": "

@ronan zergzerg

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "http://localhost:9000/accounts/ronan", + "name": "@ronan@localhost:9000" + } + ] + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://localhost:3000/users/ronan2#main-key", + "created": "2018-10-22T13:34:19Z", + "signatureValue": "x+xL4l8ERziYVhwEafHJyBQOInvNZ0gV4ccYd9AtFYeGJagc8fY6jjjhbDRCD7yMhgTjBX69z20MXnDuwpmM6wej3dt1wLKdIyXVViO84nAlqFz7KmNxtk5lDnAVX/vttscT5YUFvw4dbPT2mQiEd1lKbaLftRiIPEomZpQ37+fUkQdcPrnhruPAISO/Sof1n1LFW4mYIffozteQSZBH6HaCVp+MRMIhdMi5e8w7PD48/cZz8D/EU8Vqi91FM76/3tMqg6nLqQ+8bq74Jvt2kzwZlIufe+I55QMpZOmF6hGIJEt+R0JXdjQbtgcELONmNj2dr8sAlzu7zKlAGuJ24Q==" + } + } +} diff --git a/server/tests/api/activitypub/json/mastodon/public-key.json b/server/tests/api/activitypub/json/mastodon/public-key.json new file mode 100644 index 000000000..b7b9b8308 --- /dev/null +++ b/server/tests/api/activitypub/json/mastodon/public-key.json @@ -0,0 +1,3 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0YyuthHtWWgDe0Fdgdp2\ndC5dTJsRqW6pFw5omIYYYjoES/WRewhVxEA54BhmxD3L1zChfx131N1TS8jVowhW\nm999jpUffKCCvLgYKIXETJDHiDeMONVx8wp7v9fS1HiFXo/E5und39gUMs14CMFZ\n6PE5jRV3r4XIKQJHQl7/X5n5FOb2934K+1TKUeBkbft/AushlKatYQakt3qHxpwx\nFvE+JjGo7QTnzdjaOx/e5QvojdGi2Kx4+jl87j2WVcSo5lOBz04OAVJtChtn82vS\nulPdDh3hZcDn+WK67yAhGP6AnzvOybZZS4zowlKiQ3kqjVVXKdl8gAsL4Y7MZ40R\nJQIDAQAB\n-----END PUBLIC KEY-----\n" +} diff --git a/server/tests/api/activitypub/json/peertube/announce-without-context.json b/server/tests/api/activitypub/json/peertube/announce-without-context.json new file mode 100644 index 000000000..5f2af0cde --- /dev/null +++ b/server/tests/api/activitypub/json/peertube/announce-without-context.json @@ -0,0 +1,13 @@ +{ + "type": "Announce", + "id": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05/announces/1", + "actor": "http://localhost:9002/accounts/peertube", + "object": "http://localhost:9002/videos/watch/997111d4-e8d8-4f45-99d3-857905785d05", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://localhost:9002/accounts/peertube/followers", + "http://localhost:9002/video-channels/root_channel/followers", + "http://localhost:9002/accounts/root/followers" + ], + "cc": [] +} diff --git a/server/tests/api/activitypub/json/peertube/invalid-keys.json b/server/tests/api/activitypub/json/peertube/invalid-keys.json new file mode 100644 index 000000000..0544e96b9 --- /dev/null +++ b/server/tests/api/activitypub/json/peertube/invalid-keys.json @@ -0,0 +1,6 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw2Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----" +} + + diff --git a/server/tests/api/activitypub/json/peertube/keys.json b/server/tests/api/activitypub/json/peertube/keys.json new file mode 100644 index 000000000..1a7700865 --- /dev/null +++ b/server/tests/api/activitypub/json/peertube/keys.json @@ -0,0 +1,4 @@ +{ + "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjQGdH6D3naKmSbbr/Df\nEh1H42F3WlHYXuxKLkm5Bemjdde+GwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYO\nwAyc3Zoy7afPNa4bZXqhJ1Im41rMGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55s\nIkczDkseJuadTvG+A1e4uNY2lnRmVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/F\npP5S75TS5l1DfJQIq2lp8RwrH6FvGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM\n7mS7eP8zF8lKXYUu8cjIscKm+XqGmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKdd\naQIDAQAB\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAqjQGdH6D3naKmSbbr/DfEh1H42F3WlHYXuxKLkm5Bemjdde+\nGwHYdz5m3fcIWw3HTzfA+y9Of8epGdfSrtYOwAyc3Zoy7afPNa4bZXqhJ1Im41rM\nGieiCuUn4uTPPucIjC0gCkVwvuQr3Elbk55sIkczDkseJuadTvG+A1e4uNY2lnRm\nVhf4g5B90u6CLe2KdbPpifRoKlw9zaUBj4/FpP5S75TS5l1DfJQIq2lp8RwrH6Fv\nGKLnWlbGeNYX96DDvlA5Sxoxz6a+bTV9OopM7mS7eP8zF8lKXYUu8cjIscKm+XqG\nmyRoPyw3Pp53tew29idRUocVQHGBnlNbpKddaQIDAQABAoIBAQCnBZawCtbtH/ay\ng+dhqEW/SOyavbKZ92cU/1tsQPxISRYXNjdf2VfK7HmVqC2S7NqBanz+AVZPHmda\n7OfamkSvQbFN5VvEy8ATNV+9HbG3HG78/MT9hZcGigmyJkcZuy4wILgoXCxfpxlD\netla60PB/4yioiRcmEIWjjOgpByphDJ7RuuuptyEvgjUjpPtvHK47O/loaD2HFJk\nbIYbRirbjUjITRjQxGVIvanqiwPG9pB26YDLxDOoXEumcnzRcEFWNdvoleaLgquS\nn/zVsXWEq4+1i7t44DDstWUt/2Bw5ksIkSdayQ6oy3vzre3YFHwvbVZ7qtQQgpru\nx+NIolZhAoGBAN1RgNj8zy9Py3SJdsoXtnuCItfD7eo7LWXUa06cM/NS695Q+/to\naa5i3cJnRlv+b+b3VvnhkhIBLfFQW+hWwPnnxJEehcm09ddN9zbWrZ4Yv9yYu+8d\nTLGyWL8kPFF1dz+29DcrSv3tXEOwxByX/O4U/X/i3wl2WhkybxVFnCuvAoGBAMTf\n91BgLzvcYKOxH+vRPOJY7g2HKGFe35R91M4E+9Eq1rq4LUQHBb3fhRh4+scNu0yb\nNfN1Zdx2nbgCXdTKomF1Ahxp58/A2iU65vVzL6hYfWXEGSmoBqsGCIpIxQ9jgB9k\nCl7t/Ban8Z/ORHTjI9fpHlSZyCWJ3ajepiM2a1ZnAoGAPpDO6wi1DXvyWVSPF1yS\nwuGsNfD2rjPihpoBZ+yypwP3GBcu1QjUb28Vn+KQOmt4eQPNO8DwCVT6BvEfulPk\nJAHISPom+jnFEgPBcmhIFpyKiLNI1bUjvExd2FNHFgQuHP38ligQAC782Un8dtTk\ntO2MKH4bbVJe8CaYzpuqJZMCgYABZyMpBHZxs8FQiUuT75rCdiXEHOlxwC5RrY/d\no/VzaR28mOFhsbcdwkD9iqcm0fc6tYRt5rFCH+pBzGqEwKjljuLj9vE67sHfMAtD\nRn3Zcj/6gKo5PMRHZbSb36bf1DKuhpT4VjPMqYe0PtEIEDJKMJQRwELH2bKlqGiA\nqbucEwKBgQCkS85JnpHEV/tSylsEEn2W3CQCx58zl7iZNV7h/tWMR4AyrcI0HqP6\nllJ7V/Cfw66MgelPnosKgagwLVI6gsqDtjnzYo3XuMRVlYIySJ/jV3eiUNkV2Ky2\nfp/gA9sVgp38QSr+xB9E0LNStcbqDzoCCcDRws/SK7PbkQH9KV47tQ==\n-----END RSA PRIVATE KEY-----" +} diff --git a/server/tests/api/activitypub/security.ts b/server/tests/api/activitypub/security.ts new file mode 100644 index 000000000..c5428abbb --- /dev/null +++ b/server/tests/api/activitypub/security.ts @@ -0,0 +1,180 @@ +/* tslint:disable:no-unused-expression */ + +import 'mocha' + +import { flushAndRunMultipleServers, flushTests, killallServers, makeAPRequest, makeFollowRequest, ServerInfo } from '../../utils' +import { HTTP_SIGNATURE } from '../../../initializers' +import { buildDigest, buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils' +import * as chai from 'chai' +import { setActorField } from '../../utils/miscs/sql' +import { activityPubContextify, buildSignedActivity } from '../../../helpers/activitypub' + +const expect = chai.expect + +function setKeysOfServer2 (serverNumber: number, publicKey: string, privateKey: string) { + return Promise.all([ + setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'publicKey', publicKey), + setActorField(serverNumber, 'http://localhost:9002/accounts/peertube', 'privateKey', privateKey) + ]) +} + +function setKeysOfServer3 (serverNumber: number, publicKey: string, privateKey: string) { + return Promise.all([ + setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'publicKey', publicKey), + setActorField(serverNumber, 'http://localhost:9003/accounts/peertube', 'privateKey', privateKey) + ]) +} + +describe('Test ActivityPub security', function () { + let servers: ServerInfo[] + let url: string + + const keys = require('./json/peertube/keys.json') + const invalidKeys = require('./json/peertube/invalid-keys.json') + const baseHttpSignature = { + algorithm: HTTP_SIGNATURE.ALGORITHM, + authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, + keyId: 'acct:peertube@localhost:9002', + key: keys.privateKey, + headers: HTTP_SIGNATURE.HEADERS_TO_SIGN + } + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + servers = await flushAndRunMultipleServers(3) + + url = servers[0].url + '/inbox' + + await setKeysOfServer2(1, keys.publicKey, keys.privateKey) + + const to = { url: 'http://localhost:9001/accounts/peertube' } + const by = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } + await makeFollowRequest(to, by) + }) + + describe('When checking HTTP signature', function () { + + it('Should fail with an invalid digest', async function () { + const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) + const headers = { + Digest: buildDigest({ hello: 'coucou' }) + } + + const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(403) + }) + + it('Should fail with an invalid date', async function () { + const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) + const headers = buildGlobalHeaders(body) + headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT' + + const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(403) + }) + + it('Should fail with bad keys', async function () { + await setKeysOfServer2(1, invalidKeys.publicKey, invalidKeys.privateKey) + await setKeysOfServer2(2, invalidKeys.publicKey, invalidKeys.privateKey) + + const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) + const headers = buildGlobalHeaders(body) + + const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(403) + }) + + it('Should succeed with a valid HTTP signature', async function () { + await setKeysOfServer2(1, keys.publicKey, keys.privateKey) + await setKeysOfServer2(2, keys.publicKey, keys.privateKey) + + const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) + const headers = buildGlobalHeaders(body) + + const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(204) + }) + }) + + describe('When checking Linked Data Signature', function () { + before(async () => { + await setKeysOfServer3(3, keys.publicKey, keys.privateKey) + + const to = { url: 'http://localhost:9001/accounts/peertube' } + const by = { url: 'http://localhost:9003/accounts/peertube', privateKey: keys.privateKey } + await makeFollowRequest(to, by) + }) + + it('Should fail with bad keys', async function () { + this.timeout(10000) + + await setKeysOfServer3(1, invalidKeys.publicKey, invalidKeys.privateKey) + await setKeysOfServer3(3, invalidKeys.publicKey, invalidKeys.privateKey) + + const body = require('./json/peertube/announce-without-context.json') + body.actor = 'http://localhost:9003/accounts/peertube' + + const signer: any = { privateKey: invalidKeys.privateKey, url: 'http://localhost:9003/accounts/peertube' } + const signedBody = await buildSignedActivity(signer, body) + + const headers = buildGlobalHeaders(signedBody) + + const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(403) + }) + + it('Should fail with an altered body', async function () { + this.timeout(10000) + + await setKeysOfServer3(1, keys.publicKey, keys.privateKey) + await setKeysOfServer3(3, keys.publicKey, keys.privateKey) + + const body = require('./json/peertube/announce-without-context.json') + body.actor = 'http://localhost:9003/accounts/peertube' + + const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' } + const signedBody = await buildSignedActivity(signer, body) + + signedBody.actor = 'http://localhost:9003/account/peertube' + + const headers = buildGlobalHeaders(signedBody) + + const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(403) + }) + + it('Should succeed with a valid signature', async function () { + this.timeout(10000) + + const body = require('./json/peertube/announce-without-context.json') + body.actor = 'http://localhost:9003/accounts/peertube' + + const signer: any = { privateKey: keys.privateKey, url: 'http://localhost:9003/accounts/peertube' } + const signedBody = await buildSignedActivity(signer, body) + + const headers = buildGlobalHeaders(signedBody) + + const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + + expect(response.statusCode).to.equal(204) + }) + }) + + after(async function () { + killallServers(servers) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/api/index-4.ts b/server/tests/api/index-4.ts index 8e69b95a6..7d8be2b3d 100644 --- a/server/tests/api/index-4.ts +++ b/server/tests/api/index-4.ts @@ -1 +1,2 @@ import './redundancy' +import './activitypub' diff --git a/server/tests/index.ts b/server/tests/index.ts index e659fd3df..ed16d65dd 100644 --- a/server/tests/index.ts +++ b/server/tests/index.ts @@ -1,6 +1,5 @@ // Order of the tests we want to execute import './client' -import './activitypub' import './feeds/' import './cli/' import './api/' diff --git a/server/tests/utils/index.ts b/server/tests/utils/index.ts index 897389824..905d93823 100644 --- a/server/tests/utils/index.ts +++ b/server/tests/utils/index.ts @@ -4,8 +4,10 @@ export * from './server/clients' export * from './server/config' export * from './users/login' export * from './miscs/miscs' +export * from './miscs/stubs' export * from './server/follows' export * from './requests/requests' +export * from './requests/activitypub' export * from './server/servers' export * from './videos/services' export * from './users/users' diff --git a/server/tests/utils/miscs/sql.ts b/server/tests/utils/miscs/sql.ts new file mode 100644 index 000000000..204ff5163 --- /dev/null +++ b/server/tests/utils/miscs/sql.ts @@ -0,0 +1,29 @@ +import * as Sequelize from 'sequelize' + +function getSequelize (serverNumber: number) { + const dbname = 'peertube_test' + serverNumber + const username = 'peertube' + const password = 'peertube' + const host = 'localhost' + const port = 5432 + + return new Sequelize(dbname, username, password, { + dialect: 'postgres', + host, + port, + operatorsAliases: false, + logging: false + }) +} + +function setActorField (serverNumber: number, to: string, field: string, value: string) { + const seq = getSequelize(serverNumber) + + const options = { type: Sequelize.QueryTypes.UPDATE } + + return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options) +} + +export { + setActorField +} diff --git a/server/tests/utils/miscs/stubs.ts b/server/tests/utils/miscs/stubs.ts new file mode 100644 index 000000000..d1eb0e3b2 --- /dev/null +++ b/server/tests/utils/miscs/stubs.ts @@ -0,0 +1,14 @@ +function buildRequestStub (): any { + return { } +} + +function buildResponseStub (): any { + return { + locals: {} + } +} + +export { + buildResponseStub, + buildRequestStub +} diff --git a/server/tests/utils/requests/activitypub.ts b/server/tests/utils/requests/activitypub.ts new file mode 100644 index 000000000..e3e08ce67 --- /dev/null +++ b/server/tests/utils/requests/activitypub.ts @@ -0,0 +1,43 @@ +import { doRequest } from '../../../helpers/requests' +import { HTTP_SIGNATURE } from '../../../initializers' +import { buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils' +import { activityPubContextify } from '../../../helpers/activitypub' + +function makeAPRequest (url: string, body: any, httpSignature: any, headers: any) { + const options = { + method: 'POST', + uri: url, + json: body, + httpSignature, + headers + } + + return doRequest(options) +} + +async function makeFollowRequest (to: { url: string }, by: { url: string, privateKey }) { + const follow = { + type: 'Follow', + id: by.url + '/toto', + actor: by.url, + object: to.url + } + + const body = activityPubContextify(follow) + + const httpSignature = { + algorithm: HTTP_SIGNATURE.ALGORITHM, + authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, + keyId: by.url, + key: by.privateKey, + headers: HTTP_SIGNATURE.HEADERS_TO_SIGN + } + const headers = buildGlobalHeaders(body) + + return makeAPRequest(to.url, body, httpSignature, headers) +} + +export { + makeAPRequest, + makeFollowRequest +} -- cgit v1.2.3 From 5c6d985faeef1d6793d3f44ca6374f1a9b722806 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 14 Nov 2018 15:01:28 +0100 Subject: Check activities host --- server/controllers/activitypub/client.ts | 36 +++++++++++-- server/controllers/activitypub/inbox.ts | 6 ++- server/controllers/api/videos/rate.ts | 17 +++--- server/helpers/activitypub.ts | 9 ++++ server/helpers/requests.ts | 4 +- server/initializers/constants.ts | 5 +- .../migrations/0290-account-video-rate-url.ts | 46 +++++++++++++++++ server/lib/activitypub/actor.ts | 13 +++-- server/lib/activitypub/crawl.ts | 5 +- server/lib/activitypub/process/index.ts | 8 --- server/lib/activitypub/process/process-create.ts | 5 +- server/lib/activitypub/process/process-like.ts | 4 +- server/lib/activitypub/process/process-undo.ts | 6 ++- server/lib/activitypub/process/process.ts | 25 ++++++--- server/lib/activitypub/send/send-create.ts | 10 ++-- server/lib/activitypub/send/send-like.ts | 2 +- server/lib/activitypub/send/send-undo.ts | 2 +- server/lib/activitypub/share.ts | 15 ++++-- server/lib/activitypub/url.ts | 12 ++--- server/lib/activitypub/video-comments.ts | 17 ++++++ server/lib/activitypub/video-rates.ts | 36 +++++++++++-- server/lib/activitypub/videos.ts | 7 ++- .../job-queue/handlers/activitypub-http-fetcher.ts | 2 +- server/middlewares/validators/videos/index.ts | 2 + .../middlewares/validators/videos/video-rates.ts | 55 ++++++++++++++++++++ .../middlewares/validators/videos/video-shares.ts | 38 ++++++++++++++ server/middlewares/validators/videos/videos.ts | 40 --------------- server/models/account/account-video-rate.ts | 60 ++++++++++++++++++++-- server/models/oauth/oauth-token.ts | 2 +- server/models/video/video-share.ts | 2 +- server/tests/api/activitypub/security.ts | 16 +++--- server/tests/utils/requests/activitypub.ts | 6 +-- 32 files changed, 394 insertions(+), 119 deletions(-) create mode 100644 server/initializers/migrations/0290-account-video-rate-url.ts create mode 100644 server/middlewares/validators/videos/video-rates.ts create mode 100644 server/middlewares/validators/videos/video-shares.ts (limited to 'server') diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts index 433186179..ffbf1ba19 100644 --- a/server/controllers/activitypub/client.ts +++ b/server/controllers/activitypub/client.ts @@ -3,17 +3,22 @@ import * as express from 'express' import { VideoPrivacy, VideoRateType } from '../../../shared/models/videos' import { activityPubCollectionPagination, activityPubContextify } from '../../helpers/activitypub' import { CONFIG, ROUTE_CACHE_LIFETIME } from '../../initializers' -import { buildAnnounceWithVideoAudience } from '../../lib/activitypub/send' +import { buildAnnounceWithVideoAudience, buildDislikeActivity, buildLikeActivity } from '../../lib/activitypub/send' import { audiencify, getAudience } from '../../lib/activitypub/audience' import { buildCreateActivity } from '../../lib/activitypub/send/send-create' import { asyncMiddleware, + videosShareValidator, executeIfActivityPub, localAccountValidator, localVideoChannelValidator, videosCustomGetValidator } from '../../middlewares' -import { videoCommentGetValidator, videosGetValidator, videosShareValidator } from '../../middlewares/validators' +import { + getAccountVideoRateValidator, + videoCommentGetValidator, + videosGetValidator +} from '../../middlewares/validators' import { AccountModel } from '../../models/account/account' import { ActorModel } from '../../models/activitypub/actor' import { ActorFollowModel } from '../../models/activitypub/actor-follow' @@ -25,6 +30,7 @@ import { cacheRoute } from '../../middlewares/cache' import { activityPubResponse } from './utils' import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { + getRateUrl, getVideoCommentsActivityPubUrl, getVideoDislikesActivityPubUrl, getVideoLikesActivityPubUrl, @@ -48,6 +54,14 @@ activityPubClientRouter.get('/accounts?/:name/following', executeIfActivityPub(asyncMiddleware(localAccountValidator)), executeIfActivityPub(asyncMiddleware(accountFollowingController)) ) +activityPubClientRouter.get('/accounts?/:name/likes/:videoId', + executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('like'))), + executeIfActivityPub(getAccountVideoRate('like')) +) +activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId', + executeIfActivityPub(asyncMiddleware(getAccountVideoRateValidator('dislike'))), + executeIfActivityPub(getAccountVideoRate('dislike')) +) activityPubClientRouter.get('/videos/watch/:id', executeIfActivityPub(asyncMiddleware(cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS))), @@ -62,7 +76,7 @@ activityPubClientRouter.get('/videos/watch/:id/announces', executeIfActivityPub(asyncMiddleware(videosCustomGetValidator('only-video'))), executeIfActivityPub(asyncMiddleware(videoAnnouncesController)) ) -activityPubClientRouter.get('/videos/watch/:id/announces/:accountId', +activityPubClientRouter.get('/videos/watch/:id/announces/:actorId', executeIfActivityPub(asyncMiddleware(videosShareValidator)), executeIfActivityPub(asyncMiddleware(videoAnnounceController)) ) @@ -133,6 +147,20 @@ async function accountFollowingController (req: express.Request, res: express.Re return activityPubResponse(activityPubContextify(activityPubResult), res) } +function getAccountVideoRate (rateType: VideoRateType) { + return (req: express.Request, res: express.Response) => { + const accountVideoRate: AccountVideoRateModel = res.locals.accountVideoRate + + const byActor = accountVideoRate.Account.Actor + const url = getRateUrl(rateType, byActor, accountVideoRate.Video) + const APObject = rateType === 'like' + ? buildLikeActivity(url, byActor, accountVideoRate.Video) + : buildCreateActivity(url, byActor, buildDislikeActivity(url, byActor, accountVideoRate.Video)) + + return activityPubResponse(activityPubContextify(APObject), res) + } +} + async function videoController (req: express.Request, res: express.Response, next: express.NextFunction) { const video: VideoModel = res.locals.video @@ -276,7 +304,7 @@ function videoRates (req: express.Request, rateType: VideoRateType, video: Video const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count) return { total: result.count, - data: result.rows.map(r => r.Account.Actor.url) + data: result.rows.map(r => r.url) } } return activityPubCollectionPagination(url, handler, req.query.page) diff --git a/server/controllers/activitypub/inbox.ts b/server/controllers/activitypub/inbox.ts index 738d155eb..f0e65015b 100644 --- a/server/controllers/activitypub/inbox.ts +++ b/server/controllers/activitypub/inbox.ts @@ -43,11 +43,13 @@ export { // --------------------------------------------------------------------------- const inboxQueue = queue<{ activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel }, Error>((task, cb) => { - processActivities(task.activities, task.signatureActor, task.inboxActor) + const options = { signatureActor: task.signatureActor, inboxActor: task.inboxActor } + + processActivities(task.activities, options) .then(() => cb()) }) -function inboxController (req: express.Request, res: express.Response, next: express.NextFunction) { +function inboxController (req: express.Request, res: express.Response) { const rootActivity: RootActivity = req.body let activities: Activity[] = [] diff --git a/server/controllers/api/videos/rate.ts b/server/controllers/api/videos/rate.ts index dc322bb0c..53952a0a2 100644 --- a/server/controllers/api/videos/rate.ts +++ b/server/controllers/api/videos/rate.ts @@ -2,8 +2,8 @@ import * as express from 'express' import { UserVideoRateUpdate } from '../../../../shared' import { logger } from '../../../helpers/logger' import { sequelizeTypescript, VIDEO_RATE_TYPES } from '../../../initializers' -import { sendVideoRateChange } from '../../../lib/activitypub' -import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoRateValidator } from '../../../middlewares' +import { getRateUrl, sendVideoRateChange } from '../../../lib/activitypub' +import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares' import { AccountModel } from '../../../models/account/account' import { AccountVideoRateModel } from '../../../models/account/account-video-rate' import { VideoModel } from '../../../models/video/video' @@ -12,7 +12,7 @@ const rateVideoRouter = express.Router() rateVideoRouter.put('/:id/rate', authenticate, - asyncMiddleware(videoRateValidator), + asyncMiddleware(videoUpdateRateValidator), asyncRetryTransactionMiddleware(rateVideo) ) @@ -28,11 +28,12 @@ async function rateVideo (req: express.Request, res: express.Response) { const body: UserVideoRateUpdate = req.body const rateType = body.rating const videoInstance: VideoModel = res.locals.video + const userAccount: AccountModel = res.locals.oauth.token.User.Account await sequelizeTypescript.transaction(async t => { const sequelizeOptions = { transaction: t } - const accountInstance = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) + const accountInstance = await AccountModel.load(userAccount.id, t) const previousRate = await AccountVideoRateModel.load(accountInstance.id, videoInstance.id, t) let likesToIncrement = 0 @@ -44,20 +45,22 @@ async function rateVideo (req: express.Request, res: express.Response) { // There was a previous rate, update it if (previousRate) { // We will remove the previous rate, so we will need to update the video count attribute - if (previousRate.type === VIDEO_RATE_TYPES.LIKE) likesToIncrement-- - else if (previousRate.type === VIDEO_RATE_TYPES.DISLIKE) dislikesToIncrement-- + if (previousRate.type === 'like') likesToIncrement-- + else if (previousRate.type === 'dislike') dislikesToIncrement-- if (rateType === 'none') { // Destroy previous rate await previousRate.destroy(sequelizeOptions) } else { // Update previous rate previousRate.type = rateType + previousRate.url = getRateUrl(rateType, userAccount.Actor, videoInstance) await previousRate.save(sequelizeOptions) } } else if (rateType !== 'none') { // There was not a previous rate, insert a new one if there is a rate const query = { accountId: accountInstance.id, videoId: videoInstance.id, - type: rateType + type: rateType, + url: getRateUrl(rateType, userAccount.Actor, videoInstance) } await AccountVideoRateModel.create(query, sequelizeOptions) diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts index b0bcfe824..4bf6e387d 100644 --- a/server/helpers/activitypub.ts +++ b/server/helpers/activitypub.ts @@ -6,6 +6,7 @@ import { ACTIVITY_PUB } from '../initializers' import { ActorModel } from '../models/activitypub/actor' import { signJsonLDObject } from './peertube-crypto' import { pageToStartAndCount } from './core-utils' +import { parse } from 'url' function activityPubContextify (data: T) { return Object.assign(data, { @@ -111,9 +112,17 @@ function getActorUrl (activityActor: string | ActivityPubActor) { return activityActor.id } +function checkUrlsSameHost (url1: string, url2: string) { + const idHost = parse(url1).host + const actorHost = parse(url2).host + + return idHost && actorHost && idHost.toLowerCase() === actorHost.toLowerCase() +} + // --------------------------------------------------------------------------- export { + checkUrlsSameHost, getActorUrl, activityPubContextify, activityPubCollectionPagination, diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts index ee9e80404..51facc9e0 100644 --- a/server/helpers/requests.ts +++ b/server/helpers/requests.ts @@ -3,7 +3,7 @@ import { createWriteStream } from 'fs-extra' import * as request from 'request' import { ACTIVITY_PUB } from '../initializers' -function doRequest ( +function doRequest ( requestOptions: request.CoreOptions & request.UriOptions & { activityPub?: boolean } ): Bluebird<{ response: request.RequestResponse, body: any }> { if (requestOptions.activityPub === true) { @@ -11,7 +11,7 @@ function doRequest ( requestOptions.headers['accept'] = ACTIVITY_PUB.ACCEPT_HEADER } - return new Bluebird<{ response: request.RequestResponse, body: any }>((res, rej) => { + return new Bluebird<{ response: request.RequestResponse, body: T }>((res, rej) => { request(requestOptions, (err, response, body) => err ? rej(err) : res({ response, body })) }) } diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 9aadbe824..ae3d671bb 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -16,7 +16,7 @@ let config: IConfig = require('config') // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 285 +const LAST_MIGRATION_VERSION = 290 // --------------------------------------------------------------------------- @@ -336,6 +336,9 @@ const CONSTRAINTS_FIELDS = { VIDEOS_REDUNDANCY: { URL: { min: 3, max: 2000 } // Length }, + VIDEO_RATES: { + URL: { min: 3, max: 2000 } // Length + }, VIDEOS: { NAME: { min: 3, max: 120 }, // Length LANGUAGE: { min: 1, max: 10 }, // Length diff --git a/server/initializers/migrations/0290-account-video-rate-url.ts b/server/initializers/migrations/0290-account-video-rate-url.ts new file mode 100644 index 000000000..bdabf2929 --- /dev/null +++ b/server/initializers/migrations/0290-account-video-rate-url.ts @@ -0,0 +1,46 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction, + queryInterface: Sequelize.QueryInterface, + sequelize: Sequelize.Sequelize, + db: any +}): Promise { + { + const data = { + type: Sequelize.STRING(2000), + allowNull: true + } + + await utils.queryInterface.addColumn('accountVideoRate', 'url', data) + } + + { + const builtUrlQuery = `SELECT "actor"."url" || '/' || "accountVideoRate"."type" || 's/' || "videoId" ` + + 'FROM "accountVideoRate" ' + + 'INNER JOIN account ON account.id = "accountVideoRate"."accountId" ' + + 'INNER JOIN actor ON actor.id = account."actorId" ' + + 'WHERE "base".id = "accountVideoRate".id' + + const query = 'UPDATE "accountVideoRate" base SET "url" = (' + builtUrlQuery + ') WHERE "url" IS NULL' + await utils.sequelize.query(query) + } + + { + const data = { + type: Sequelize.STRING(2000), + allowNull: false, + defaultValue: null + } + await utils.queryInterface.changeColumn('accountVideoRate', 'url', data) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index 45dd4443d..b16a00669 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -5,7 +5,7 @@ import * as url from 'url' import * as uuidv4 from 'uuid/v4' import { ActivityPubActor, ActivityPubActorType } from '../../../shared/models/activitypub' import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects' -import { getActorUrl } from '../../helpers/activitypub' +import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub' import { isActorObjectValid, normalizeActor } from '../../helpers/custom-validators/activitypub/actor' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils' @@ -65,8 +65,12 @@ async function getOrCreateActorAndServerAndModel ( const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person') if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url) + if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) { + throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`) + } + try { - // Assert we don't recurse another time + // Don't recurse another time ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', false) } catch (err) { logger.error('Cannot get or create account attributed to video channel ' + actor.url) @@ -297,12 +301,15 @@ async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: numbe normalizeActor(requestResult.body) const actorJSON: ActivityPubActor = requestResult.body - if (isActorObjectValid(actorJSON) === false) { logger.debug('Remote actor JSON is not valid.', { actorJSON: actorJSON }) return { result: undefined, statusCode: requestResult.response.statusCode } } + if (checkUrlsSameHost(actorJSON.id, actorUrl) !== true) { + throw new Error('Actor url ' + actorUrl + ' has not the same host than its AP id ' + actorJSON.id) + } + const followersCount = await fetchActorTotalItems(actorJSON.followers) const followingCount = await fetchActorTotalItems(actorJSON.following) diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts index db9ce3293..1b9b14c2e 100644 --- a/server/lib/activitypub/crawl.ts +++ b/server/lib/activitypub/crawl.ts @@ -2,6 +2,7 @@ import { ACTIVITY_PUB, JOB_REQUEST_TIMEOUT } from '../../initializers' import { doRequest } from '../../helpers/requests' import { logger } from '../../helpers/logger' import * as Bluebird from 'bluebird' +import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' async function crawlCollectionPage (uri: string, handler: (items: T[]) => Promise | Bluebird) { logger.info('Crawling ActivityPub data on %s.', uri) @@ -14,7 +15,7 @@ async function crawlCollectionPage (uri: string, handler: (items: T[]) => Pr timeout: JOB_REQUEST_TIMEOUT } - const response = await doRequest(options) + const response = await doRequest>(options) const firstBody = response.body let limit = ACTIVITY_PUB.FETCH_PAGE_LIMIT @@ -23,7 +24,7 @@ async function crawlCollectionPage (uri: string, handler: (items: T[]) => Pr while (nextLink && i < limit) { options.uri = nextLink - const { body } = await doRequest(options) + const { body } = await doRequest>(options) nextLink = body.next i++ diff --git a/server/lib/activitypub/process/index.ts b/server/lib/activitypub/process/index.ts index db4980a72..5466739c1 100644 --- a/server/lib/activitypub/process/index.ts +++ b/server/lib/activitypub/process/index.ts @@ -1,9 +1 @@ export * from './process' -export * from './process-accept' -export * from './process-announce' -export * from './process-create' -export * from './process-delete' -export * from './process-follow' -export * from './process-like' -export * from './process-undo' -export * from './process-update' diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index cefe89db0..920d02cd2 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -12,6 +12,8 @@ import { getOrCreateVideoAndAccountAndChannel } from '../videos' import { forwardVideoRelatedActivity } from '../send/utils' import { Redis } from '../../redis' import { createOrUpdateCacheFile } from '../cache-file' +import { immutableAssign } from '../../../tests/utils' +import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { const activityObject = activity.object @@ -65,9 +67,10 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea videoId: video.id, accountId: byAccount.id } + const [ , created ] = await AccountVideoRateModel.findOrCreate({ where: rate, - defaults: rate, + defaults: immutableAssign(rate, { url: getVideoDislikeActivityPubUrl(byActor, video) }), transaction: t }) if (created === true) await video.increment('dislikes', { transaction: t }) diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts index f7200db61..0dca17551 100644 --- a/server/lib/activitypub/process/process-like.ts +++ b/server/lib/activitypub/process/process-like.ts @@ -5,6 +5,8 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat import { ActorModel } from '../../../models/activitypub/actor' import { forwardVideoRelatedActivity } from '../send/utils' import { getOrCreateVideoAndAccountAndChannel } from '../videos' +import { immutableAssign } from '../../../tests/utils' +import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' async function processLikeActivity (activity: ActivityLike, byActor: ActorModel) { return retryTransactionWrapper(processLikeVideo, byActor, activity) @@ -34,7 +36,7 @@ async function processLikeVideo (byActor: ActorModel, activity: ActivityLike) { } const [ , created ] = await AccountVideoRateModel.findOrCreate({ where: rate, - defaults: rate, + defaults: immutableAssign(rate, { url: getVideoLikeActivityPubUrl(byActor, video) }), transaction: t }) if (created === true) await video.increment('likes', { transaction: t }) diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts index ff019cd8c..438a013b6 100644 --- a/server/lib/activitypub/process/process-undo.ts +++ b/server/lib/activitypub/process/process-undo.ts @@ -55,7 +55,8 @@ async function processUndoLike (byActor: ActorModel, activity: ActivityUndo) { return sequelizeTypescript.transaction(async t => { if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) - const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) + let rate = await AccountVideoRateModel.loadByUrl(likeActivity.id, t) + if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`) await rate.destroy({ transaction: t }) @@ -78,7 +79,8 @@ async function processUndoDislike (byActor: ActorModel, activity: ActivityUndo) return sequelizeTypescript.transaction(async t => { if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) - const rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) + let rate = await AccountVideoRateModel.loadByUrl(dislike.id, t) + if (!rate) rate = await AccountVideoRateModel.load(byActor.Account.id, video.id, t) if (!rate) throw new Error(`Unknown rate by account ${byActor.Account.id} for video ${video.id}.`) await rate.destroy({ transaction: t }) diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts index b263f1ea2..b9b255ddf 100644 --- a/server/lib/activitypub/process/process.ts +++ b/server/lib/activitypub/process/process.ts @@ -1,5 +1,5 @@ import { Activity, ActivityType } from '../../../../shared/models/activitypub' -import { getActorUrl } from '../../../helpers/activitypub' +import { checkUrlsSameHost, getActorUrl } from '../../../helpers/activitypub' import { logger } from '../../../helpers/logger' import { ActorModel } from '../../../models/activitypub/actor' import { processAcceptActivity } from './process-accept' @@ -25,11 +25,17 @@ const processActivity: { [ P in ActivityType ]: (activity: Activity, byActor: Ac Like: processLikeActivity } -async function processActivities (activities: Activity[], signatureActor?: ActorModel, inboxActor?: ActorModel) { +async function processActivities ( + activities: Activity[], + options: { + signatureActor?: ActorModel + inboxActor?: ActorModel + outboxUrl?: string + } = {}) { const actorsCache: { [ url: string ]: ActorModel } = {} for (const activity of activities) { - if (!signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) { + if (!options.signatureActor && [ 'Create', 'Announce', 'Like' ].indexOf(activity.type) === -1) { logger.error('Cannot process activity %s (type: %s) without the actor signature.', activity.id, activity.type) continue } @@ -37,12 +43,17 @@ async function processActivities (activities: Activity[], signatureActor?: Actor const actorUrl = getActorUrl(activity.actor) // When we fetch remote data, we don't have signature - if (signatureActor && actorUrl !== signatureActor.url) { - logger.warn('Signature mismatch between %s and %s.', actorUrl, signatureActor.url) + if (options.signatureActor && actorUrl !== options.signatureActor.url) { + logger.warn('Signature mismatch between %s and %s, skipping.', actorUrl, options.signatureActor.url) continue } - const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl) + if (options.outboxUrl && checkUrlsSameHost(options.outboxUrl, actorUrl) !== true) { + logger.warn('Host mismatch between outbox URL %s and actor URL %s, skipping.', options.outboxUrl, actorUrl) + continue + } + + const byActor = options.signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl) actorsCache[actorUrl] = byActor const activityProcessor = processActivity[activity.type] @@ -52,7 +63,7 @@ async function processActivities (activities: Activity[], signatureActor?: Actor } try { - await activityProcessor(activity, byActor, inboxActor) + await activityProcessor(activity, byActor, options.inboxActor) } catch (err) { logger.warn('Cannot process activity %s.', activity.type, { err }) } diff --git a/server/lib/activitypub/send/send-create.ts b/server/lib/activitypub/send/send-create.ts index 285edba3b..e3fca0a17 100644 --- a/server/lib/activitypub/send/send-create.ts +++ b/server/lib/activitypub/send/send-create.ts @@ -95,7 +95,7 @@ async function sendCreateView (byActor: ActorModel, video: VideoModel, t: Transa logger.info('Creating job to send view of %s.', video.url) const url = getVideoViewActivityPubUrl(byActor, video) - const viewActivity = buildViewActivity(byActor, video) + const viewActivity = buildViewActivity(url, byActor, video) return sendVideoRelatedCreateActivity({ // Use the server actor to send the view @@ -111,7 +111,7 @@ async function sendCreateDislike (byActor: ActorModel, video: VideoModel, t: Tra logger.info('Creating job to dislike %s.', video.url) const url = getVideoDislikeActivityPubUrl(byActor, video) - const dislikeActivity = buildDislikeActivity(byActor, video) + const dislikeActivity = buildDislikeActivity(url, byActor, video) return sendVideoRelatedCreateActivity({ byActor, @@ -136,16 +136,18 @@ function buildCreateActivity (url: string, byActor: ActorModel, object: any, aud ) } -function buildDislikeActivity (byActor: ActorModel, video: VideoModel) { +function buildDislikeActivity (url: string, byActor: ActorModel, video: VideoModel) { return { + id: url, type: 'Dislike', actor: byActor.url, object: video.url } } -function buildViewActivity (byActor: ActorModel, video: VideoModel) { +function buildViewActivity (url: string, byActor: ActorModel, video: VideoModel) { return { + id: url, type: 'View', actor: byActor.url, object: video.url diff --git a/server/lib/activitypub/send/send-like.ts b/server/lib/activitypub/send/send-like.ts index 89307acc6..35227887a 100644 --- a/server/lib/activitypub/send/send-like.ts +++ b/server/lib/activitypub/send/send-like.ts @@ -24,8 +24,8 @@ function buildLikeActivity (url: string, byActor: ActorModel, video: VideoModel, return audiencify( { - type: 'Like' as 'Like', id: url, + type: 'Like' as 'Like', actor: byActor.url, object: video.url }, diff --git a/server/lib/activitypub/send/send-undo.ts b/server/lib/activitypub/send/send-undo.ts index 5236d2cb3..bf1b6e117 100644 --- a/server/lib/activitypub/send/send-undo.ts +++ b/server/lib/activitypub/send/send-undo.ts @@ -64,7 +64,7 @@ async function sendUndoDislike (byActor: ActorModel, video: VideoModel, t: Trans logger.info('Creating job to undo a dislike of video %s.', video.url) const dislikeUrl = getVideoDislikeActivityPubUrl(byActor, video) - const dislikeActivity = buildDislikeActivity(byActor, video) + const dislikeActivity = buildDislikeActivity(dislikeUrl, byActor, video) const createDislikeActivity = buildCreateActivity(dislikeUrl, byActor, dislikeActivity) return sendUndoVideoRelatedActivity({ byActor, video, url: dislikeUrl, activity: createDislikeActivity, transaction: t }) diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts index 3ff60a97c..d2649e2d5 100644 --- a/server/lib/activitypub/share.ts +++ b/server/lib/activitypub/share.ts @@ -4,13 +4,14 @@ import { getServerActor } from '../../helpers/utils' import { VideoModel } from '../../models/video/video' import { VideoShareModel } from '../../models/video/video-share' import { sendUndoAnnounce, sendVideoAnnounce } from './send' -import { getAnnounceActivityPubUrl } from './url' +import { getVideoAnnounceActivityPubUrl } from './url' import { VideoChannelModel } from '../../models/video/video-channel' import * as Bluebird from 'bluebird' import { doRequest } from '../../helpers/requests' import { getOrCreateActorAndServerAndModel } from './actor' import { logger } from '../../helpers/logger' import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' +import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub' async function shareVideoByServerAndChannel (video: VideoModel, t: Transaction) { if (video.privacy === VideoPrivacy.PRIVATE) return undefined @@ -38,9 +39,13 @@ async function addVideoShares (shareUrls: string[], instance: VideoModel) { json: true, activityPub: true }) - if (!body || !body.actor) throw new Error('Body of body actor is invalid') + if (!body || !body.actor) throw new Error('Body or body actor is invalid') + + const actorUrl = getActorUrl(body.actor) + if (checkUrlsSameHost(shareUrl, actorUrl) !== true) { + throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`) + } - const actorUrl = body.actor const actor = await getOrCreateActorAndServerAndModel(actorUrl) const entry = { @@ -72,7 +77,7 @@ export { async function shareByServer (video: VideoModel, t: Transaction) { const serverActor = await getServerActor() - const serverShareUrl = getAnnounceActivityPubUrl(video.url, serverActor) + const serverShareUrl = getVideoAnnounceActivityPubUrl(serverActor, video) return VideoShareModel.findOrCreate({ defaults: { actorId: serverActor.id, @@ -91,7 +96,7 @@ async function shareByServer (video: VideoModel, t: Transaction) { } async function shareByVideoChannel (video: VideoModel, t: Transaction) { - const videoChannelShareUrl = getAnnounceActivityPubUrl(video.url, video.VideoChannel.Actor) + const videoChannelShareUrl = getVideoAnnounceActivityPubUrl(video.VideoChannel.Actor, video) return VideoShareModel.findOrCreate({ defaults: { actorId: video.VideoChannel.actorId, diff --git a/server/lib/activitypub/url.ts b/server/lib/activitypub/url.ts index e792be698..38f15448c 100644 --- a/server/lib/activitypub/url.ts +++ b/server/lib/activitypub/url.ts @@ -33,14 +33,14 @@ function getVideoAbuseActivityPubUrl (videoAbuse: VideoAbuseModel) { } function getVideoViewActivityPubUrl (byActor: ActorModel, video: VideoModel) { - return video.url + '/views/' + byActor.uuid + '/' + new Date().toISOString() + return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString() } -function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel) { +function getVideoLikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) { return byActor.url + '/likes/' + video.id } -function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel) { +function getVideoDislikeActivityPubUrl (byActor: ActorModel, video: VideoModel | { id: number }) { return byActor.url + '/dislikes/' + video.id } @@ -74,8 +74,8 @@ function getActorFollowAcceptActivityPubUrl (actorFollow: ActorFollowModel) { return follower.url + '/accepts/follows/' + me.id } -function getAnnounceActivityPubUrl (originalUrl: string, byActor: ActorModel) { - return originalUrl + '/announces/' + byActor.id +function getVideoAnnounceActivityPubUrl (byActor: ActorModel, video: VideoModel) { + return video.url + '/announces/' + byActor.id } function getDeleteActivityPubUrl (originalUrl: string) { @@ -97,7 +97,7 @@ export { getVideoAbuseActivityPubUrl, getActorFollowActivityPubUrl, getActorFollowAcceptActivityPubUrl, - getAnnounceActivityPubUrl, + getVideoAnnounceActivityPubUrl, getUpdateActivityPubUrl, getUndoActivityPubUrl, getVideoViewActivityPubUrl, diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts index c8c17f4c4..5868e7297 100644 --- a/server/lib/activitypub/video-comments.ts +++ b/server/lib/activitypub/video-comments.ts @@ -9,6 +9,7 @@ import { VideoCommentModel } from '../../models/video/video-comment' import { getOrCreateActorAndServerAndModel } from './actor' import { getOrCreateVideoAndAccountAndChannel } from './videos' import * as Bluebird from 'bluebird' +import { checkUrlsSameHost } from '../../helpers/activitypub' async function videoCommentActivityObjectToDBAttributes (video: VideoModel, actor: ActorModel, comment: VideoCommentObject) { let originCommentId: number = null @@ -61,6 +62,14 @@ async function addVideoComment (videoInstance: VideoModel, commentUrl: string) { const actorUrl = body.attributedTo if (!actorUrl) return { created: false } + if (checkUrlsSameHost(commentUrl, actorUrl) !== true) { + throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${commentUrl}`) + } + + if (checkUrlsSameHost(body.id, commentUrl) !== true) { + throw new Error(`Comment url ${commentUrl} host is different from the AP object id ${body.id}`) + } + const actor = await getOrCreateActorAndServerAndModel(actorUrl) const entry = await videoCommentActivityObjectToDBAttributes(videoInstance, actor, body) if (!entry) return { created: false } @@ -134,6 +143,14 @@ async function resolveThread (url: string, comments: VideoCommentModel[] = []) { const actorUrl = body.attributedTo if (!actorUrl) throw new Error('Miss attributed to in comment') + if (checkUrlsSameHost(url, actorUrl) !== true) { + throw new Error(`Actor url ${actorUrl} has not the same host than the comment url ${url}`) + } + + if (checkUrlsSameHost(body.id, url) !== true) { + throw new Error(`Comment url ${url} host is different from the AP object id ${body.id}`) + } + const actor = await getOrCreateActorAndServerAndModel(actorUrl) const comment = new VideoCommentModel({ url: body.id, diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts index 1619251c3..1854b44c4 100644 --- a/server/lib/activitypub/video-rates.ts +++ b/server/lib/activitypub/video-rates.ts @@ -8,13 +8,35 @@ import { getOrCreateActorAndServerAndModel } from './actor' import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { logger } from '../../helpers/logger' import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers' +import { doRequest } from '../../helpers/requests' +import { checkUrlsSameHost, getActorUrl } from '../../helpers/activitypub' +import { ActorModel } from '../../models/activitypub/actor' +import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from './url' -async function createRates (actorUrls: string[], video: VideoModel, rate: VideoRateType) { +async function createRates (ratesUrl: string[], video: VideoModel, rate: VideoRateType) { let rateCounts = 0 - await Bluebird.map(actorUrls, async actorUrl => { + await Bluebird.map(ratesUrl, async rateUrl => { try { + // Fetch url + const { body } = await doRequest({ + uri: rateUrl, + json: true, + activityPub: true + }) + if (!body || !body.actor) throw new Error('Body or body actor is invalid') + + const actorUrl = getActorUrl(body.actor) + if (checkUrlsSameHost(actorUrl, rateUrl) !== true) { + throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`) + } + + if (checkUrlsSameHost(body.id, rateUrl) !== true) { + throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`) + } + const actor = await getOrCreateActorAndServerAndModel(actorUrl) + const [ , created ] = await AccountVideoRateModel .findOrCreate({ where: { @@ -24,13 +46,14 @@ async function createRates (actorUrls: string[], video: VideoModel, rate: VideoR defaults: { videoId: video.id, accountId: actor.Account.id, - type: rate + type: rate, + url: body.id } }) if (created) rateCounts += 1 } catch (err) { - logger.warn('Cannot add rate %s for actor %s.', rate, actorUrl, { err }) + logger.warn('Cannot add rate %s.', rateUrl, { err }) } }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) @@ -62,7 +85,12 @@ async function sendVideoRateChange (account: AccountModel, if (dislikes > 0) await sendCreateDislike(actor, video, t) } +function getRateUrl (rateType: VideoRateType, actor: ActorModel, video: VideoModel) { + return rateType === 'like' ? getVideoLikeActivityPubUrl(actor, video) : getVideoDislikeActivityPubUrl(actor, video) +} + export { + getRateUrl, createRates, sendVideoRateChange } diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts index 3da363c0a..5bd03c8c6 100644 --- a/server/lib/activitypub/videos.ts +++ b/server/lib/activitypub/videos.ts @@ -29,6 +29,7 @@ import { createRates } from './video-rates' import { addVideoShares, shareVideoByServerAndChannel } from './share' import { AccountModel } from '../../models/account/account' import { fetchVideoByUrl, VideoFetchByUrlType } from '../../helpers/video' +import { checkUrlsSameHost } from '../../helpers/activitypub' async function federateVideoIfNeeded (video: VideoModel, isNewVideo: boolean, transaction?: sequelize.Transaction) { // If the video is not private and published, we federate it @@ -63,7 +64,7 @@ async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request. const { response, body } = await doRequest(options) - if (sanitizeAndCheckVideoTorrentObject(body) === false) { + if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) { logger.debug('Remote video JSON is not valid.', { body }) return { response, videoObject: undefined } } @@ -107,6 +108,10 @@ function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject const channel = videoObject.attributedTo.find(a => a.type === 'Group') if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) + if (checkUrlsSameHost(channel.id, videoObject.id) !== true) { + throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`) + } + return getOrCreateActorAndServerAndModel(channel.id, 'all') } diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts index 42217c27c..67ccfa995 100644 --- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts +++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts @@ -23,7 +23,7 @@ async function processActivityPubHttpFetcher (job: Bull.Job) { if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise } = { - 'activity': items => processActivities(items), + 'activity': items => processActivities(items, { outboxUrl: payload.uri }), 'video-likes': items => createRates(items, video, 'like'), 'video-dislikes': items => createRates(items, video, 'dislike'), 'video-shares': items => addVideoShares(items, video), diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts index 294783d85..a0d585b93 100644 --- a/server/middlewares/validators/videos/index.ts +++ b/server/middlewares/validators/videos/index.ts @@ -5,4 +5,6 @@ export * from './video-channels' export * from './video-comments' export * from './video-imports' export * from './video-watch' +export * from './video-rates' +export * from './video-shares' export * from './videos' diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts new file mode 100644 index 000000000..793354520 --- /dev/null +++ b/server/middlewares/validators/videos/video-rates.ts @@ -0,0 +1,55 @@ +import * as express from 'express' +import 'express-validator' +import { body, param } from 'express-validator/check' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist, isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { areValidationErrors } from '../utils' +import { AccountVideoRateModel } from '../../../models/account/account-video-rate' +import { VideoRateType } from '../../../../shared/models/videos' +import { isAccountNameValid } from '../../../helpers/custom-validators/accounts' + +const videoUpdateRateValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoRate parameters', { parameters: req.body }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res)) return + + return next() + } +] + +const getAccountVideoRateValidator = function (rateType: VideoRateType) { + return [ + param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'), + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + + const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.name, req.params.videoId) + if (!rate) { + return res.status(404) + .json({ error: 'Video rate not found' }) + .end() + } + + res.locals.accountVideoRate = rate + + return next() + } + ] +} + +// --------------------------------------------------------------------------- + +export { + videoUpdateRateValidator, + getAccountVideoRateValidator +} diff --git a/server/middlewares/validators/videos/video-shares.ts b/server/middlewares/validators/videos/video-shares.ts new file mode 100644 index 000000000..646d7acb1 --- /dev/null +++ b/server/middlewares/validators/videos/video-shares.ts @@ -0,0 +1,38 @@ +import * as express from 'express' +import 'express-validator' +import { param } from 'express-validator/check' +import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { isVideoExist } from '../../../helpers/custom-validators/videos' +import { logger } from '../../../helpers/logger' +import { VideoShareModel } from '../../../models/video/video-share' +import { areValidationErrors } from '../utils' +import { VideoModel } from '../../../models/video/video' + +const videosShareValidator = [ + param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), + param('actorId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid actor id'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking videoShare parameters', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.id, res)) return + + const video: VideoModel = res.locals.video + + const share = await VideoShareModel.load(req.params.actorId, video.id) + if (!share) { + return res.status(404) + .end() + } + + res.locals.videoShare = share + return next() + } +] + +// --------------------------------------------------------------------------- + +export { + videosShareValidator +} diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index 9dc52a134..656d161d8 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts @@ -26,14 +26,12 @@ import { isVideoLicenceValid, isVideoNameValid, isVideoPrivacyValid, - isVideoRatingTypeValid, isVideoSupportValid, isVideoTagsValid } from '../../../helpers/custom-validators/videos' import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils' import { logger } from '../../../helpers/logger' import { CONSTRAINTS_FIELDS } from '../../../initializers' -import { VideoShareModel } from '../../../models/video/video-share' import { authenticate } from '../../oauth' import { areValidationErrors } from '../utils' import { cleanUpReqFiles } from '../../../helpers/express-utils' @@ -188,41 +186,6 @@ const videosRemoveValidator = [ } ] -const videoRateValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoRate parameters', { parameters: req.body }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res)) return - - return next() - } -] - -const videosShareValidator = [ - param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), - param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'), - - async (req: express.Request, res: express.Response, next: express.NextFunction) => { - logger.debug('Checking videoShare parameters', { parameters: req.params }) - - if (areValidationErrors(req, res)) return - if (!await isVideoExist(req.params.id, res)) return - - const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined) - if (!share) { - return res.status(404) - .end() - } - - res.locals.videoShare = share - return next() - } -] - const videosChangeOwnershipValidator = [ param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), @@ -415,9 +378,6 @@ export { videosGetValidator, videosCustomGetValidator, videosRemoveValidator, - videosShareValidator, - - videoRateValidator, videosChangeOwnershipValidator, videosTerminateChangeOwnershipValidator, diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts index c99e32012..18762f0c5 100644 --- a/server/models/account/account-video-rate.ts +++ b/server/models/account/account-video-rate.ts @@ -1,12 +1,14 @@ import { values } from 'lodash' import { Transaction } from 'sequelize' -import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' import { IFindOptions } from 'sequelize-typescript/lib/interfaces/IFindOptions' import { VideoRateType } from '../../../shared/models/videos' -import { VIDEO_RATE_TYPES } from '../../initializers' +import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers' import { VideoModel } from '../video/video' import { AccountModel } from './account' import { ActorModel } from '../activitypub/actor' +import { throwIfNotValid } from '../utils' +import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' /* Account rates per video. @@ -26,6 +28,10 @@ import { ActorModel } from '../activitypub/actor' }, { fields: [ 'videoId', 'type' ] + }, + { + fields: [ 'url' ], + unique: true } ] }) @@ -35,6 +41,11 @@ export class AccountVideoRateModel extends Model { @Column(DataType.ENUM(values(VIDEO_RATE_TYPES))) type: VideoRateType + @AllowNull(false) + @Is('AccountVideoRateUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) + @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_RATES.URL.max)) + url: string + @CreatedAt createdAt: Date @@ -65,7 +76,7 @@ export class AccountVideoRateModel extends Model { }) Account: AccountModel - static load (accountId: number, videoId: number, transaction: Transaction) { + static load (accountId: number, videoId: number, transaction?: Transaction) { const options: IFindOptions = { where: { accountId, @@ -77,6 +88,49 @@ export class AccountVideoRateModel extends Model { return AccountVideoRateModel.findOne(options) } + static loadLocalAndPopulateVideo (rateType: VideoRateType, accountName: string, videoId: number, transaction?: Transaction) { + const options: IFindOptions = { + where: { + videoId, + type: rateType + }, + include: [ + { + model: AccountModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'id', 'url', 'preferredUsername' ], + model: ActorModel.unscoped(), + required: true, + where: { + preferredUsername: accountName + } + } + ] + }, + { + model: VideoModel.unscoped(), + required: true + } + ] + } + if (transaction) options.transaction = transaction + + return AccountVideoRateModel.findOne(options) + } + + static loadByUrl (url: string, transaction: Transaction) { + const options: IFindOptions = { + where: { + url + } + } + if (transaction) options.transaction = transaction + + return AccountVideoRateModel.findOne(options) + } + static listAndCountAccountUrlsByVideoId (rateType: VideoRateType, videoId: number, start: number, count: number, t?: Transaction) { const query = { offset: start, diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts index ef9592c04..ecf846821 100644 --- a/server/models/oauth/oauth-token.ts +++ b/server/models/oauth/oauth-token.ts @@ -47,7 +47,7 @@ enum ScopeNames { required: true, include: [ { - attributes: [ 'id' ], + attributes: [ 'id', 'url' ], model: () => ActorModel.unscoped(), required: true } diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts index fa9a70d50..c87f71277 100644 --- a/server/models/video/video-share.ts +++ b/server/models/video/video-share.ts @@ -88,7 +88,7 @@ export class VideoShareModel extends Model { }) Video: VideoModel - static load (actorId: number, videoId: number, t: Sequelize.Transaction) { + static load (actorId: number, videoId: number, t?: Sequelize.Transaction) { return VideoShareModel.scope(ScopeNames.WITH_ACTOR).findOne({ where: { actorId, diff --git a/server/tests/api/activitypub/security.ts b/server/tests/api/activitypub/security.ts index c5428abbb..e7899bb14 100644 --- a/server/tests/api/activitypub/security.ts +++ b/server/tests/api/activitypub/security.ts @@ -2,7 +2,7 @@ import 'mocha' -import { flushAndRunMultipleServers, flushTests, killallServers, makeAPRequest, makeFollowRequest, ServerInfo } from '../../utils' +import { flushAndRunMultipleServers, flushTests, killallServers, makePOSTAPRequest, makeFollowRequest, ServerInfo } from '../../utils' import { HTTP_SIGNATURE } from '../../../initializers' import { buildDigest, buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils' import * as chai from 'chai' @@ -63,7 +63,7 @@ describe('Test ActivityPub security', function () { Digest: buildDigest({ hello: 'coucou' }) } - const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers) expect(response.statusCode).to.equal(403) }) @@ -73,7 +73,7 @@ describe('Test ActivityPub security', function () { const headers = buildGlobalHeaders(body) headers['date'] = 'Wed, 21 Oct 2015 07:28:00 GMT' - const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers) expect(response.statusCode).to.equal(403) }) @@ -85,7 +85,7 @@ describe('Test ActivityPub security', function () { const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) const headers = buildGlobalHeaders(body) - const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers) expect(response.statusCode).to.equal(403) }) @@ -97,7 +97,7 @@ describe('Test ActivityPub security', function () { const body = activityPubContextify(require('./json/peertube/announce-without-context.json')) const headers = buildGlobalHeaders(body) - const { response } = await makeAPRequest(url, body, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, body, baseHttpSignature, headers) expect(response.statusCode).to.equal(204) }) @@ -126,7 +126,7 @@ describe('Test ActivityPub security', function () { const headers = buildGlobalHeaders(signedBody) - const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers) expect(response.statusCode).to.equal(403) }) @@ -147,7 +147,7 @@ describe('Test ActivityPub security', function () { const headers = buildGlobalHeaders(signedBody) - const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers) expect(response.statusCode).to.equal(403) }) @@ -163,7 +163,7 @@ describe('Test ActivityPub security', function () { const headers = buildGlobalHeaders(signedBody) - const { response } = await makeAPRequest(url, signedBody, baseHttpSignature, headers) + const { response } = await makePOSTAPRequest(url, signedBody, baseHttpSignature, headers) expect(response.statusCode).to.equal(204) }) diff --git a/server/tests/utils/requests/activitypub.ts b/server/tests/utils/requests/activitypub.ts index e3e08ce67..96fee60a8 100644 --- a/server/tests/utils/requests/activitypub.ts +++ b/server/tests/utils/requests/activitypub.ts @@ -3,7 +3,7 @@ import { HTTP_SIGNATURE } from '../../../initializers' import { buildGlobalHeaders } from '../../../lib/job-queue/handlers/utils/activitypub-http-utils' import { activityPubContextify } from '../../../helpers/activitypub' -function makeAPRequest (url: string, body: any, httpSignature: any, headers: any) { +function makePOSTAPRequest (url: string, body: any, httpSignature: any, headers: any) { const options = { method: 'POST', uri: url, @@ -34,10 +34,10 @@ async function makeFollowRequest (to: { url: string }, by: { url: string, privat } const headers = buildGlobalHeaders(body) - return makeAPRequest(to.url, body, httpSignature, headers) + return makePOSTAPRequest(to.url, body, httpSignature, headers) } export { - makeAPRequest, + makePOSTAPRequest, makeFollowRequest } -- cgit v1.2.3 From e5cb43e071924b8cbc77731d3a0511b4f7bcff9d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 14 Nov 2018 15:45:50 +0100 Subject: Clean up peertube auth --- server/tests/cli/peertube.ts | 2 + server/tools/peertube-auth.ts | 113 ++++++++++++++++++++---------------------- server/tools/peertube.ts | 2 +- 3 files changed, 57 insertions(+), 60 deletions(-) (limited to 'server') diff --git a/server/tests/cli/peertube.ts b/server/tests/cli/peertube.ts index 65cb05a1a..7a712bc4e 100644 --- a/server/tests/cli/peertube.ts +++ b/server/tests/cli/peertube.ts @@ -44,6 +44,8 @@ describe('Test CLI wrapper', function () { }) after(async function () { + this.timeout(10000) + await execCLI(cmd + ` auth del ${server.url}`) killallServers([ server ]) diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts index 33438811e..a962944a4 100644 --- a/server/tools/peertube-auth.ts +++ b/server/tools/peertube-auth.ts @@ -5,34 +5,25 @@ import { getSettings, writeSettings, netrc } from './cli' import { isHostValid } from '../helpers/custom-validators/servers' import { isUserUsernameValid } from '../helpers/custom-validators/users' -function delInstance (url: string) { - return new Promise((res, rej): void => { - getSettings() - .then(async (settings) => { - settings.remotes.splice(settings.remotes.indexOf(url)) - await writeSettings(settings) - delete netrc.machines[url] - netrc.save() - res() - }) - .catch(err => rej(err)) - }) +async function delInstance (url: string) { + const settings = await getSettings() + + settings.remotes.splice(settings.remotes.indexOf(url)) + await writeSettings(settings) + + delete netrc.machines[url] + await netrc.save() } async function setInstance (url: string, username: string, password: string) { - return new Promise((res, rej): void => { - getSettings() - .then(async settings => { - if (settings.remotes.indexOf(url) === -1) { - settings.remotes.push(url) - } - await writeSettings(settings) - netrc.machines[url] = { login: username, password } - netrc.save() - res() - }) - .catch(err => rej(err)) - }) + const settings = await getSettings() + if (settings.remotes.indexOf(url) === -1) { + settings.remotes.push(url) + } + await writeSettings(settings) + + netrc.machines[url] = { login: username, password } + await netrc.save() } function isURLaPeerTubeInstance (url: string) { @@ -71,56 +62,60 @@ program required: true } } - }, (_, result) => { - setInstance(result.url, result.username, result.password) + }, async (_, result) => { + await setInstance(result.url, result.username, result.password) + + process.exit(0) }) }) program .command('del ') .description('unregisters a remote instance') - .action((url) => { - delInstance(url) + .action(async url => { + await delInstance(url) + + process.exit(0) }) program .command('list') .description('lists registered remote instances') - .action(() => { - getSettings() - .then(settings => { - const table = new Table({ - head: ['instance', 'login'], - colWidths: [30, 30] - }) - netrc.loadSync() - settings.remotes.forEach(element => { - table.push([ - element, - netrc.machines[element].login - ]) - }) - - console.log(table.toString()) - }) + .action(async () => { + const settings = await getSettings() + const table = new Table({ + head: ['instance', 'login'], + colWidths: [30, 30] + }) + netrc.loadSync() + settings.remotes.forEach(element => { + table.push([ + element, + netrc.machines[element].login + ]) + }) + + console.log(table.toString()) + + process.exit(0) }) program .command('set-default ') .description('set an existing entry as default') - .action((url) => { - getSettings() - .then(settings => { - const instanceExists = settings.remotes.indexOf(url) !== -1 - - if (instanceExists) { - settings.default = settings.remotes.indexOf(url) - writeSettings(settings) - } else { - console.log(' is not a registered instance.') - process.exit(-1) - } - }) + .action(async url => { + const settings = await getSettings() + const instanceExists = settings.remotes.indexOf(url) !== -1 + + if (instanceExists) { + settings.default = settings.remotes.indexOf(url) + await writeSettings(settings) + + process.exit(0) + } else { + console.log(' is not a registered instance.') + process.exit(-1) + } }) program.on('--help', function () { diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts index c8b9fa744..5d3ab2815 100755 --- a/server/tools/peertube.ts +++ b/server/tools/peertube.ts @@ -58,7 +58,7 @@ if (!process.argv.slice(2).length) { ,"\\/ _,.__/"\\/_ (the CLI for red chocobos) / \\) "./, ". - --/---"---" "-) )---- by Chocobozzz et al.`) + --/---"---" "-) )---- by Chocobozzz et al.\n`) } getSettings() -- cgit v1.2.3 From a130f33c9c07ffaf2f11a5e629d686a158b9e1c7 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 14 Nov 2018 16:32:12 +0100 Subject: Add AP fetch tests --- server/tests/api/activitypub/fetch.ts | 86 +++++++++++++++++++++++++++++++++++ server/tests/api/activitypub/index.ts | 1 + server/tests/utils/miscs/sql.ts | 9 ++++ 3 files changed, 96 insertions(+) create mode 100644 server/tests/api/activitypub/fetch.ts (limited to 'server') diff --git a/server/tests/api/activitypub/fetch.ts b/server/tests/api/activitypub/fetch.ts new file mode 100644 index 000000000..a42c606c6 --- /dev/null +++ b/server/tests/api/activitypub/fetch.ts @@ -0,0 +1,86 @@ +/* tslint:disable:no-unused-expression */ + +import 'mocha' + +import { + createUser, + doubleFollow, + flushAndRunMultipleServers, + flushTests, + getVideosListSort, + killallServers, + ServerInfo, + setAccessTokensToServers, + uploadVideo, + userLogin +} from '../../utils' +import * as chai from 'chai' +import { setActorField, setVideoField } from '../../utils/miscs/sql' +import { waitJobs } from '../../utils/server/jobs' +import { Video } from '../../../../shared/models/videos' + +const expect = chai.expect + +describe('Test ActivityPub fetcher', function () { + let servers: ServerInfo[] + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(60000) + + servers = await flushAndRunMultipleServers(3) + + // Get the access tokens + await setAccessTokensToServers(servers) + + const user = { username: 'user1', password: 'password' } + for (const server of servers) { + await createUser(server.url, server.accessToken, user.username, user.password) + } + + const userAccessToken = await userLogin(servers[0], user) + + await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video root' }) + const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'bad video root' }) + const badVideoUUID = res.body.video.uuid + await uploadVideo(servers[0].url, userAccessToken, { name: 'video user' }) + + await setActorField(1, 'http://localhost:9001/accounts/user1', 'url', 'http://localhost:9002/accounts/user1') + await setVideoField(1, badVideoUUID, 'url', 'http://localhost:9003/videos/watch/' + badVideoUUID) + }) + + it('Should add only the video with a valid actor URL', async function () { + this.timeout(60000) + + await doubleFollow(servers[0], servers[1]) + await waitJobs(servers) + + { + const res = await getVideosListSort(servers[0].url, 'createdAt') + expect(res.body.total).to.equal(3) + + const data: Video[] = res.body.data + expect(data[0].name).to.equal('video root') + expect(data[1].name).to.equal('bad video root') + expect(data[2].name).to.equal('video user') + } + + { + const res = await getVideosListSort(servers[1].url, 'createdAt') + expect(res.body.total).to.equal(1) + + const data: Video[] = res.body.data + expect(data[0].name).to.equal('video root') + } + }) + + after(async function () { + killallServers(servers) + + // Keep the logs if the test failed + if (this['ok']) { + await flushTests() + } + }) +}) diff --git a/server/tests/api/activitypub/index.ts b/server/tests/api/activitypub/index.ts index de8a59978..e748f32e9 100644 --- a/server/tests/api/activitypub/index.ts +++ b/server/tests/api/activitypub/index.ts @@ -1,3 +1,4 @@ import './client' +import './fetch' import './helpers' import './security' diff --git a/server/tests/utils/miscs/sql.ts b/server/tests/utils/miscs/sql.ts index 204ff5163..027f78131 100644 --- a/server/tests/utils/miscs/sql.ts +++ b/server/tests/utils/miscs/sql.ts @@ -24,6 +24,15 @@ function setActorField (serverNumber: number, to: string, field: string, value: return seq.query(`UPDATE actor SET "${field}" = '${value}' WHERE url = '${to}'`, options) } +function setVideoField (serverNumber: number, uuid: string, field: string, value: string) { + const seq = getSequelize(serverNumber) + + const options = { type: Sequelize.QueryTypes.UPDATE } + + return seq.query(`UPDATE video SET "${field}" = '${value}' WHERE uuid = '${uuid}'`, options) +} + export { + setVideoField, setActorField } -- cgit v1.2.3 From 742ddee1f131e6a2d701f2eeeb2851e8e1020cb2 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 10:07:44 +0100 Subject: Fix server redundancy tests --- server/lib/schedulers/videos-redundancy-scheduler.ts | 5 +++-- server/models/redundancy/video-redundancy.ts | 5 +++++ server/tests/api/server/redundancy.ts | 18 +++++++++--------- 3 files changed, 17 insertions(+), 11 deletions(-) (limited to 'server') diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts index c49a8c89a..8b7f33539 100644 --- a/server/lib/schedulers/videos-redundancy-scheduler.ts +++ b/server/lib/schedulers/videos-redundancy-scheduler.ts @@ -185,11 +185,12 @@ export class VideosRedundancyScheduler extends AbstractScheduler { } private async isTooHeavy (redundancy: VideosRedundancy, filesToDuplicate: VideoFileModel[]) { - const maxSize = redundancy.size - this.getTotalFileSizes(filesToDuplicate) + const maxSize = redundancy.size const totalDuplicated = await VideoRedundancyModel.getTotalDuplicated(redundancy.strategy) + const totalWillDuplicate = totalDuplicated + this.getTotalFileSizes(filesToDuplicate) - return totalDuplicated > maxSize + return totalWillDuplicate > maxSize } private buildNewExpiration (expiresAfterMs: number) { diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts index cbfc7f7fa..35e0cd3b1 100644 --- a/server/models/redundancy/video-redundancy.ts +++ b/server/models/redundancy/video-redundancy.ts @@ -293,6 +293,11 @@ export class VideoRedundancyModel extends Model { } return VideoFileModel.sum('size', options as any) // FIXME: typings + .then(v => { + if (!v || isNaN(v)) return 0 + + return v + }) } static async listLocalExpired () { diff --git a/server/tests/api/server/redundancy.ts b/server/tests/api/server/redundancy.ts index f50d6e3cf..77a2d61a4 100644 --- a/server/tests/api/server/redundancy.ts +++ b/server/tests/api/server/redundancy.ts @@ -54,7 +54,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams: immutableAssign({ min_lifetime: '1 hour', strategy: strategy, - size: '100KB' + size: '200KB' }, additionalParams) ] } @@ -111,8 +111,8 @@ async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { const stat = data.videosRedundancy[0] expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(102400) - expect(stat.totalUsed).to.be.at.least(1).and.below(102401) + expect(stat.totalSize).to.equal(204800) + expect(stat.totalUsed).to.be.at.least(1).and.below(204800) expect(stat.totalVideoFiles).to.equal(4) expect(stat.totalVideos).to.equal(1) } @@ -125,7 +125,7 @@ async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) { const stat = data.videosRedundancy[0] expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(102400) + expect(stat.totalSize).to.equal(204800) expect(stat.totalUsed).to.equal(0) expect(stat.totalVideoFiles).to.equal(0) expect(stat.totalVideos).to.equal(0) @@ -223,7 +223,7 @@ describe('Test videos redundancy', function () { return enableRedundancyOnServer1() }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -270,7 +270,7 @@ describe('Test videos redundancy', function () { return enableRedundancyOnServer1() }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -338,7 +338,7 @@ describe('Test videos redundancy', function () { await waitJobs(servers) }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -399,7 +399,7 @@ describe('Test videos redundancy', function () { await enableRedundancyOnServer1() }) - it('Should still have 2 webseeds after 10 seconds', async function () { + it('Should still have 2 webseedss after 10 seconds', async function () { this.timeout(40000) await wait(10000) @@ -451,7 +451,7 @@ describe('Test videos redundancy', function () { video2Server2UUID = res.body.video.uuid }) - it('Should cache video 2 webseed on the first video', async function () { + it('Should cache video 2 webseeds on the first video', async function () { this.timeout(120000) await waitJobs(servers) -- cgit v1.2.3 From 6cb3482ceba2e0564a05b525699f29a1f5ff20a2 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 11:20:23 +0100 Subject: Remove wrong redundancy test --- server/tests/api/redundancy/redundancy.ts | 36 +-- server/tests/api/server/redundancy.ts | 479 ------------------------------ 2 files changed, 16 insertions(+), 499 deletions(-) delete mode 100644 server/tests/api/server/redundancy.ts (limited to 'server') diff --git a/server/tests/api/redundancy/redundancy.ts b/server/tests/api/redundancy/redundancy.ts index 1960854b6..47f4e59fc 100644 --- a/server/tests/api/redundancy/redundancy.ts +++ b/server/tests/api/redundancy/redundancy.ts @@ -54,7 +54,7 @@ async function runServers (strategy: VideoRedundancyStrategy, additionalParams: immutableAssign({ min_lifetime: '1 hour', strategy: strategy, - size: '100KB' + size: '200KB' }, additionalParams) ] } @@ -111,8 +111,8 @@ async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { const stat = data.videosRedundancy[0] expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(102400) - expect(stat.totalUsed).to.be.at.least(1).and.below(102401) + expect(stat.totalSize).to.equal(204800) + expect(stat.totalUsed).to.be.at.least(1).and.below(204801) expect(stat.totalVideoFiles).to.equal(4) expect(stat.totalVideos).to.equal(1) } @@ -125,7 +125,7 @@ async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) { const stat = data.videosRedundancy[0] expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(102400) + expect(stat.totalSize).to.equal(204800) expect(stat.totalUsed).to.equal(0) expect(stat.totalVideoFiles).to.equal(0) expect(stat.totalVideos).to.equal(0) @@ -223,7 +223,7 @@ describe('Test videos redundancy', function () { return enableRedundancyOnServer1() }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -270,7 +270,7 @@ describe('Test videos redundancy', function () { return enableRedundancyOnServer1() }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -338,7 +338,7 @@ describe('Test videos redundancy', function () { await waitJobs(servers) }) - it('Should have 2 webseed on the first video', async function () { + it('Should have 2 webseeds on the first video', async function () { this.timeout(40000) await waitJobs(servers) @@ -419,7 +419,7 @@ describe('Test videos redundancy', function () { killallServers([ servers[0] ]) - await wait(10000) + await wait(15000) await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001') }) @@ -451,27 +451,23 @@ describe('Test videos redundancy', function () { video2Server2UUID = res.body.video.uuid }) - it('Should cache video 2 webseed on the first video', async function () { - this.timeout(50000) + it('Should cache video 2 webseeds on the first video', async function () { + this.timeout(120000) await waitJobs(servers) - await wait(7000) + let checked = false - try { - await check1WebSeed(strategy, video1Server2UUID) - await check2Webseeds(strategy, video2Server2UUID) - } catch { - await wait(3000) + while (checked === false) { + await wait(1000) try { await check1WebSeed(strategy, video1Server2UUID) await check2Webseeds(strategy, video2Server2UUID) - } catch { - await wait(5000) - await check1WebSeed(strategy, video1Server2UUID) - await check2Webseeds(strategy, video2Server2UUID) + checked = true + } catch { + checked = false } } }) diff --git a/server/tests/api/server/redundancy.ts b/server/tests/api/server/redundancy.ts deleted file mode 100644 index 77a2d61a4..000000000 --- a/server/tests/api/server/redundancy.ts +++ /dev/null @@ -1,479 +0,0 @@ -/* tslint:disable:no-unused-expression */ - -import * as chai from 'chai' -import 'mocha' -import { VideoDetails } from '../../../../shared/models/videos' -import { - doubleFollow, - flushAndRunMultipleServers, - getFollowingListPaginationAndSort, - getVideo, - immutableAssign, - killallServers, makeGetRequest, - root, - ServerInfo, - setAccessTokensToServers, unfollow, - uploadVideo, - viewVideo, - wait, - waitUntilLog, - checkVideoFilesWereRemoved, removeVideo -} from '../../utils' -import { waitJobs } from '../../utils/server/jobs' -import * as magnetUtil from 'magnet-uri' -import { updateRedundancy } from '../../utils/server/redundancy' -import { ActorFollow } from '../../../../shared/models/actors' -import { readdir } from 'fs-extra' -import { join } from 'path' -import { VideoRedundancyStrategy } from '../../../../shared/models/redundancy' -import { getStats } from '../../utils/server/stats' -import { ServerStats } from '../../../../shared/models/server/server-stats.model' - -const expect = chai.expect - -let servers: ServerInfo[] = [] -let video1Server2UUID: string - -function checkMagnetWebseeds (file: { magnetUri: string, resolution: { id: number } }, baseWebseeds: string[], server: ServerInfo) { - const parsed = magnetUtil.decode(file.magnetUri) - - for (const ws of baseWebseeds) { - const found = parsed.urlList.find(url => url === `${ws}-${file.resolution.id}.mp4`) - expect(found, `Webseed ${ws} not found in ${file.magnetUri} on server ${server.url}`).to.not.be.undefined - } - - expect(parsed.urlList).to.have.lengthOf(baseWebseeds.length) -} - -async function runServers (strategy: VideoRedundancyStrategy, additionalParams: any = {}) { - const config = { - redundancy: { - videos: { - check_interval: '5 seconds', - strategies: [ - immutableAssign({ - min_lifetime: '1 hour', - strategy: strategy, - size: '200KB' - }, additionalParams) - ] - } - } - } - servers = await flushAndRunMultipleServers(3, config) - - // Get the access tokens - await setAccessTokensToServers(servers) - - { - const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 1 server 2' }) - video1Server2UUID = res.body.video.uuid - - await viewVideo(servers[ 1 ].url, video1Server2UUID) - } - - await waitJobs(servers) - - // Server 1 and server 2 follow each other - await doubleFollow(servers[ 0 ], servers[ 1 ]) - // Server 1 and server 3 follow each other - await doubleFollow(servers[ 0 ], servers[ 2 ]) - // Server 2 and server 3 follow each other - await doubleFollow(servers[ 1 ], servers[ 2 ]) - - await waitJobs(servers) -} - -async function check1WebSeed (strategy: VideoRedundancyStrategy, videoUUID?: string) { - if (!videoUUID) videoUUID = video1Server2UUID - - const webseeds = [ - 'http://localhost:9002/static/webseed/' + videoUUID - ] - - for (const server of servers) { - { - const res = await getVideo(server.url, videoUUID) - - const video: VideoDetails = res.body - for (const f of video.files) { - checkMagnetWebseeds(f, webseeds, server) - } - } - } -} - -async function checkStatsWith2Webseed (strategy: VideoRedundancyStrategy) { - const res = await getStats(servers[0].url) - const data: ServerStats = res.body - - expect(data.videosRedundancy).to.have.lengthOf(1) - const stat = data.videosRedundancy[0] - - expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(204800) - expect(stat.totalUsed).to.be.at.least(1).and.below(204800) - expect(stat.totalVideoFiles).to.equal(4) - expect(stat.totalVideos).to.equal(1) -} - -async function checkStatsWith1Webseed (strategy: VideoRedundancyStrategy) { - const res = await getStats(servers[0].url) - const data: ServerStats = res.body - - expect(data.videosRedundancy).to.have.lengthOf(1) - - const stat = data.videosRedundancy[0] - expect(stat.strategy).to.equal(strategy) - expect(stat.totalSize).to.equal(204800) - expect(stat.totalUsed).to.equal(0) - expect(stat.totalVideoFiles).to.equal(0) - expect(stat.totalVideos).to.equal(0) -} - -async function check2Webseeds (strategy: VideoRedundancyStrategy, videoUUID?: string) { - if (!videoUUID) videoUUID = video1Server2UUID - - const webseeds = [ - 'http://localhost:9001/static/webseed/' + videoUUID, - 'http://localhost:9002/static/webseed/' + videoUUID - ] - - for (const server of servers) { - const res = await getVideo(server.url, videoUUID) - - const video: VideoDetails = res.body - - for (const file of video.files) { - checkMagnetWebseeds(file, webseeds, server) - - // Only servers 1 and 2 have the video - if (server.serverNumber !== 3) { - await makeGetRequest({ - url: server.url, - statusCodeExpected: 200, - path: '/static/webseed/' + `${videoUUID}-${file.resolution.id}.mp4`, - contentType: null - }) - } - } - } - - for (const directory of [ 'test1', 'test2' ]) { - const files = await readdir(join(root(), directory, 'videos')) - expect(files).to.have.length.at.least(4) - - for (const resolution of [ 240, 360, 480, 720 ]) { - expect(files.find(f => f === `${videoUUID}-${resolution}.mp4`)).to.not.be.undefined - } - } -} - -async function enableRedundancyOnServer1 () { - await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, true) - - const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt') - const follows: ActorFollow[] = res.body.data - const server2 = follows.find(f => f.following.host === 'localhost:9002') - const server3 = follows.find(f => f.following.host === 'localhost:9003') - - expect(server3).to.not.be.undefined - expect(server3.following.hostRedundancyAllowed).to.be.false - - expect(server2).to.not.be.undefined - expect(server2.following.hostRedundancyAllowed).to.be.true -} - -async function disableRedundancyOnServer1 () { - await updateRedundancy(servers[ 0 ].url, servers[ 0 ].accessToken, servers[ 1 ].host, false) - - const res = await getFollowingListPaginationAndSort(servers[ 0 ].url, 0, 5, '-createdAt') - const follows: ActorFollow[] = res.body.data - const server2 = follows.find(f => f.following.host === 'localhost:9002') - const server3 = follows.find(f => f.following.host === 'localhost:9003') - - expect(server3).to.not.be.undefined - expect(server3.following.hostRedundancyAllowed).to.be.false - - expect(server2).to.not.be.undefined - expect(server2.following.hostRedundancyAllowed).to.be.false -} - -async function cleanServers () { - killallServers(servers) -} - -describe('Test videos redundancy', function () { - - describe('With most-views strategy', function () { - const strategy = 'most-views' - - before(function () { - this.timeout(120000) - - return runServers(strategy) - }) - - it('Should have 1 webseed on the first video', async function () { - await check1WebSeed(strategy) - await checkStatsWith1Webseed(strategy) - }) - - it('Should enable redundancy on server 1', function () { - return enableRedundancyOnServer1() - }) - - it('Should have 2 webseeds on the first video', async function () { - this.timeout(40000) - - await waitJobs(servers) - await waitUntilLog(servers[0], 'Duplicated ', 4) - await waitJobs(servers) - - await check2Webseeds(strategy) - await checkStatsWith2Webseed(strategy) - }) - - it('Should undo redundancy on server 1 and remove duplicated videos', async function () { - this.timeout(40000) - - await disableRedundancyOnServer1() - - await waitJobs(servers) - await wait(5000) - - await check1WebSeed(strategy) - - await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) - }) - - after(function () { - return cleanServers() - }) - }) - - describe('With trending strategy', function () { - const strategy = 'trending' - - before(function () { - this.timeout(120000) - - return runServers(strategy) - }) - - it('Should have 1 webseed on the first video', async function () { - await check1WebSeed(strategy) - await checkStatsWith1Webseed(strategy) - }) - - it('Should enable redundancy on server 1', function () { - return enableRedundancyOnServer1() - }) - - it('Should have 2 webseeds on the first video', async function () { - this.timeout(40000) - - await waitJobs(servers) - await waitUntilLog(servers[0], 'Duplicated ', 4) - await waitJobs(servers) - - await check2Webseeds(strategy) - await checkStatsWith2Webseed(strategy) - }) - - it('Should unfollow on server 1 and remove duplicated videos', async function () { - this.timeout(40000) - - await unfollow(servers[0].url, servers[0].accessToken, servers[1]) - - await waitJobs(servers) - await wait(5000) - - await check1WebSeed(strategy) - - await checkVideoFilesWereRemoved(video1Server2UUID, servers[0].serverNumber, [ 'videos' ]) - }) - - after(function () { - return cleanServers() - }) - }) - - describe('With recently added strategy', function () { - const strategy = 'recently-added' - - before(function () { - this.timeout(120000) - - return runServers(strategy, { min_views: 3 }) - }) - - it('Should have 1 webseed on the first video', async function () { - await check1WebSeed(strategy) - await checkStatsWith1Webseed(strategy) - }) - - it('Should enable redundancy on server 1', function () { - return enableRedundancyOnServer1() - }) - - it('Should still have 1 webseed on the first video', async function () { - this.timeout(40000) - - await waitJobs(servers) - await wait(15000) - await waitJobs(servers) - - await check1WebSeed(strategy) - await checkStatsWith1Webseed(strategy) - }) - - it('Should view 2 times the first video to have > min_views config', async function () { - this.timeout(40000) - - await viewVideo(servers[ 0 ].url, video1Server2UUID) - await viewVideo(servers[ 2 ].url, video1Server2UUID) - - await wait(10000) - await waitJobs(servers) - }) - - it('Should have 2 webseeds on the first video', async function () { - this.timeout(40000) - - await waitJobs(servers) - await waitUntilLog(servers[0], 'Duplicated ', 4) - await waitJobs(servers) - - await check2Webseeds(strategy) - await checkStatsWith2Webseed(strategy) - }) - - it('Should remove the video and the redundancy files', async function () { - this.timeout(20000) - - await removeVideo(servers[1].url, servers[1].accessToken, video1Server2UUID) - - await waitJobs(servers) - - for (const server of servers) { - await checkVideoFilesWereRemoved(video1Server2UUID, server.serverNumber) - } - }) - - after(function () { - return cleanServers() - }) - }) - - describe('Test expiration', function () { - const strategy = 'recently-added' - - async function checkContains (servers: ServerInfo[], str: string) { - for (const server of servers) { - const res = await getVideo(server.url, video1Server2UUID) - const video: VideoDetails = res.body - - for (const f of video.files) { - expect(f.magnetUri).to.contain(str) - } - } - } - - async function checkNotContains (servers: ServerInfo[], str: string) { - for (const server of servers) { - const res = await getVideo(server.url, video1Server2UUID) - const video: VideoDetails = res.body - - for (const f of video.files) { - expect(f.magnetUri).to.not.contain(str) - } - } - } - - before(async function () { - this.timeout(120000) - - await runServers(strategy, { min_lifetime: '7 seconds', min_views: 0 }) - - await enableRedundancyOnServer1() - }) - - it('Should still have 2 webseedss after 10 seconds', async function () { - this.timeout(40000) - - await wait(10000) - - try { - await checkContains(servers, 'http%3A%2F%2Flocalhost%3A9001') - } catch { - // Maybe a server deleted a redundancy in the scheduler - await wait(2000) - - await checkContains(servers, 'http%3A%2F%2Flocalhost%3A9001') - } - }) - - it('Should stop server 1 and expire video redundancy', async function () { - this.timeout(40000) - - killallServers([ servers[0] ]) - - await wait(15000) - - await checkNotContains([ servers[1], servers[2] ], 'http%3A%2F%2Flocalhost%3A9001') - }) - - after(function () { - return killallServers([ servers[1], servers[2] ]) - }) - }) - - describe('Test file replacement', function () { - let video2Server2UUID: string - const strategy = 'recently-added' - - before(async function () { - this.timeout(120000) - - await runServers(strategy, { min_lifetime: '7 seconds', min_views: 0 }) - - await enableRedundancyOnServer1() - - await waitJobs(servers) - await waitUntilLog(servers[0], 'Duplicated ', 4) - await waitJobs(servers) - - await check2Webseeds(strategy) - await checkStatsWith2Webseed(strategy) - - const res = await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, { name: 'video 2 server 2' }) - video2Server2UUID = res.body.video.uuid - }) - - it('Should cache video 2 webseeds on the first video', async function () { - this.timeout(120000) - - await waitJobs(servers) - - let checked = false - - while (checked === false) { - await wait(1000) - - try { - await check1WebSeed(strategy, video1Server2UUID) - await check2Webseeds(strategy, video2Server2UUID) - - checked = true - } catch { - checked = false - } - } - }) - - after(function () { - return cleanServers() - }) - }) -}) -- cgit v1.2.3 From 030177d246834fdba89be9bbaeac497589b47102 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 16:18:12 +0100 Subject: Don't forward view, send updates instead To avoid inconsistencies in the federation, now the origin server will tell other instances what is the correct number of views --- server/controllers/api/videos/index.ts | 6 +++++- server/lib/activitypub/process/process-create.ts | 16 ++++------------ server/lib/job-queue/handlers/video-views.ts | 8 ++++++-- server/lib/job-queue/job-queue.ts | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) (limited to 'server') diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts index 664154406..e654bdd09 100644 --- a/server/controllers/api/videos/index.ts +++ b/server/controllers/api/videos/index.ts @@ -405,7 +405,11 @@ async function viewVideo (req: express.Request, res: express.Response) { const serverActor = await getServerActor() - await sendCreateView(serverActor, videoInstance, undefined) + // Send the event to the origin server + // If we own the video, we'll send an update event when we'll process the views (in our job queue) + if (videoInstance.isOwned() === false) { + await sendCreateView(serverActor, videoInstance, undefined) + } return res.status(204).end() } diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts index 920d02cd2..214e14546 100644 --- a/server/lib/activitypub/process/process-create.ts +++ b/server/lib/activitypub/process/process-create.ts @@ -13,7 +13,8 @@ import { forwardVideoRelatedActivity } from '../send/utils' import { Redis } from '../../redis' import { createOrUpdateCacheFile } from '../cache-file' import { immutableAssign } from '../../../tests/utils' -import { getVideoDislikeActivityPubUrl, getVideoLikeActivityPubUrl } from '../url' +import { getVideoDislikeActivityPubUrl } from '../url' +import { VideoModel } from '../../../models/video/video' async function processCreateActivity (activity: ActivityCreate, byActor: ActorModel) { const activityObject = activity.object @@ -87,19 +88,10 @@ async function processCreateDislike (byActor: ActorModel, activity: ActivityCrea async function processCreateView (byActor: ActorModel, activity: ActivityCreate) { const view = activity.object as ViewObject - const options = { - videoObject: view.object, - fetchType: 'only-video' as 'only-video' - } - const { video } = await getOrCreateVideoAndAccountAndChannel(options) + const video = await VideoModel.loadByUrl(view.object) + if (!video || video.isOwned() === false) return await Redis.Instance.addVideoView(video.id) - - if (video.isOwned()) { - // Don't resend the activity to the sender - const exceptions = [ byActor ] - await forwardVideoRelatedActivity(activity, undefined, exceptions, video) - } } async function processCacheFile (byActor: ActorModel, activity: ActivityCreate) { diff --git a/server/lib/job-queue/handlers/video-views.ts b/server/lib/job-queue/handlers/video-views.ts index cf180a11a..2ceec2342 100644 --- a/server/lib/job-queue/handlers/video-views.ts +++ b/server/lib/job-queue/handlers/video-views.ts @@ -3,8 +3,9 @@ import { logger } from '../../../helpers/logger' import { VideoModel } from '../../../models/video/video' import { VideoViewModel } from '../../../models/video/video-views' import { isTestInstance } from '../../../helpers/core-utils' +import { federateVideoIfNeeded } from '../../activitypub' -async function processVideosViewsViews () { +async function processVideosViews () { const lastHour = new Date() // In test mode, we run this function multiple times per hour, so we don't want the values of the previous hour @@ -36,6 +37,9 @@ async function processVideosViewsViews () { views, videoId }) + + const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) + await federateVideoIfNeeded(video, false) } catch (err) { logger.debug('Cannot create video views for video %d in hour %d. Maybe the video does not exist anymore?', videoId, hour) } @@ -51,5 +55,5 @@ async function processVideosViewsViews () { // --------------------------------------------------------------------------- export { - processVideosViewsViews + processVideosViews } diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts index 0696ba43c..4cfd4d253 100644 --- a/server/lib/job-queue/job-queue.ts +++ b/server/lib/job-queue/job-queue.ts @@ -10,7 +10,7 @@ import { EmailPayload, processEmail } from './handlers/email' import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file' import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' import { processVideoImport, VideoImportPayload } from './handlers/video-import' -import { processVideosViewsViews } from './handlers/video-views' +import { processVideosViews } from './handlers/video-views' type CreateJobArgument = { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | @@ -32,7 +32,7 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise} = { 'video-file': processVideoFile, 'email': processEmail, 'video-import': processVideoImport, - 'videos-views': processVideosViewsViews + 'videos-views': processVideosViews } const jobTypes: JobType[] = [ -- cgit v1.2.3 From 6385c0cb7f773f5212a96807468988e17dba1d6d Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 16:57:59 +0100 Subject: Fix embed video id parsing --- server/lib/job-queue/handlers/video-views.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'server') diff --git a/server/lib/job-queue/handlers/video-views.ts b/server/lib/job-queue/handlers/video-views.ts index 2ceec2342..f44c3c727 100644 --- a/server/lib/job-queue/handlers/video-views.ts +++ b/server/lib/job-queue/handlers/video-views.ts @@ -39,7 +39,7 @@ async function processVideosViews () { }) const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) - await federateVideoIfNeeded(video, false) + if (video.isOwned()) await federateVideoIfNeeded(video, false) } catch (err) { logger.debug('Cannot create video views for video %d in hour %d. Maybe the video does not exist anymore?', videoId, hour) } -- cgit v1.2.3 From 8cf998733496d44fa564e2e252356b871756c984 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 15 Nov 2018 17:12:41 +0100 Subject: Fix video channel videos url when scrolling --- server/tests/api/server/handle-down.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'server') diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts index ed15c8090..0421b2b40 100644 --- a/server/tests/api/server/handle-down.ts +++ b/server/tests/api/server/handle-down.ts @@ -5,7 +5,7 @@ import 'mocha' import { JobState, Video } from '../../../../shared/models' import { VideoPrivacy } from '../../../../shared/models/videos' import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' -import { completeVideoCheck, getVideo, immutableAssign, reRunServer, unfollow, viewVideo } from '../../utils' +import { completeVideoCheck, getVideo, immutableAssign, reRunServer, unfollow, updateVideo, viewVideo } from '../../utils' import { flushAndRunMultipleServers, getVideosList, @@ -194,15 +194,15 @@ describe('Test handle downs', function () { expect(res.body.data).to.have.lengthOf(2) }) - it('Should send a view to server 3, and automatically fetch the video', async function () { + it('Should send an update to server 3, and automatically fetch the video', async function () { this.timeout(15000) const res1 = await getVideosList(servers[2].url) expect(res1.body.data).to.be.an('array') expect(res1.body.data).to.have.lengthOf(11) - await viewVideo(servers[0].url, missedVideo1.uuid) - await viewVideo(servers[0].url, unlistedVideo.uuid) + await updateVideo(servers[0].url, servers[0].accessToken, missedVideo1.uuid, { }) + await updateVideo(servers[0].url, servers[0].accessToken, unlistedVideo.uuid, { }) await waitJobs(servers) -- cgit v1.2.3