Compact json-ld AP objects

This commit is contained in:
Chocobozzz 2024-04-25 11:21:55 +02:00
parent 712f7d18e6
commit b8635c2606
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
21 changed files with 300 additions and 170 deletions

View File

@ -3,7 +3,7 @@
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
import { signAndContextify } from '@peertube/peertube-server/core/helpers/activity-pub-utils.js' 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 { 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 { expect } from 'chai'
import { readJsonSync } from 'fs-extra/esm' import { readJsonSync } from 'fs-extra/esm'
import cloneDeep from 'lodash-es/cloneDeep.js' import cloneDeep from 'lodash-es/cloneDeep.js'
@ -24,6 +24,10 @@ function fakeFilter () {
return (data: any) => Promise.resolve(data) return (data: any) => Promise.resolve(data)
} }
function fakeExpressReq (body: any) {
return { body }
}
describe('Test activity pub helpers', function () { describe('Test activity pub helpers', function () {
describe('When checking the Linked Signature', 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 publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 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 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 publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 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 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 publicKey = readJsonSync(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 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 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 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 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 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 expect(result).to.be.true
}) })

View File

@ -1,9 +1,9 @@
import { ContextType } from '@peertube/peertube-models' import { ContextType } from '@peertube/peertube-models'
import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js' import { ACTIVITY_PUB, REMOTE_SCHEME } from '@server/initializers/constants.js'
import { isArray } from './custom-validators/misc.js'
import { buildDigest } from './peertube-crypto.js' import { buildDigest } from './peertube-crypto.js'
import type { signJsonLDObject } from './peertube-jsonld.js' import type { signJsonLDObject } from './peertube-jsonld.js'
import { doJSONRequest } from './requests.js' import { doJSONRequest } from './requests.js'
import { isArray } from './custom-validators/misc.js'
export type ContextFilter = <T> (arg: T) => Promise<T> export type ContextFilter = <T> (arg: T) => Promise<T>
@ -49,6 +49,18 @@ export async function getApplicationActorOfHost (host: string) {
return found?.href || undefined 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 // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -58,7 +70,6 @@ type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string
const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = { const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = {
Video: buildContext({ Video: buildContext({
Hashtag: 'as:Hashtag', Hashtag: 'as:Hashtag',
uuid: 'sc:identifier',
category: 'sc:category', category: 'sc:category',
licence: 'sc:license', licence: 'sc:license',
subtitleLanguage: 'sc:subtitleLanguage', subtitleLanguage: 'sc:subtitleLanguage',
@ -99,6 +110,11 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
'@id': 'pt:aspectRatio' '@id': 'pt:aspectRatio'
}, },
uuid: {
'@type': 'sc:identifier',
'@id': 'pt:uuid'
},
originallyPublishedAt: 'sc:datePublished', originallyPublishedAt: 'sc:datePublished',
uploadDate: 'sc:uploadDate', uploadDate: 'sc:uploadDate',
@ -170,12 +186,23 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
'@type': 'sc:Number', '@type': 'sc:Number',
'@id': 'pt:stopTimestamp' '@id': 'pt:stopTimestamp'
}, },
uuid: 'sc:identifier' uuid: {
'@type': 'sc:identifier',
'@id': 'pt:uuid'
}
}), }),
CacheFile: buildContext({ CacheFile: buildContext({
expires: 'sc:expires', 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({ Flag: buildContext({
@ -205,15 +232,21 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
'@type': 'sc:Number', '@type': 'sc:Number',
'@id': 'pt:startTimestamp' '@id': 'pt:startTimestamp'
}, },
stopTimestamp: { endTimestamp: {
'@type': 'sc:Number', '@type': 'sc:Number',
'@id': 'pt:stopTimestamp' '@id': 'pt:endTimestamp'
}, },
watchSection: { uuid: {
'@type': 'sc:Number', '@type': 'sc:identifier',
'@id': 'pt:stopTimestamp' '@id': 'pt:uuid'
}, },
uuid: 'sc:identifier' actionStatus: 'sc:actionStatus',
watchSections: {
'@type': '@id',
'@id': 'pt:watchSections'
},
addressRegion: 'sc:addressRegion',
addressCountry: 'sc:addressCountry'
}), }),
View: buildContext({ View: buildContext({
@ -233,13 +266,46 @@ const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string
Rate: buildContext(), Rate: buildContext(),
Chapters: buildContext({ Chapters: buildContext({
name: 'sc:name',
hasPart: 'sc:hasPart', hasPart: 'sc:hasPart',
endOffset: 'sc:endOffset', endOffset: 'sc:endOffset',
startOffset: 'sc:startOffset' startOffset: 'sc:startOffset'
}) })
} }
let allContext: (string | ContextValue)[]
export function getAllContext () {
if (allContext) return allContext
const processed = new Set<string>()
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) { async function getContextData (type: ContextType, contextFilter: ContextFilter) {
const contextData = contextFilter const contextData = contextFilter
? await contextFilter(contextStore[type]) ? await contextFilter(contextStore[type])

View File

@ -1,6 +1,6 @@
import jsonld from 'jsonld' import jsonld from 'jsonld'
const CACHE = { const STATIC_CACHE = {
'https://w3id.org/security/v1': { 'https://w3id.org/security/v1': {
'@context': { '@context': {
id: '@id', id: '@id',
@ -53,19 +53,29 @@ const CACHE = {
} }
} }
const localCache = new Map<string, any>()
const nodeDocumentLoader = (jsonld as any).documentLoaders.node(); const nodeDocumentLoader = (jsonld as any).documentLoaders.node();
/* eslint-disable no-import-assign */ /* eslint-disable no-import-assign */
(jsonld as any).documentLoader = (url) => { (jsonld as any).documentLoader = async (url: string) => {
if (url in CACHE) { if (url in STATIC_CACHE) {
return Promise.resolve({ return {
contextUrl: null, contextUrl: null,
document: CACHE[url], document: STATIC_CACHE[url],
documentUrl: 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 } export { jsonld }

View File

@ -1,20 +1,15 @@
import { CacheFileObject } from '@peertube/peertube-models' 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 { isActivityPubUrlValid } from './misc.js'
import { isRemoteVideoUrlValid } from './videos.js'
function isCacheFileObjectValid (object: CacheFileObject) { export function isCacheFileObjectValid (object: CacheFileObject) {
return exists(object) && if (!object || object.type !== 'CacheFile') return false
object.type === 'CacheFile' &&
(object.expires === null || isDateValid(object.expires)) && return (!object.expires || isDateValid(object.expires)) &&
isActivityPubUrlValid(object.object) && isActivityPubUrlValid(object.object) &&
(isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url)) (isRedundancyUrlVideoValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
}
// ---------------------------------------------------------------------------
export {
isCacheFileObjectValid
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -24,3 +19,15 @@ function isPlaylistRedundancyUrlValid (url: any) {
(url.mediaType || url.mimeType) === 'application/x-mpegURL' && (url.mediaType || url.mimeType) === 'application/x-mpegURL' &&
isActivityPubUrlValid(url.href) 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 }))
}

View File

@ -1,29 +1,25 @@
import validator from 'validator'
import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models' import { PlaylistElementObject, PlaylistObject } from '@peertube/peertube-models'
import validator from 'validator'
import { exists, isDateValid, isUUIDValid } from '../misc.js' import { exists, isDateValid, isUUIDValid } from '../misc.js'
import { isVideoPlaylistNameValid } from '../video-playlists.js' import { isVideoPlaylistNameValid } from '../video-playlists.js'
import { isActivityPubUrlValid } from './misc.js' import { isActivityPubUrlValid } from './misc.js'
function isPlaylistObjectValid (object: PlaylistObject) { export function isPlaylistObjectValid (object: PlaylistObject) {
return exists(object) && if (!object || object.type !== 'Playlist') return false
object.type === 'Playlist' &&
validator.default.isInt(object.totalItems + '') && // 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) && isVideoPlaylistNameValid(object.name) &&
isUUIDValid(object.uuid) && isUUIDValid(object.uuid) &&
isDateValid(object.published) && isDateValid(object.published) &&
isDateValid(object.updated) isDateValid(object.updated)
} }
function isPlaylistElementObjectValid (object: PlaylistElementObject) { export function isPlaylistElementObjectValid (object: PlaylistElementObject) {
return exists(object) && return exists(object) &&
object.type === 'PlaylistElement' && object.type === 'PlaylistElement' &&
validator.default.isInt(object.position + '') && validator.default.isInt(object.position + '') &&
isActivityPubUrlValid(object.url) isActivityPubUrlValid(object.url)
} }
// ---------------------------------------------------------------------------
export {
isPlaylistObjectValid,
isPlaylistElementObjectValid
}

View File

@ -1,5 +1,5 @@
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
import validator from 'validator' import validator from 'validator'
import { ACTIVITY_PUB } from '../../../initializers/constants.js'
import { exists, isArray, isDateValid } from '../misc.js' import { exists, isArray, isDateValid } from '../misc.js'
import { isActivityPubUrlValid } from './misc.js' import { isActivityPubUrlValid } from './misc.js'
@ -23,10 +23,7 @@ function sanitizeAndCheckVideoCommentObject (comment: any) {
isDateValid(comment.published) && isDateValid(comment.published) &&
isActivityPubUrlValid(comment.url) && isActivityPubUrlValid(comment.url) &&
isArray(comment.to) && isArray(comment.to) &&
( (hasAPPublic(comment.to) || hasAPPublic(comment.cc)) // Only accept public comments
comment.to.indexOf(ACTIVITY_PUB.PUBLIC) !== -1 ||
comment.cc.indexOf(ACTIVITY_PUB.PUBLIC) !== -1
) // Only accept public comments
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -27,7 +27,7 @@ function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
sanitizeAndCheckVideoTorrentObject(activity.object) sanitizeAndCheckVideoTorrentObject(activity.object)
} }
function sanitizeAndCheckVideoTorrentObject (video: any) { function sanitizeAndCheckVideoTorrentObject (video: VideoObject) {
if (!video || video.type !== 'Video') return false if (!video || video.type !== 'Video') return false
if (!setValidRemoteTags(video)) { if (!setValidRemoteTags(video)) {
@ -59,6 +59,9 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
return false return false
} }
// TODO: compat with < 6.1, remove in 7.0
if (!video.uuid && video['identifier']) video.uuid = video['identifier']
// Default attributes // Default attributes
if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED if (!isVideoStateValid(video.state)) video.state = VideoState.PUBLISHED
if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false

View File

@ -1,19 +1,26 @@
import { arrayify } from '@peertube/peertube-core-utils'
import { WatchActionObject } from '@peertube/peertube-models' 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 { isVideoTimeValid } from '../video-view.js'
import { isActivityPubVideoDurationValid, isObjectValid } from './misc.js' import { isActivityPubVideoDurationValid, isObjectValid } from './misc.js'
function isWatchActionObjectValid (action: WatchActionObject) { function isWatchActionObjectValid (action: WatchActionObject) {
return exists(action) && if (!action || action.type !== 'WatchAction') return false
action.type === 'WatchAction' &&
isObjectValid(action.id) && // 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) && isActivityPubVideoDurationValid(action.duration) &&
isDateValid(action.startTime) && isDateValid(action.startTime) &&
isDateValid(action.endTime) && isDateValid(action.endTime) &&
isLocationValid(action.location) && isLocationValid(action.location) &&
isUUIDValid(action.uuid) && isUUIDValid(action.uuid) &&
isObjectValid(action.object) && isObjectValid(action.object) &&
isWatchSectionsValid(action.watchSections) areWatchSectionsValid(action.watchSections)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -34,8 +41,11 @@ function isLocationValid (location: any) {
return true return true
} }
function isWatchSectionsValid (sections: WatchActionObject['watchSections']) { function areWatchSectionsValid (sections: WatchActionObject['watchSections']) {
return Array.isArray(sections) && sections.every(s => { 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) return isVideoTimeValid(s.startTimestamp) && isVideoTimeValid(s.endTimestamp)
}) })
} }

View File

@ -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) return exists(value) && validator.default.isInt(value + '', VIDEOS_CONSTRAINTS_FIELDS.VIEWS)
} }

View File

@ -1,26 +1,51 @@
import { omit } from '@peertube/peertube-core-utils'
import { sha256 } from '@peertube/peertube-node-utils' import { sha256 } from '@peertube/peertube-node-utils'
import { createSign, createVerify } from 'crypto' import { createSign, createVerify } from 'crypto'
import cloneDeep from 'lodash-es/cloneDeep.js' import cloneDeep from 'lodash-es/cloneDeep.js'
import { MActor } from '../types/models/index.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 { logger } from './logger.js'
import { assertIsInWorkerThread } from './threads.js' import { assertIsInWorkerThread } from './threads.js'
import { jsonld } from './custom-jsonld-signature.js'
export function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> { type ExpressRequest = { body: any }
if (signedDocument.signature.type === 'RsaSignature2017') {
return isJsonLDRSA2017Verified(fromActor, signedDocument) export function compactJSONLDAndCheckSignature (fromActor: MActor, req: ExpressRequest): Promise<boolean> {
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) return Promise.resolve(false)
} }
// Backward compatibility with "other" implementations // 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([ const [ documentHash, optionsHash ] = await Promise.all([
createDocWithoutSignatureHash(signedDocument), hashObject(compacted, safe),
createSignatureHash(signedDocument.signature) createSignatureHash(req.body.signature, safe)
]) ])
const toVerify = optionsHash + documentHash const toVerify = optionsHash + documentHash
@ -28,7 +53,39 @@ export async function isJsonLDRSA2017Verified (fromActor: MActor, signedDocument
const verify = createVerify('RSA-SHA256') const verify = createVerify('RSA-SHA256')
verify.update(toVerify, 'utf8') 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 <T> (options: { export async function signJsonLDObject <T> (options: {
@ -66,35 +123,40 @@ export async function signJsonLDObject <T> (options: {
// Private // Private
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function hashObject (obj: any): Promise<any> { async function hashObject (obj: any, safe: boolean): Promise<any> {
const res = await (jsonld as any).promises.normalize(obj, { const res = await jsonldNormalize(obj, safe)
safe: false,
algorithm: 'URDNA2015',
format: 'application/n-quads'
})
return sha256(res) return sha256(res)
} }
function createSignatureHash (signature: any) { function jsonldCompact (obj: any) {
const signatureCopy = cloneDeep(signature) return (jsonld as any).promises.compact(obj, getAllContext())
Object.assign(signatureCopy, { }
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': [ '@context': [
'https://w3id.org/security/v1', 'https://w3id.org/security/v1',
{ RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' } { RsaSignature2017: 'https://w3id.org/security#RsaSignature2017' }
] ],
})
delete signatureCopy.type ...omit(signature, [ 'type', 'id', 'signatureValue' ])
delete signatureCopy.id }, safe)
delete signatureCopy.signatureValue
return hashObject(signatureCopy)
} }
function createDocWithoutSignatureHash (doc: any) { function createDocWithoutSignatureHash (doc: any) {
const docWithoutSignature = cloneDeep(doc) const docWithoutSignature = cloneDeep(doc)
delete docWithoutSignature.signature delete docWithoutSignature.signature
return hashObject(docWithoutSignature) return hashObject(docWithoutSignature, true)
} }

View File

@ -774,7 +774,6 @@ const ACTIVITY_PUB = {
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
], ],
ACCEPT_HEADER: 'application/activity+json, application/ld+json', ACCEPT_HEADER: 'application/activity+json, application/ld+json',
PUBLIC: 'https://www.w3.org/ns/activitystreams#Public',
COLLECTION_ITEMS_PER_PAGE: 10, COLLECTION_ITEMS_PER_PAGE: 10,
FETCH_PAGE_LIMIT: 2000, FETCH_PAGE_LIMIT: 2000,
MAX_RECURSION_COMMENTS: 100, MAX_RECURSION_COMMENTS: 100,

View File

@ -1,17 +1,17 @@
import { ActivityAudience } from '@peertube/peertube-models' 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' 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) return buildAudience([ actorSender.followersUrl ], isPublic)
} }
function buildAudience (followerUrls: string[], isPublic = true) { export function buildAudience (followerUrls: string[], isPublic = true) {
let to: string[] = [] let to: string[] = []
let cc: string[] = [] let cc: string[] = []
if (isPublic) { if (isPublic) {
to = [ ACTIVITY_PUB.PUBLIC ] to = [ getAPPublicValue() ]
cc = followerUrls cc = followerUrls
} else { // Unlisted } else { // Unlisted
to = [] to = []
@ -21,14 +21,6 @@ function buildAudience (followerUrls: string[], isPublic = true) {
return { to, cc } return { to, cc }
} }
function audiencify<T> (object: T, audience: ActivityAudience) { export function audiencify<T> (object: T, audience: ActivityAudience) {
return { ...audience, ...object } return { ...audience, ...object }
} }
// ---------------------------------------------------------------------------
export {
buildAudience,
getAudience,
audiencify
}

View File

@ -2,6 +2,7 @@ import { Transaction } from 'sequelize'
import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js' import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models/index.js'
import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models' import { CacheFileObject, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy.js' 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) { async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t) const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
@ -65,11 +66,15 @@ function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject
} }
const url = cacheFileObject.url 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 => { 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 { return {
expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null, expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,

View File

@ -6,7 +6,7 @@ import { Activity } from '@peertube/peertube-models'
import { StatsManager } from '../stat-manager.js' import { StatsManager } from '../stat-manager.js'
import { processActivities } from './process/index.js' import { processActivities } from './process/index.js'
class InboxManager { export class InboxManager {
private static instance: InboxManager private static instance: InboxManager
private readonly inboxQueue: PQueue private readonly inboxQueue: PQueue
@ -39,9 +39,3 @@ class InboxManager {
return this.instance || (this.instance = new this()) return this.instance || (this.instance = new this())
} }
} }
// ---------------------------------------------------------------------------
export {
InboxManager
}

View File

@ -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 { 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[]) { export function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
const privacy = to.includes(ACTIVITY_PUB.PUBLIC) const privacy = hasAPPublic(to)
? VideoPlaylistPrivacy.PUBLIC ? VideoPlaylistPrivacy.PUBLIC
: VideoPlaylistPrivacy.UNLISTED : VideoPlaylistPrivacy.UNLISTED
@ -23,7 +23,11 @@ function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: strin
} as AttributesOnly<VideoPlaylistModel> } as AttributesOnly<VideoPlaylistModel>
} }
function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) { export function playlistElementObjectToDBAttributes (
elementObject: PlaylistElementObject,
videoPlaylist: MVideoPlaylistId,
video: MVideoId
) {
return { return {
position: elementObject.position, position: elementObject.position,
url: elementObject.id, url: elementObject.id,
@ -33,8 +37,3 @@ function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObje
videoId: video.id videoId: video.id
} as AttributesOnly<VideoPlaylistElementModel> } as AttributesOnly<VideoPlaylistElementModel>
} }
export {
playlistObjectToDBAttributes,
playlistElementObjectToDBAttributes
}

View File

@ -32,8 +32,8 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
video, video,
viewerId: activity.id, viewerId: activity.id,
viewerExpires: activity.expires viewerExpires: getExpires(activity)
? new Date(activity.expires) ? new Date(getExpires(activity))
: undefined, : undefined,
viewerResultCounter: getViewerResultCounter(activity) viewerResultCounter: getViewerResultCounter(activity)
}) })
@ -49,10 +49,15 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
function getViewerResultCounter (activity: ActivityView) { function getViewerResultCounter (activity: ActivityView) {
const result = activity.result 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 + '') const counter = parseInt(result.userInteractionCount + '')
if (isNaN(counter)) return undefined if (isNaN(counter)) return undefined
return counter return counter
} }
// TODO: compat with < 6.1, remove in 7.0
function getExpires (activity: ActivityView) {
return activity.expires || activity['expiration'] as string
}

