Browse Source

Compact json-ld AP objects

Chocobozzz 1 week ago
parent
commit
b8635c2606

+ 10 - 6
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
     })

+ 77 - 11
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 = <T> (arg: T) => Promise<T>
 
@@ -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<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) {
   const contextData = contextFilter
     ? await contextFilter(contextStore[type])

+ 17 - 7
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<string, any>()
+
 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
-    })
+    }
+  }
+
+  if (localCache.has(url)) return localCache.get(url)
+
+  const remoteDoc = await nodeDocumentLoader(url)
+
+  if (localCache.size < 100) {
+    localCache.set(url, remoteDoc)
   }
 
-  return nodeDocumentLoader(url)
+  return remoteDoc
 }
 
 export { jsonld }

+ 20 - 13
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)) &&
-    isActivityPubUrlValid(object.object) &&
-    (isRemoteVideoUrlValid(object.url) || isPlaylistRedundancyUrlValid(object.url))
-}
-
-// ---------------------------------------------------------------------------
+export function isCacheFileObjectValid (object: CacheFileObject) {
+  if (!object || object.type !== 'CacheFile') return false
 
-export {
-  isCacheFileObjectValid
+  return (!object.expires || isDateValid(object.expires)) &&
+    isActivityPubUrlValid(object.object) &&
+    (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 }))
+}

+ 9 - 13
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
-}

+ 2 - 5
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
 }
 
 // ---------------------------------------------------------------------------

+ 4 - 1
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

+ 16 - 6
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)
   })
 }

+ 1 - 1
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)
 }
 

+ 87 - 25
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<boolean> {
-  if (signedDocument.signature.type === 'RsaSignature2017') {
-    return isJsonLDRSA2017Verified(fromActor, signedDocument)
+type ExpressRequest = { body: any }
+
+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)
 }
 
 // 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 <T> (options: {
@@ -66,35 +123,40 @@ export async function signJsonLDObject <T> (options: {
 // Private
 // ---------------------------------------------------------------------------
 
-async function hashObject (obj: any): Promise<any> {
-  const res = await (jsonld as any).promises.normalize(obj, {
-    safe: false,
+async function hashObject (obj: any, safe: boolean): Promise<any> {
+  const res = await jsonldNormalize(obj, safe)
+
+  return sha256(res)
+}
+
+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'
   })
-
-  return sha256(res)
 }
 
-function createSignatureHash (signature: any) {
-  const signatureCopy = cloneDeep(signature)
-  Object.assign(signatureCopy, {
+// ---------------------------------------------------------------------------
+
+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)
 }

+ 0 - 1
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,

+ 5 - 13
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<T> (object: T, audience: ActivityAudience) {
+export function audiencify<T> (object: T, audience: ActivityAudience) {
   return { ...audience, ...object }
 }
-
-// ---------------------------------------------------------------------------
-
-export {
-  buildAudience,
-  getAudience,
-  audiencify
-}

+ 7 - 2
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,

+ 1 - 7
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
-}

+ 11 - 12
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 { 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'
-import { AttributesOnly } from '@peertube/peertube-typescript-utils'
-import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@peertube/peertube-models'
 
-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<VideoPlaylistModel>
 }
 
-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<VideoPlaylistElementModel>
 }
-
-export {
-  playlistObjectToDBAttributes,
-  playlistElementObjectToDBAttributes
-}

+ 8 - 3
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
+}

+ 1 - 5
server/core/lib/activitypub/process/process.ts

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

+ 10 - 19
server/core/lib/activitypub/send/shared/audience-utils.ts

@@ -1,25 +1,25 @@
-import { Transaction } from 'sequelize'
-import { ACTIVITY_PUB } from '@server/initializers/constants.js'
+import { ActivityAudience } from '@peertube/peertube-models'
+import { getAPPublicValue } from '@server/helpers/activity-pub-utils.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 { VideoModel } from '@server/models/video/video.js'
 import { MActorFollowersUrl, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '@server/types/models/index.js'
-import { ActivityAudience } from '@peertube/peertube-models'
+import { Transaction } from 'sequelize'
 
-function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
+export function getOriginVideoAudience (accountActor: MActorUrl, actorsInvolvedInVideo: MActorFollowersUrl[] = []): ActivityAudience {
   return {
     to: [ accountActor.url ],
     cc: actorsInvolvedInVideo.map(a => 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
-}

+ 0 - 1
server/core/lib/activitypub/send/shared/send-utils.ts

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

+ 3 - 2
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
 

+ 7 - 13
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)