diff --git a/packages/tests/src/server-helpers/activitypub.ts b/packages/tests/src/server-helpers/activitypub.ts index 8b8b7a45b..43c5cab41 100644 --- a/packages/tests/src/server-helpers/activitypub.ts +++ b/packages/tests/src/server-helpers/activitypub.ts @@ -3,7 +3,7 @@ import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { signAndContextify } from '@peertube/peertube-server/core/helpers/activity-pub-utils.js' import { isHTTPSignatureVerified, parseHTTPSignature } from '@peertube/peertube-server/core/helpers/peertube-crypto.js' -import { isJsonLDSignatureVerified, signJsonLDObject } from '@peertube/peertube-server/core/helpers/peertube-jsonld.js' +import { compactJSONLDAndCheckSignature, signJsonLDObject } from '@peertube/peertube-server/core/helpers/peertube-jsonld.js' import { expect } from 'chai' import { readJsonSync } from 'fs-extra/esm' import cloneDeep from 'lodash-es/cloneDeep.js' @@ -24,6 +24,10 @@ function fakeFilter () { return (data: any) => Promise.resolve(data) } +function fakeExpressReq (body: any) { + return { body } +} + describe('Test activity pub helpers', function () { describe('When checking the Linked Signature', function () { @@ -33,7 +37,7 @@ describe('Test activity pub helpers', function () { const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } - const result = await isJsonLDSignatureVerified(fromActor as any, body) + const result = await compactJSONLDAndCheckSignature(fromActor as any, fakeExpressReq(body)) expect(result).to.be.false }) @@ -43,7 +47,7 @@ describe('Test activity pub helpers', function () { const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } - const result = await isJsonLDSignatureVerified(fromActor as any, body) + const result = await compactJSONLDAndCheckSignature(fromActor as any, fakeExpressReq(body)) expect(result).to.be.false }) @@ -53,7 +57,7 @@ describe('Test activity pub helpers', function () { const publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } - const result = await isJsonLDSignatureVerified(fromActor as any, body) + const result = await compactJSONLDAndCheckSignature(fromActor as any, fakeExpressReq(body)) expect(result).to.be.true }) @@ -72,7 +76,7 @@ describe('Test activity pub helpers', function () { }) const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } - const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + const result = await compactJSONLDAndCheckSignature(fromActor as any, fakeExpressReq(signedBody)) expect(result).to.be.false }) @@ -91,7 +95,7 @@ describe('Test activity pub helpers', function () { }) const fromActor = { publicKey: keys.publicKey, url: 'http://localhost:9002/accounts/peertube' } - const result = await isJsonLDSignatureVerified(fromActor as any, signedBody) + const result = await compactJSONLDAndCheckSignature(fromActor as any, fakeExpressReq(signedBody)) expect(result).to.be.true }) diff --git a/server/core/helpers/activity-pub-utils.ts b/server/core/helpers/activity-pub-utils.ts index fdbd36d4a..9ff88370d 100644 --- a/server/core/helpers/activity-pub-utils.ts +++ b/server/core/helpers/activity-pub-utils.ts @@ -1,9 +1,9 @@ import { ContextType } from '@peertube/peertube-models' import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js' +import { isArray } from './custom-validators/misc.js' import { buildDigest } from './peertube-crypto.js' import type { signJsonLDObject } from './peertube-jsonld.js' import { doJSONRequest } from './requests.js' -import { isArray } from './custom-validators/misc.js' export type ContextFilter = (arg: T) => Promise @@ -49,6 +49,18 @@ export async function getApplicationActorOfHost (host: string) { return found?.href || undefined } +export function getAPPublicValue () { + return 'https://www.w3.org/ns/activitystreams#Public' +} + +export function hasAPPublic (toOrCC: string[]) { + if (!isArray(toOrCC)) return false + + const publicValue = getAPPublicValue() + + return toOrCC.some(f => f === 'as:Public' || publicValue) +} + // --------------------------------------------------------------------------- // Private // --------------------------------------------------------------------------- @@ -58,7 +70,6 @@ type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = { Video: buildContext({ Hashtag: 'as:Hashtag', - uuid: 'sc:identifier', category: 'sc:category', licence: 'sc:license', subtitleLanguage: 'sc:subtitleLanguage', @@ -99,6 +110,11 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string '@id': 'pt:aspectRatio' }, + uuid: { + '@type': 'sc:identifier', + '@id': 'pt:uuid' + }, + originallyPublishedAt: 'sc:datePublished', uploadDate: 'sc:uploadDate', @@ -170,12 +186,23 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string '@type': 'sc:Number', '@id': 'pt:stopTimestamp' }, - uuid: 'sc:identifier' + uuid: { + '@type': 'sc:identifier', + '@id': 'pt:uuid' + } }), CacheFile: buildContext({ expires: 'sc:expires', - CacheFile: 'pt:CacheFile' + CacheFile: 'pt:CacheFile', + size: { + '@type': 'sc:Number', + '@id': 'pt:size' + }, + fps: { + '@type': 'sc:Number', + '@id': 'pt:fps' + } }), Flag: buildContext({ @@ -205,15 +232,21 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string '@type': 'sc:Number', '@id': 'pt:startTimestamp' }, - stopTimestamp: { + endTimestamp: { '@type': 'sc:Number', - '@id': 'pt:stopTimestamp' + '@id': 'pt:endTimestamp' }, - watchSection: { - '@type': 'sc:Number', - '@id': 'pt:stopTimestamp' + uuid: { + '@type': 'sc:identifier', + '@id': 'pt:uuid' }, - uuid: 'sc:identifier' + actionStatus: 'sc:actionStatus', + watchSections: { + '@type': '@id', + '@id': 'pt:watchSections' + }, + addressRegion: 'sc:addressRegion', + addressCountry: 'sc:addressCountry' }), View: buildContext({ @@ -233,13 +266,46 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string Rate: buildContext(), Chapters: buildContext({ - name: 'sc:name', hasPart: 'sc:hasPart', endOffset: 'sc:endOffset', startOffset: 'sc:startOffset' }) } +let allContext: (string | ContextValue)[] +export function getAllContext () { + if (allContext) return allContext + + const processed = new Set() + allContext = [] + + let staticContext: ContextValue = {} + + for (const v of Object.values(contextStore)) { + for (const item of v) { + if (typeof item === 'string') { + if (!processed.has(item)) { + allContext.push(item) + } + + processed.add(item) + } else { + for (const subKey of Object.keys(item)) { + if (!processed.has(subKey)) { + staticContext = { ...staticContext, [subKey]: item[subKey] } + } + + processed.add(subKey) + } + } + } + } + + allContext = [ ...allContext, staticContext ] + + return allContext +} + async function getContextData (type: ContextType, contextFilter: ContextFilter) { const contextData = contextFilter ? await contextFilter(contextStore[type]) diff --git a/server/core/helpers/custom-jsonld-signature.ts b/server/core/helpers/custom-jsonld-signature.ts index 402c1e13e..231ebde86 100644 --- a/server/core/helpers/custom-jsonld-signature.ts +++ b/server/core/helpers/custom-jsonld-signature.ts @@ -1,6 +1,6 @@ import jsonld from 'jsonld' -const CACHE = { +const STATIC_CACHE = { 'https://w3id.org/security/v1': { '@context': { id: '@id', @@ -53,19 +53,29 @@ const CACHE = { } } +const localCache = new Map() + const nodeDocumentLoader = (jsonld as any).documentLoaders.node(); /* eslint-disable no-import-assign */ -(jsonld as any).documentLoader = (url) => { - if (url in CACHE) { - return Promise.resolve({ +(jsonld as any).documentLoader = async (url: string) => { + if (url in STATIC_CACHE) { + return { contextUrl: null, - document: CACHE[url], + document: STATIC_CACHE[url], documentUrl: url - }) + } } - return nodeDocumentLoader(url) + if (localCache.has(url)) return localCache.get(url) + + const remoteDoc = await nodeDocumentLoader(url) + + if (localCache.size < 100) { + localCache.set(url, remoteDoc) + } + + return remoteDoc } export { jsonld } diff --git a/server/core/helpers/custom-validators/activitypub/cache-file.ts b/server/core/helpers/custom-validators/activitypub/cache-file.ts index 2b1408eb7..6b184a024 100644 --- a/server/core/helpers/custom-validators/activitypub/cache-file.ts +++ b/server/core/helpers/custom-validators/activitypub/cache-file.ts @@ -1,20 +1,15 @@ import { CacheFileObject } from '@peertube/peertube-models' -import { exists, isDateValid } from '../misc.js' +import { MIMETYPES } from '@server/initializers/constants.js' +import validator from 'validator' +import { isDateValid } from '../misc.js' import { isActivityPubUrlValid } from './misc.js' -import { isRemoteVideoUrlValid } from './videos.js' -function isCacheFileObjectValid (object: CacheFileObject) { - return exists(object) && - object.type === 'CacheFile' && - (object.expires === null || isDateValid(object.expires)) && +export function isCacheFileObjectValid (object: CacheFileObject) { + if (!object || object.type !== 'CacheFile') return false + + return (!object.expires || isDateValid(object.expires)) && isActivityPubUrlValid(object.object) && - (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url)) -} - -// --------------------------------------------------------------------------- - -export { - isCacheFileObjectValid + (isRedundancyUrlVideoValid(object.url) || isPlaylistRedundancyUrlValid(object.url)) } // --------------------------------------------------------------------------- @@ -24,3 +19,15 @@ function isPlaylistRedundancyUrlValid (url: any) { (url.mediaType || url.mimeType) === 'application/x-mpegURL' && isActivityPubUrlValid(url.href) } + +// TODO: compat with < 6.1, use isRemoteVideoUrlValid instead in 7.0 +function isRedundancyUrlVideoValid (url: any) { + const size = url.size || url['_:size'] + const fps = url.fps || url['_fps'] + + return MIMETYPES.AP_VIDEO.MIMETYPE_EXT[url.mediaType] && + isActivityPubUrlValid(url.href) && + validator.default.isInt(url.height + '', { min: 0 }) && + validator.default.isInt(size + '', { min: 0 }) && + (!fps || validator.default.isInt(fps + '', { min: -1 })) +} diff --git a/server/core/helpers/custom-validators/activitypub/playlist.ts b/server/core/helpers/custom-validators/activitypub/playlist.ts index 7c338992b..acd337c87 100644 --- a/server/core/helpers/custom-validators/activitypub/playlist.ts +++ b/server/core/helpers/custom-validators/activitypub/playlist.ts @@ -1,29 +1,25 @@ -import validator from 'validator' import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models' +import validator from 'validator' import { exists, isDateValid, isUUIDValid } from '../misc.js' import { isVideoPlaylistNameValid } from '../video-playlists.js' import { isActivityPubUrlValid } from './misc.js' -function isPlaylistObjectValid (object: PlaylistObject) { - return exists(object) && - object.type === 'Playlist' && - validator.default.isInt(object.totalItems + '') && +export function isPlaylistObjectValid (object: PlaylistObject) { + if (!object || object.type !== 'Playlist') return false + + // TODO: compat with < 6.1, remove in 7.0 + if (!object.uuid && object['identifier']) object.uuid = object['identifier'] + + return validator.default.isInt(object.totalItems + '') && isVideoPlaylistNameValid(object.name) && isUUIDValid(object.uuid) && isDateValid(object.published) && isDateValid(object.updated) } -function isPlaylistElementObjectValid (object: PlaylistElementObject) { +export function isPlaylistElementObjectValid (object: PlaylistElementObject) { return exists(object) && object.type === 'PlaylistElement' && validator.default.isInt(object.position + '') && isActivityPubUrlValid(object.url) } - -// --------------------------------------------------------------------------- - -export { - isPlaylistObjectValid, - isPlaylistElementObjectValid -} diff --git a/server/core/helpers/custom-validators/activitypub/video-comments.ts b/server/core/helpers/custom-validators/activitypub/video-comments.ts index a024476dc..944b5e996 100644 --- a/server/core/helpers/custom-validators/activitypub/video-comments.ts +++ b/server/core/helpers/custom-validators/activitypub/video-comments.ts @@ -1,5 +1,5 @@ +import { hasAPPublic } from '@server/helpers/activity-pub-utils.js' import validator from 'validator' -import { ACTIVITY_PUB } from '../../../initializers/constants.js' import { exists, isArray, isDateValid } from '../misc.js' import { isActivityPubUrlValid } from './misc.js' @@ -23,10 +23,7 @@ function sanitizeAndCheckVideoCommentObject (comment: any) { isDateValid(comment.published) && isActivityPubUrlValid(comment.url) && isArray(comment.to) && - ( - comment.to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 || - comment.cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 - ) // Only accept public comments + (hasAPPublic(comment.to) || hasAPPublic(comment.cc)) // Only accept public comments } // --------------------------------------------------------------------------- diff --git a/server/core/helpers/custom-validators/activitypub/videos.ts b/server/core/helpers/custom-validators/activitypub/videos.ts index 7ef9723ca..5561b94d8 100644 --- a/server/core/helpers/custom-validators/activitypub/videos.ts +++ b/server/core/helpers/custom-validators/activitypub/videos.ts @@ -27,7 +27,7 @@ function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) { sanitizeAndCheckVideoTorrentObject(activity.object) } -function sanitizeAndCheckVideoTorrentObject (video: any) { +function sanitizeAndCheckVideoTorrentObject (video: VideoObject) { if (!video || video.type !== 'Video') return false if (!setValidRemoteTags(video)) { @@ -59,6 +59,9 @@ function sanitizeAndCheckVideoTorrentObject (video: any) { return false } + // TODO: compat with < 6.1, remove in 7.0 + if (!video.uuid && video['identifier']) video.uuid = video['identifier'] + // Default attributes if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false diff --git a/server/core/helpers/custom-validators/activitypub/watch-action.ts b/server/core/helpers/custom-validators/activitypub/watch-action.ts index 30fe1c5ec..b2c86ab95 100644 --- a/server/core/helpers/custom-validators/activitypub/watch-action.ts +++ b/server/core/helpers/custom-validators/activitypub/watch-action.ts @@ -1,19 +1,26 @@ +import { arrayify } from '@peertube/peertube-core-utils' import { WatchActionObject } from '@peertube/peertube-models' -import { exists, isDateValid, isUUIDValid } from '../misc.js' +import { isDateValid, isUUIDValid } from '../misc.js' import { isVideoTimeValid } from '../video-view.js' import { isActivityPubVideoDurationValid, isObjectValid } from './misc.js' function isWatchActionObjectValid (action: WatchActionObject) { - return exists(action) && - action.type === 'WatchAction' && - isObjectValid(action.id) && + if (!action || action.type !== 'WatchAction') return false + + // TODO: compat with < 6.1, remove in 7.0 + if (!action.uuid && action['identifier']) action.uuid = action['identifier'] + + if (action['_:actionStatus'] && !action.actionStatus) action.actionStatus = action['_:actionStatus'] + if (action['_:watchSections'] && !action.watchSections) action.watchSections = arrayify(action['_:watchSections']) + + return isObjectValid(action.id) && isActivityPubVideoDurationValid(action.duration) && isDateValid(action.startTime) && isDateValid(action.endTime) && isLocationValid(action.location) && isUUIDValid(action.uuid) && isObjectValid(action.object) && - isWatchSectionsValid(action.watchSections) + areWatchSectionsValid(action.watchSections) } // --------------------------------------------------------------------------- @@ -34,8 +41,11 @@ function isLocationValid (location: any) { return true } -function isWatchSectionsValid (sections: WatchActionObject['watchSections']) { +function areWatchSectionsValid (sections: WatchActionObject['watchSections']) { return Array.isArray(sections) && sections.every(s => { + // TODO: compat with < 6.1, remove in 7.0 + if (s['_:endTimestamp'] && !s.endTimestamp) s.endTimestamp = s['_:endTimestamp'] + return isVideoTimeValid(s.startTimestamp) && isVideoTimeValid(s.endTimestamp) }) } diff --git a/server/core/helpers/custom-validators/videos.ts b/server/core/helpers/custom-validators/videos.ts index f99f93847..0c916a0ea 100644 --- a/server/core/helpers/custom-validators/videos.ts +++ b/server/core/helpers/custom-validators/videos.ts @@ -70,7 +70,7 @@ export function areVideoTagsValid (tags: string[]) { ) } -export function isVideoViewsValid (value: string) { +export function isVideoViewsValid (value: string | number) { return exists(value) && validator.default.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.VIEWS) } diff --git a/server/core/helpers/peertube-jsonld.ts b/server/core/helpers/peertube-jsonld.ts index 320351e67..f22743a18 100644 --- a/server/core/helpers/peertube-jsonld.ts +++ b/server/core/helpers/peertube-jsonld.ts @@ -1,26 +1,51 @@ +import { omit } from '@peertube/peertube-core-utils' import { sha256 } from '@peertube/peertube-node-utils' import { createSign, createVerify } from 'crypto' import cloneDeep from 'lodash-es/cloneDeep.js' import { MActor } from '../types/models/index.js' +import { getAllContext } from './activity-pub-utils.js' +import { jsonld } from './custom-jsonld-signature.js' +import { isArray } from './custom-validators/misc.js' import { logger } from './logger.js' import { assertIsInWorkerThread } from './threads.js' -import { jsonld } from './custom-jsonld-signature.js' -export function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise { - if (signedDocument.signature.type === 'RsaSignature2017') { - return isJsonLDRSA2017Verified(fromActor, signedDocument) +type ExpressRequest = { body: any } + +export function compactJSONLDAndCheckSignature (fromActor: MActor, req: ExpressRequest): Promise { + if (req.body.signature.type === 'RsaSignature2017') { + return compactJSONLDAndCheckRSA2017Signature(fromActor, req) } - logger.warn('Unknown JSON LD signature %s.', signedDocument.signature.type, signedDocument) + logger.warn('Unknown JSON LD signature %s.', req.body.signature.type, req.body) return Promise.resolve(false) } // Backward compatibility with "other" implementations -export async function isJsonLDRSA2017Verified (fromActor: MActor, signedDocument: any) { +export async function compactJSONLDAndCheckRSA2017Signature (fromActor: MActor, req: ExpressRequest) { + const compacted = await jsonldCompact(omit(req.body, [ 'signature' ])) + + fixCompacted(req.body, compacted) + + req.body = { ...compacted, signature: req.body.signature } + + if (compacted['@include']) { + logger.warn('JSON-LD @include is not supported') + return false + } + + // TODO: compat with < 6.1, remove in 7.0 + let safe = true + if ( + (compacted.type === 'Create' && (compacted?.object?.type === 'WatchAction' || compacted?.object?.type === 'CacheFile')) || + (compacted.type === 'Undo' && compacted?.object?.type === 'Create' && compacted?.object?.object.type === 'CacheFile') + ) { + safe = false + } + const [ documentHash, optionsHash ] = await Promise.all([ - createDocWithoutSignatureHash(signedDocument), - createSignatureHash(signedDocument.signature) + hashObject(compacted, safe), + createSignatureHash(req.body.signature, safe) ]) const toVerify = optionsHash + documentHash @@ -28,7 +53,39 @@ export async function isJsonLDRSA2017Verified (fromActor: MActor, signedDocument const verify = createVerify('RSA-SHA256') verify.update(toVerify, 'utf8') - return verify.verify(fromActor.publicKey, signedDocument.signature.signatureValue, 'base64') + return verify.verify(fromActor.publicKey, req.body.signature.signatureValue, 'base64') +} + +function fixCompacted (original: any, compacted: any) { + if (!original || !compacted) return + + for (const [ k, v ] of Object.entries(original)) { + if (k === '@context' || k === 'signature') continue + if (v === undefined || v === null) continue + + const cv = compacted[k] + if (cv === undefined || cv === null) continue + + if (typeof v === 'string') { + if (v === 'https://www.w3.org/ns/activitystreams#Public' && cv === 'as:Public') { + compacted[k] = v + } + } + + if (isArray(v) && !isArray(cv)) { + compacted[k] = [ cv ] + + for (let i = 0; i < v.length; i++) { + if (v[i] === 'https://www.w3.org/ns/activitystreams#Public' && cv[i] === 'as:Public') { + compacted[k][i] = v[i] + } + } + } + + if (typeof v === 'object') { + fixCompacted(original[k], compacted[k]) + } + } } export async function signJsonLDObject (options: { @@ -66,35 +123,40 @@ export async function signJsonLDObject (options: { // Private // --------------------------------------------------------------------------- -async function hashObject (obj: any): Promise { - const res = await (jsonld as any).promises.normalize(obj, { - safe: false, - algorithm: 'URDNA2015', - format: 'application/n-quads' - }) +async function hashObject (obj: any, safe: boolean): Promise { + const res = await jsonldNormalize(obj, safe) return sha256(res) } -function createSignatureHash (signature: any) { - const signatureCopy = cloneDeep(signature) - Object.assign(signatureCopy, { +function jsonldCompact (obj: any) { + return (jsonld as any).promises.compact(obj, getAllContext()) +} + +function jsonldNormalize (obj: any, safe: boolean) { + return (jsonld as any).promises.normalize(obj, { + safe, + algorithm: 'URDNA2015', + format: 'application/n-quads' + }) +} + +// --------------------------------------------------------------------------- + +function createSignatureHash (signature: any, safe = true) { + return hashObject({ '@context': [ 'https://w3id.org/security/v1', { RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' } - ] - }) + ], - delete signatureCopy.type - delete signatureCopy.id - delete signatureCopy.signatureValue - - return hashObject(signatureCopy) + ...omit(signature, [ 'type', 'id', 'signatureValue' ]) + }, safe) } function createDocWithoutSignatureHash (doc: any) { const docWithoutSignature = cloneDeep(doc) delete docWithoutSignature.signature - return hashObject(docWithoutSignature) + return hashObject(docWithoutSignature, true) } diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index aff54ca5a..29f556c7c 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -774,7 +774,6 @@ const ACTIVITY_PUB = { 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' ], ACCEPT_HEADER: 'application/activity+json, application/ld+json', - PUBLIC: 'https://www.w3.org/ns/activitystreams#Public', COLLECTION_ITEMS_PER_PAGE: 10, FETCH_PAGE_LIMIT: 2000, MAX_RECURSION_COMMENTS: 100, diff --git a/server/core/lib/activitypub/audience.ts b/server/core/lib/activitypub/audience.ts index 197acdb85..6bbc56cdd 100644 --- a/server/core/lib/activitypub/audience.ts +++ b/server/core/lib/activitypub/audience.ts @@ -1,17 +1,17 @@ import { ActivityAudience } from '@peertube/peertube-models' -import { ACTIVITY_PUB } from '../../initializers/constants.js' +import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js' import { MActorFollowersUrl } from '../../types/models/index.js' -function getAudience (actorSender: MActorFollowersUrl, isPublic = true) { +export function getAudience (actorSender: MActorFollowersUrl, isPublic = true) { return buildAudience([ actorSender.followersUrl ], isPublic) } -function buildAudience (followerUrls: string[], isPublic = true) { +export function buildAudience (followerUrls: string[], isPublic = true) { let to: string[] = [] let cc: string[] = [] if (isPublic) { - to = [ ACTIVITY_PUB.PUBLIC ] + to = [ getAPPublicValue() ] cc = followerUrls } else { // Unlisted to = [] @@ -21,14 +21,6 @@ function buildAudience (followerUrls: string[], isPublic = true) { return { to, cc } } -function audiencify (object: T, audience: ActivityAudience) { +export function audiencify (object: T, audience: ActivityAudience) { return { ...audience, ...object } } - -// --------------------------------------------------------------------------- - -export { - buildAudience, - getAudience, - audiencify -} diff --git a/server/core/lib/activitypub/cache-file.ts b/server/core/lib/activitypub/cache-file.ts index ef8bbace8..a446f3e7c 100644 --- a/server/core/lib/activitypub/cache-file.ts +++ b/server/core/lib/activitypub/cache-file.ts @@ -2,6 +2,7 @@ import { Transaction } from 'sequelize' import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js' import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models' import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js' +import { exists } from '@server/helpers/custom-validators/misc.js' async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) { const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t) @@ -65,11 +66,15 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject } const url = cacheFileObject.url + const urlFPS = exists(url.fps) // TODO: compat with < 6.1, remove in 7.0 + ? url.fps + : url['_:fps'] + const videoFile = video.VideoFiles.find(f => { - return f.resolution === url.height && f.fps === url.fps + return f.resolution === url.height && f.fps === urlFPS }) - if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`) + if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${urlFPS} of video ${video.url}`) return { expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null, diff --git a/server/core/lib/activitypub/inbox-manager.ts b/server/core/lib/activitypub/inbox-manager.ts index ecf95d1ee..01acb36db 100644 --- a/server/core/lib/activitypub/inbox-manager.ts +++ b/server/core/lib/activitypub/inbox-manager.ts @@ -6,7 +6,7 @@ import { Activity } from '@peertube/peertube-models' import { StatsManager } from '../stat-manager.js' import { processActivities } from './process/index.js' -class InboxManager { +export class InboxManager { private static instance: InboxManager private readonly inboxQueue: PQueue @@ -39,9 +39,3 @@ class InboxManager { return this.instance || (this.instance = new this()) } } - -// --------------------------------------------------------------------------- - -export { - InboxManager -} diff --git a/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts index 1964e04df..449edb615 100644 --- a/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/playlists/shared/object-to-model-attributes.ts @@ -1,12 +1,12 @@ -import { ACTIVITY_PUB } from '@server/initializers/constants.js' -import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' -import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js' -import { MVideoId, MVideoPlaylistId } from '@server/types/models/index.js' -import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@peertube/peertube-models' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' +import { hasAPPublic } from '@server/helpers/activity-pub-utils.js' +import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js' +import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' +import { MVideoId, MVideoPlaylistId } from '@server/types/models/index.js' -function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) { - const privacy = to.includes(ACTIVITY_PUB.PUBLIC) +export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) { + const privacy = hasAPPublic(to) ? VideoPlaylistPrivacy.PUBLIC : VideoPlaylistPrivacy.UNLISTED @@ -23,7 +23,11 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: strin } as AttributesOnly } -function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) { +export function playlistElementObjectToDBAttributes ( + elementObject: PlaylistElementObject, + videoPlaylist: MVideoPlaylistId, + video: MVideoId +) { return { position: elementObject.position, url: elementObject.id, @@ -33,8 +37,3 @@ function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObje videoId: video.id } as AttributesOnly } - -export { - playlistObjectToDBAttributes, - playlistElementObjectToDBAttributes -} diff --git a/server/core/lib/activitypub/process/process-view.ts b/server/core/lib/activitypub/process/process-view.ts index 630ce05d8..60820e249 100644 --- a/server/core/lib/activitypub/process/process-view.ts +++ b/server/core/lib/activitypub/process/process-view.ts @@ -32,8 +32,8 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu video, viewerId: activity.id, - viewerExpires: activity.expires - ? new Date(activity.expires) + viewerExpires: getExpires(activity) + ? new Date(getExpires(activity)) : undefined, viewerResultCounter: getViewerResultCounter(activity) }) @@ -49,10 +49,15 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu function getViewerResultCounter (activity: ActivityView) { const result = activity.result - if (!activity.expires || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined + if (!getExpires(activity) || result?.interactionType !== 'WatchAction' || result?.type !== 'InteractionCounter') return undefined const counter = parseInt(result.userInteractionCount + '') if (isNaN(counter)) return undefined return counter } + +// TODO: compat with < 6.1, remove in 7.0 +function getExpires (activity: ActivityView) { + return activity.expires || activity['expiration'] as string +} diff --git a/server/core/lib/activitypub/process/process.ts b/server/core/lib/activitypub/process/process.ts index abc1ebf6f..5e187cecb 100644 --- a/server/core/lib/activitypub/process/process.ts +++ b/server/core/lib/activitypub/process/process.ts @@ -34,7 +34,7 @@ const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions a.followersUrl) } } -function getVideoCommentAudience ( +export function getVideoCommentAudience ( videoComment: MCommentOwnerVideo, threadParentComments: MCommentOwner[], actorsInvolvedInVideo: MActorFollowersUrl[], isOrigin = false ): ActivityAudience { - const to = [ ACTIVITY_PUB.PUBLIC ] + const to = [ getAPPublicValue() ] const cc: string[] = [] // Owner of the video we comment @@ -43,14 +43,14 @@ function getVideoCommentAudience ( } } -function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience { +export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience { return { - to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), + to: [ getAPPublicValue() ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), cc: [] } } -async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) { +export async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) { const actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t) const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor @@ -63,12 +63,3 @@ async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) { return actors } - -// --------------------------------------------------------------------------- - -export { - getOriginVideoAudience, - getActorsInvolvedInVideo, - getAudienceFromFollowersOf, - getVideoCommentAudience -} diff --git a/server/core/lib/activitypub/send/shared/send-utils.ts b/server/core/lib/activitypub/send/shared/send-utils.ts index 29e22ef58..77c7d7595 100644 --- a/server/core/lib/activitypub/send/shared/send-utils.ts +++ b/server/core/lib/activitypub/send/shared/send-utils.ts @@ -258,7 +258,6 @@ function unicastTo (options: { export { broadcastToFollowers, unicastTo, - forwardActivity, broadcastToActors, sendVideoActivityToOrigin, forwardVideoRelatedActivity, diff --git a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts index f32fe9f55..ae9f8a651 100644 --- a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts +++ b/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts @@ -11,13 +11,14 @@ import { VideoPrivacy, VideoStreamingPlaylistType } from '@peertube/peertube-models' +import { hasAPPublic } from '@server/helpers/activity-pub-utils.js' import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js' import { isArray } from '@server/helpers/custom-validators/misc.js' import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js' import { generateImageFilename } from '@server/helpers/image-utils.js' import { logger } from '@server/helpers/logger.js' import { getExtFromMimetype } from '@server/helpers/video.js' -import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants.js' +import { MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants.js' import { generateTorrentFileName } from '@server/lib/paths.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoFileModel } from '@server/models/video/video-file.js' @@ -191,7 +192,7 @@ export function getStoryboardAttributeFromObject (video: MVideoId, videoObject: } export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) { - const privacy = to.includes(ACTIVITY_PUB.PUBLIC) + const privacy = hasAPPublic(to) ? VideoPrivacy.PUBLIC : VideoPrivacy.UNLISTED diff --git a/server/core/middlewares/activitypub.ts b/server/core/middlewares/activitypub.ts index e955f251d..1eb380733 100644 --- a/server/core/middlewares/activitypub.ts +++ b/server/core/middlewares/activitypub.ts @@ -1,14 +1,14 @@ -import { NextFunction, Request, Response } from 'express' +import { ActivityDelete, ActivityPubSignature, HttpStatusCode } from '@peertube/peertube-models' import { isActorDeleteActivityValid } from '@server/helpers/custom-validators/activitypub/actor.js' import { getAPId } from '@server/lib/activitypub/activity.js' import { wrapWithSpanAndContext } from '@server/lib/opentelemetry/tracing.js' -import { ActivityDelete, ActivityPubSignature, HttpStatusCode } from '@peertube/peertube-models' +import { NextFunction, Request, Response } from 'express' import { logger } from '../helpers/logger.js' import { isHTTPSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto.js' import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants.js' import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../lib/activitypub/actors/index.js' -async function checkSignature (req: Request, res: Response, next: NextFunction) { +export async function checkSignature (req: Request, res: Response, next: NextFunction) { try { const httpSignatureChecked = await checkHttpSignature(req, res) if (httpSignatureChecked !== true) return @@ -39,7 +39,7 @@ async function checkSignature (req: Request, res: Response, next: NextFunction) } } -function executeIfActivityPub (req: Request, res: Response, next: NextFunction) { +export function executeIfActivityPub (req: Request, res: Response, next: NextFunction) { const accepted = req.accepts(ACCEPT_HEADERS) if (accepted === false || ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS.includes(accepted) === false) { // Bypass this route @@ -52,13 +52,7 @@ function executeIfActivityPub (req: Request, res: Response, next: NextFunction) } // --------------------------------------------------------------------------- - -export { - checkSignature, - executeIfActivityPub, - checkHttpSignature -} - +// Private // --------------------------------------------------------------------------- async function checkHttpSignature (req: Request, res: Response) { @@ -123,7 +117,7 @@ async function checkHttpSignature (req: Request, res: Response) { async function checkJsonLDSignature (req: Request, res: Response) { // Lazy load the module as it's quite big with json.ld dependency - const { isJsonLDSignatureVerified } = await import('../helpers/peertube-jsonld.js') + const { compactJSONLDAndCheckSignature } = await import('../helpers/peertube-jsonld.js') return wrapWithSpanAndContext('peertube.activitypub.JSONLDSignature', async () => { const signatureObject: ActivityPubSignature = req.body.signature @@ -141,7 +135,7 @@ async function checkJsonLDSignature (req: Request, res: Response) { logger.debug('Checking JsonLD signature of actor %s...', creator) const actor = await getOrCreateAPActor(creator) - const verified = await isJsonLDSignatureVerified(actor, req.body) + const verified = await compactJSONLDAndCheckSignature(actor, req) if (verified !== true) { logger.warn('Signature not verified.', req.body)