View File

@ -34,7 +34,7 @@ const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Act
View: processViewActivity View: processViewActivity
} }
async function processActivities ( export async function processActivities (
activities: Activity[], activities: Activity[],
options: { options: {
signatureActor?: MActorSignature signatureActor?: MActorSignature
@ -86,7 +86,3 @@ async function processActivities (
} }
} }
} }
export {
processActivities
}

View File

@ -1,25 +1,25 @@
import { Transaction } from 'sequelize'
import { ACTIVITY_PUB } from '@server/initializers/constants.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { VideoModel } from '@server/models/video/video.js'
import { VideoShareModel } from '@server/models/video/video-share.js'
import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models/index.js'
import { ActivityAudience } from '@peertube/peertube-models' import { ActivityAudience } from '@peertube/peertube-models'
import { getAPPublicValue } from '@server/helpers/activity-pub-utils.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { VideoShareModel } from '@server/models/video/video-share.js'
import { VideoModel } from '@server/models/video/video.js'
import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models/index.js'
import { Transaction } from 'sequelize'
function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience { export function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
return { return {
to: [ accountActor.url ], to: [ accountActor.url ],
cc: actorsInvolvedInVideo.map(a => a.followersUrl) cc: actorsInvolvedInVideo.map(a => a.followersUrl)
} }
} }
function getVideoCommentAudience ( export function getVideoCommentAudience (
videoComment: MCommentOwnerVideo, videoComment: MCommentOwnerVideo,
threadParentComments: MCommentOwner[], threadParentComments: MCommentOwner[],
actorsInvolvedInVideo: MActorFollowersUrl[], actorsInvolvedInVideo: MActorFollowersUrl[],
isOrigin = false isOrigin = false
): ActivityAudience { ): ActivityAudience {
const to = [ ACTIVITY_PUB.PUBLIC ] const to = [ getAPPublicValue() ]
const cc: string[] = [] const cc: string[] = []
// Owner of the video we comment // Owner of the video we comment
@ -43,14 +43,14 @@ function getVideoCommentAudience (
} }
} }
function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience { export function getAudienceFromFollowersOf (actorsInvolvedInObject: MActorFollowersUrl[]): ActivityAudience {
return { return {
to: [ ACTIVITY_PUB.PUBLIC ].concat(actorsInvolvedInObject.map(a => a.followersUrl)), to: [ getAPPublicValue() ].concat(actorsInvolvedInObject.map(a => a.followersUrl)),
cc: [] 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 actors = await VideoShareModel.listActorIdsAndFollowerUrlsByShare(video.id, t)
const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor const alreadyLoadedActor = (video as VideoModel).VideoChannel?.Account?.Actor
@ -63,12 +63,3 @@ async function getActorsInvolvedInVideo (video: MVideoId, t: Transaction) {
return actors return actors
} }
// ---------------------------------------------------------------------------
export {
getOriginVideoAudience,
getActorsInvolvedInVideo,
getAudienceFromFollowersOf,
getVideoCommentAudience
}

View File

@ -258,7 +258,6 @@ function unicastTo (options: {
export { export {
broadcastToFollowers, broadcastToFollowers,
unicastTo, unicastTo,
forwardActivity,
broadcastToActors, broadcastToActors,
sendVideoActivityToOrigin, sendVideoActivityToOrigin,
forwardVideoRelatedActivity, forwardVideoRelatedActivity,

View File

@ -11,13 +11,14 @@ import {
VideoPrivacy, VideoPrivacy,
VideoStreamingPlaylistType VideoStreamingPlaylistType
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { hasAPPublic } from '@server/helpers/activity-pub-utils.js'
import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js' import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos.js'
import { isArray } from '@server/helpers/custom-validators/misc.js' import { isArray } from '@server/helpers/custom-validators/misc.js'
import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js' import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos.js'
import { generateImageFilename } from '@server/helpers/image-utils.js' import { generateImageFilename } from '@server/helpers/image-utils.js'
import { logger } from '@server/helpers/logger.js' import { logger } from '@server/helpers/logger.js'
import { getExtFromMimetype } from '@server/helpers/video.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 { generateTorrentFileName } from '@server/lib/paths.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js' import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoFileModel } from '@server/models/video/video-file.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[] = []) { export function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
const privacy = to.includes(ACTIVITY_PUB.PUBLIC) const privacy = hasAPPublic(to)
? VideoPrivacy.PUBLIC ? VideoPrivacy.PUBLIC
: VideoPrivacy.UNLISTED : VideoPrivacy.UNLISTED

View File

@ -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 { isActorDeleteActivityValid } from '@server/helpers/custom-validators/activitypub/actor.js'
import { getAPId } from '@server/lib/activitypub/activity.js' import { getAPId } from '@server/lib/activitypub/activity.js'
import { wrapWithSpanAndContext } from '@server/lib/opentelemetry/tracing.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 { logger } from '../helpers/logger.js'
import { isHTTPSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto.js' import { isHTTPSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto.js'
import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants.js' import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants.js'
import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../lib/activitypub/actors/index.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 { try {
const httpSignatureChecked = await checkHttpSignature(req, res) const httpSignatureChecked = await checkHttpSignature(req, res)
if (httpSignatureChecked !== true) return 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) const accepted = req.accepts(ACCEPT_HEADERS)
if (accepted === false || ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS.includes(accepted) === false) { if (accepted === false || ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS.includes(accepted) === false) {
// Bypass this route // Bypass this route
@ -52,13 +52,7 @@ function executeIfActivityPub (req: Request, res: Response, next: NextFunction)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private
export {
checkSignature,
executeIfActivityPub,
checkHttpSignature
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function checkHttpSignature (req: Request, res: Response) { 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) { async function checkJsonLDSignature (req: Request, res: Response) {
// Lazy load the module as it's quite big with json.ld dependency // 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 () => { return wrapWithSpanAndContext('peertube.activitypub.JSONLDSignature', async () => {
const signatureObject: ActivityPubSignature = req.body.signature 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) logger.debug('Checking JsonLD signature of actor %s...', creator)
const actor = await getOrCreateAPActor(creator) const actor = await getOrCreateAPActor(creator)
const verified = await isJsonLDSignatureVerified(actor, req.body) const verified = await compactJSONLDAndCheckSignature(actor, req)
if (verified !== true) { if (verified !== true) {
logger.warn('Signature not verified.', req.body) logger.warn('Signature not verified.', req.body)