this.user.save()
this.setStatus(AuthStatus.LoggedIn)
+ this.userInformationLoaded.next(true)
}
private handleRefreshToken (obj: UserRefreshToken) {
const page = req.params.page || 1
const { start, count } = pageToStartAndCount(page, ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE)
- const result = await db.AccountFollow.listAcceptedFollowerUrlsForApi(account.id, start, count)
+ const result = await db.AccountFollow.listAcceptedFollowerUrlsForApi([ account.id ], start, count)
const activityPubResult = activityPubCollectionPagination(req.url, page, result)
return res.json(activityPubResult)
const page = req.params.page || 1
const { start, count } = pageToStartAndCount(page, ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE)
- const result = await db.AccountFollow.listAcceptedFollowingUrlsForApi(account.id, start, count)
+ const result = await db.AccountFollow.listAcceptedFollowingUrlsForApi([ account.id ], start, count)
const activityPubResult = activityPubCollectionPagination(req.url, page, result)
return res.json(activityPubResult)
import { UserRight } from '../../../../shared/models/users/user-right.enum'
import { getFormattedObjects } from '../../../helpers'
import { logger } from '../../../helpers/logger'
-import { getApplicationAccount } from '../../../helpers/utils'
+import { getServerAccount } from '../../../helpers/utils'
import { getAccountFromWebfinger } from '../../../helpers/webfinger'
import { SERVER_ACCOUNT_NAME } from '../../../initializers/constants'
import { database as db } from '../../../initializers/database'
// ---------------------------------------------------------------------------
async function listFollowing (req: express.Request, res: express.Response, next: express.NextFunction) {
- const applicationAccount = await getApplicationAccount()
+ const applicationAccount = await getServerAccount()
const resultList = await db.AccountFollow.listFollowingForApi(applicationAccount.id, req.query.start, req.query.count, req.query.sort)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listFollowers (req: express.Request, res: express.Response, next: express.NextFunction) {
- const applicationAccount = await getApplicationAccount()
+ const applicationAccount = await getServerAccount()
const resultList = await db.AccountFollow.listFollowersForApi(applicationAccount.id, req.query.start, req.query.count, req.query.sort)
return res.json(getFormattedObjects(resultList.data, resultList.total))
async function follow (req: express.Request, res: express.Response, next: express.NextFunction) {
const hosts = req.body.hosts as string[]
- const fromAccount = await getApplicationAccount()
+ const fromAccount = await getServerAccount()
const tasks: Promise<any>[] = []
const accountName = SERVER_ACCOUNT_NAME
resetSequelizeInstance,
retryTransactionWrapper
} from '../../../helpers'
-import { getActivityPubUrl } from '../../../helpers/activitypub'
+import { getActivityPubUrl, shareVideoByServer } from '../../../helpers/activitypub'
import { CONFIG, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_MIMETYPE_EXT, VIDEO_PRIVACIES } from '../../../initializers'
import { database as db } from '../../../initializers/database'
-import { sendAddVideo, sendUpdateVideoChannel } from '../../../lib/activitypub/send-request'
+import { sendAddVideo, sendUpdateVideo } from '../../../lib/activitypub/send-request'
import { transcodingJobScheduler } from '../../../lib/jobs/transcoding-job-scheduler/transcoding-job-scheduler'
import {
asyncMiddleware,
randomString = 'fake-random-string'
}
- cb(null, randomString + '.' + extension)
+ cb(null, randomString + extension)
}
})
if (video.privacy === VideoPrivacy.PRIVATE) return undefined
await sendAddVideo(video, t)
+ await shareVideoByServer(video, t)
})
logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoUUID)
}
async function updateVideo (req: express.Request, res: express.Response) {
- const videoInstance = res.locals.video
+ const videoInstance: VideoInstance = res.locals.video
const videoFieldsSave = videoInstance.toJSON()
const videoInfoToUpdate: VideoUpdate = req.body
const wasPrivateVideo = videoInstance.privacy === VideoPrivacy.PRIVATE
// Now we'll update the video's meta data to our friends
if (wasPrivateVideo === false) {
- await sendUpdateVideoChannel(videoInstance, t)
+ await sendUpdateVideo(videoInstance, t)
}
// Video is not private anymore, send a create action to remote servers
import { join } from 'path'
import * as request from 'request'
+import * as Sequelize from 'sequelize'
import * as url from 'url'
import { ActivityIconObject } from '../../shared/index'
import { ActivityPubActor } from '../../shared/models/activitypub/activitypub-actor'
import { ResultList } from '../../shared/models/result-list.model'
import { database as db, REMOTE_SCHEME } from '../initializers'
import { ACTIVITY_PUB_ACCEPT_HEADER, CONFIG, STATIC_PATHS } from '../initializers/constants'
+import { sendAnnounce } from '../lib/activitypub/send-request'
+import { VideoChannelInstance } from '../models/video/video-channel-interface'
import { VideoInstance } from '../models/video/video-interface'
import { isRemoteAccountValid } from './custom-validators'
import { logger } from './logger'
import { doRequest, doRequestAndSaveToFile } from './requests'
+import { getServerAccount } from './utils'
function generateThumbnailFromUrl (video: VideoInstance, icon: ActivityIconObject) {
const thumbnailName = video.getThumbnailName()
return doRequestAndSaveToFile(options, thumbnailPath)
}
+async function shareVideoChannelByServer (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
+ const serverAccount = await getServerAccount()
+
+ await db.VideoChannelShare.create({
+ accountId: serverAccount.id,
+ videoChannelId: videoChannel.id
+ }, { transaction: t })
+
+ return sendAnnounce(serverAccount, videoChannel, t)
+}
+
+async function shareVideoByServer (video: VideoInstance, t: Sequelize.Transaction) {
+ const serverAccount = await getServerAccount()
+
+ await db.VideoShare.create({
+ accountId: serverAccount.id,
+ videoId: video.id
+ }, { transaction: t })
+
+ return sendAnnounce(serverAccount, video, t)
+}
+
function getActivityPubUrl (type: 'video' | 'videoChannel' | 'account' | 'videoAbuse', id: string) {
if (type === 'video') return CONFIG.WEBSERVER.URL + '/videos/watch/' + id
else if (type === 'videoChannel') return CONFIG.WEBSERVER.URL + '/video-channels/' + id
generateThumbnailFromUrl,
getOrCreateAccount,
fetchRemoteVideoPreview,
- fetchRemoteVideoDescription
+ fetchRemoteVideoDescription,
+ shareVideoChannelByServer,
+ shareVideoByServer
}
// ---------------------------------------------------------------------------
import * as createTorrent from 'create-torrent'
import * as rimraf from 'rimraf'
import * as pem from 'pem'
-import * as jsonld from 'jsonld'
-import * as jsig from 'jsonld-signatures'
-jsig.use('jsonld', jsonld)
function isTestInstance () {
return process.env.NODE_ENV === 'test'
const createTorrentPromise = promisify2<string, any, any>(createTorrent)
const rimrafPromise = promisify1WithVoid<string>(rimraf)
const statPromise = promisify1<string, Stats>(stat)
-const jsonldSignPromise = promisify2<object, { privateKeyPem: string, creator: string }, object>(jsig.sign)
-const jsonldVerifyPromise = promisify2<object, object, object>(jsig.verify)
// ---------------------------------------------------------------------------
bcryptHashPromise,
createTorrentPromise,
rimrafPromise,
- statPromise,
- jsonldSignPromise,
- jsonldVerifyPromise
+ statPromise
}
typeof value === 'string' &&
value.startsWith('PT') &&
value.endsWith('S') &&
- isVideoDurationValid(value.replace(/[^0-9]+/, ''))
+ isVideoDurationValid(value.replace(/[^0-9]+/g, ''))
}
function isVideoTorrentObjectValid (video: any) {
isRemoteIdentifierValid(video.category) &&
isRemoteIdentifierValid(video.licence) &&
isRemoteIdentifierValid(video.language) &&
- isVideoViewsValid(video.video) &&
+ isVideoViewsValid(video.views) &&
isVideoNSFWValid(video.nsfw) &&
isDateValid(video.published) &&
isDateValid(video.updated) &&
isRemoteVideoContentValid(video.mediaType, video.content) &&
isRemoteVideoIconValid(video.icon) &&
- setValidRemoteVideoUrls(video.url)
+ setValidRemoteVideoUrls(video) &&
+ video.url.length !== 0
}
function isVideoFlagValid (activity: any) {
return icon.type === 'Image' &&
isVideoUrlValid(icon.url) &&
icon.mediaType === 'image/jpeg' &&
- validator.isInt(icon.width, { min: 0 }) &&
- validator.isInt(icon.height, { min: 0 })
+ validator.isInt(icon.width + '', { min: 0 }) &&
+ validator.isInt(icon.height + '', { min: 0 })
}
function setValidRemoteVideoUrls (video: any) {
return url.type === 'Link' &&
ACTIVITY_PUB.VIDEO_URL_MIME_TYPES.indexOf(url.mimeType) !== -1 &&
isVideoUrlValid(url.url) &&
- validator.isInt(url.width, { min: 0 }) &&
- validator.isInt(url.size, { min: 0 })
+ validator.isInt(url.width + '', { min: 0 }) &&
+ validator.isInt(url.size + '', { min: 0 })
}
+import * as jsonld from 'jsonld'
import * as jsig from 'jsonld-signatures'
+jsig.use('jsonld', jsonld)
import {
PRIVATE_RSA_KEY_SIZE,
bcryptGenSaltPromise,
bcryptHashPromise,
createPrivateKey,
- getPublicKey,
- jsonldSignPromise,
- jsonldVerifyPromise
+ getPublicKey
} from './core-utils'
import { logger } from './logger'
import { AccountInstance } from '../models/account/account-interface'
publicKeyOwner: publicKeyOwnerObject
}
- return jsonldVerifyPromise(signedDocument, options)
+ return jsig.promises.verify(signedDocument, options)
.catch(err => {
logger.error('Cannot check signature.', err)
return false
creator: byAccount.url
}
- return jsonldSignPromise(data, options)
+ return jsig.promises.sign(data, options)
}
function comparePassword (plainPassword: string, hashPassword: string) {
import { ResultList } from '../../shared'
import { VideoResolution } from '../../shared/models/videos/video-resolution.enum'
import { AccountInstance } from '../models/account/account-interface'
+import { logger } from './logger'
function badRequest (req: express.Request, res: express.Response, next: express.NextFunction) {
return res.type('json').status(400).end()
})
}
-let applicationAccount: AccountInstance
-async function getApplicationAccount () {
- if (applicationAccount === undefined) {
- applicationAccount = await db.Account.loadApplication()
+let serverAccount: AccountInstance
+async function getServerAccount () {
+ if (serverAccount === undefined) {
+ serverAccount = await db.Account.loadApplication()
}
- return Promise.resolve(applicationAccount)
+ if (!serverAccount) {
+ logger.error('Cannot load server account.')
+ process.exit(0)
+ }
+
+ return Promise.resolve(serverAccount)
}
type SortType = { sortModel: any, sortValue: string }
isSignupAllowed,
computeResolutionsToTranscode,
resetSequelizeInstance,
- getApplicationAccount,
+ getServerAccount,
SortType
}
}
const VIDEO_MIMETYPE_EXT = {
- 'video/webm': 'webm',
- 'video/ogg': 'ogv',
- 'video/mp4': 'mp4'
+ 'video/webm': '.webm',
+ 'video/ogg': '.ogv',
+ 'video/mp4': '.mp4'
}
// ---------------------------------------------------------------------------
await db.sequelize.sync()
await removeCacheDirectories()
await createDirectoriesIfNotExist()
+ await createApplicationIfNotExist()
await createOAuthClientIfNotExist()
await createOAuthAdminIfNotExist()
- await createApplicationIfNotExist()
} catch (err) {
logger.error('Cannot install application.', err)
throw err
description: videoObject.content,
channelId: videoChannel.id,
duration: parseInt(duration, 10),
- createdAt: videoObject.published,
+ createdAt: new Date(videoObject.published),
// FIXME: updatedAt does not seems to be considered by Sequelize
- updatedAt: videoObject.updated,
+ updatedAt: new Date(videoObject.updated),
views: videoObject.views,
likes: 0,
dislikes: 0,
function videoFileActivityUrlToDBAttributes (videoCreated: VideoInstance, videoObject: VideoTorrentObject) {
const fileUrls = videoObject.url
- .filter(u => Object.keys(VIDEO_MIMETYPE_EXT).indexOf(u.mimeType) !== -1)
+ .filter(u => Object.keys(VIDEO_MIMETYPE_EXT).indexOf(u.mimeType) !== -1 && u.url.startsWith('video/'))
const attributes: VideoFileAttributes[] = []
for (const url of fileUrls) {
name: videoChannelToCreateData.name,
description: videoChannelToCreateData.content,
uuid: videoChannelToCreateData.uuid,
- createdAt: videoChannelToCreateData.published,
- updatedAt: videoChannelToCreateData.updated,
+ createdAt: new Date(videoChannelToCreateData.published),
+ updatedAt: new Date(videoChannelToCreateData.updated),
remote: true,
accountId: account.id
}
const videoChannelObject = videoChannel.toActivityPubObject()
const data = await createActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
- return broadcastToFollowers(data, videoChannel.Account, t)
+ return broadcastToFollowers(data, [ videoChannel.Account ], t)
}
async function sendUpdateVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
const videoChannelObject = videoChannel.toActivityPubObject()
const data = await updateActivityData(videoChannel.url, videoChannel.Account, videoChannelObject)
- return broadcastToFollowers(data, videoChannel.Account, t)
+ return broadcastToFollowers(data, [ videoChannel.Account ], t)
}
async function sendDeleteVideoChannel (videoChannel: VideoChannelInstance, t: Sequelize.Transaction) {
const data = await deleteActivityData(videoChannel.url, videoChannel.Account)
- return broadcastToFollowers(data, videoChannel.Account, t)
+ return broadcastToFollowers(data, [ videoChannel.Account ], t)
}
async function sendAddVideo (video: VideoInstance, t: Sequelize.Transaction) {
const videoObject = video.toActivityPubObject()
const data = await addActivityData(video.url, video.VideoChannel.Account, video.VideoChannel.url, videoObject)
- return broadcastToFollowers(data, video.VideoChannel.Account, t)
+ return broadcastToFollowers(data, [ video.VideoChannel.Account ], t)
}
async function sendUpdateVideo (video: VideoInstance, t: Sequelize.Transaction) {
const videoObject = video.toActivityPubObject()
const data = await updateActivityData(video.url, video.VideoChannel.Account, videoObject)
- return broadcastToFollowers(data, video.VideoChannel.Account, t)
+ return broadcastToFollowers(data, [ video.VideoChannel.Account ], t)
}
async function sendDeleteVideo (video: VideoInstance, t: Sequelize.Transaction) {
const data = await deleteActivityData(video.url, video.VideoChannel.Account)
- return broadcastToFollowers(data, video.VideoChannel.Account, t)
+ return broadcastToFollowers(data, [ video.VideoChannel.Account ], t)
}
async function sendDeleteAccount (account: AccountInstance, t: Sequelize.Transaction) {
const data = await deleteActivityData(account.url, account)
- return broadcastToFollowers(data, account, t)
+ return broadcastToFollowers(data, [ account ], t)
+}
+
+async function sendAnnounce (byAccount: AccountInstance, instance: VideoInstance | VideoChannelInstance, t: Sequelize.Transaction) {
+ const object = instance.toActivityPubObject()
+
+ let url = ''
+ let objectActorUrl: string
+ if ((instance as any).VideoChannel !== undefined) {
+ objectActorUrl = (instance as VideoInstance).VideoChannel.Account.url
+ url = getActivityPubUrl('video', instance.uuid) + '#announce'
+ } else {
+ objectActorUrl = (instance as VideoChannelInstance).Account.url
+ url = getActivityPubUrl('videoChannel', instance.uuid) + '#announce'
+ }
+
+ const objectWithActor = Object.assign(object, {
+ actor: objectActorUrl
+ })
+
+ const data = await announceActivityData(url, byAccount, objectWithActor)
+ return broadcastToFollowers(data, [ byAccount ], t)
}
async function sendVideoAbuse (
sendDeleteAccount,
sendAccept,
sendFollow,
- sendVideoAbuse
+ sendVideoAbuse,
+ sendAnnounce
}
// ---------------------------------------------------------------------------
-async function broadcastToFollowers (data: any, fromAccount: AccountInstance, t: Sequelize.Transaction) {
- const result = await db.AccountFollow.listAcceptedFollowerUrlsForApi(fromAccount.id)
+async function broadcastToFollowers (data: any, toAccountFollowers: AccountInstance[], t: Sequelize.Transaction) {
+ const toAccountFollowerIds = toAccountFollowers.map(a => a.id)
+ const result = await db.AccountFollow.listAcceptedFollowerSharedInboxUrls(toAccountFollowerIds)
if (result.data.length === 0) {
- logger.info('Not broadcast because of 0 followers.')
+ logger.info('Not broadcast because of 0 followers for %s.', toAccountFollowerIds.join(', '))
return
}
return buildSignedActivity(byAccount, base)
}
+async function announceActivityData (url: string, byAccount: AccountInstance, object: any) {
+ const base = {
+ type: 'Announce',
+ id: url,
+ actor: byAccount.url,
+ object
+ }
+
+ return buildSignedActivity(byAccount, base)
+}
+
async function followActivityData (url: string, byAccount: AccountInstance) {
const base = {
type: 'Follow',
import { sendAddVideo } from '../../activitypub/send-request'
import { JobScheduler } from '../job-scheduler'
import { TranscodingJobPayload } from './transcoding-job-scheduler'
+import { shareVideoByServer } from '../../../helpers/activitypub'
async function process (data: TranscodingJobPayload, jobId: number) {
const video = await db.Video.loadByUUIDAndPopulateAccountAndServerAndTags(data.videoUUID)
// Now we'll add the video's meta data to our followers
await sendAddVideo(video, undefined)
+ await shareVideoByServer(video, undefined)
const originalFileHeight = await videoDatabase.getOriginalFileHeight()
import { AccountInstance } from '../models'
import { VideoChannelCreate } from '../../shared/models'
import { sendCreateVideoChannel } from './activitypub/send-request'
-import { getActivityPubUrl } from '../helpers/activitypub'
+import { getActivityPubUrl, shareVideoChannelByServer } from '../helpers/activitypub'
async function createVideoChannel (videoChannelInfo: VideoChannelCreate, account: AccountInstance, t: Sequelize.Transaction) {
const videoChannelData = {
// Do not forget to add Account information to the created video channel
videoChannelCreated.Account = account
- sendCreateVideoChannel(videoChannelCreated, t)
+ await sendCreateVideoChannel(videoChannelCreated, t)
+ await shareVideoChannelByServer(videoChannelCreated, t)
return videoChannelCreated
}
export type ListFollowingForApi = (id: number, start: number, count: number, sort: string) => Bluebird< ResultList<AccountInstance> >
export type ListFollowersForApi = (id: number, start: number, count: number, sort: string) => Bluebird< ResultList<AccountInstance> >
- export type ListAcceptedFollowerUrlsForApi = (id: number, start?: number, count?: number) => Promise< ResultList<string> >
- export type ListAcceptedFollowingUrlsForApi = (id: number, start?: number, count?: number) => Promise< ResultList<string> >
+ export type ListAcceptedFollowerUrlsForApi = (accountId: number[], start?: number, count?: number) => Promise< ResultList<string> >
+ export type ListAcceptedFollowingUrlsForApi = (accountId: number[], start?: number, count?: number) => Promise< ResultList<string> >
+ export type ListAcceptedFollowerSharedInboxUrls = (accountId: number[]) => Promise< ResultList<string> >
}
export interface AccountFollowClass {
listAcceptedFollowerUrlsForApi: AccountFollowMethods.ListAcceptedFollowerUrlsForApi
listAcceptedFollowingUrlsForApi: AccountFollowMethods.ListAcceptedFollowingUrlsForApi
+ listAcceptedFollowerSharedInboxUrls: AccountFollowMethods.ListAcceptedFollowerSharedInboxUrls
}
export interface AccountFollowAttributes {
let listFollowersForApi: AccountFollowMethods.ListFollowersForApi
let listAcceptedFollowerUrlsForApi: AccountFollowMethods.ListAcceptedFollowerUrlsForApi
let listAcceptedFollowingUrlsForApi: AccountFollowMethods.ListAcceptedFollowingUrlsForApi
+let listAcceptedFollowerSharedInboxUrls: AccountFollowMethods.ListAcceptedFollowerSharedInboxUrls
export default function (sequelize: Sequelize.Sequelize, DataTypes: Sequelize.DataTypes) {
AccountFollow = sequelize.define<AccountFollowInstance, AccountFollowAttributes>('AccountFollow',
listFollowingForApi,
listFollowersForApi,
listAcceptedFollowerUrlsForApi,
- listAcceptedFollowingUrlsForApi
+ listAcceptedFollowingUrlsForApi,
+ listAcceptedFollowerSharedInboxUrls
]
addMethodsToModel(AccountFollow, classMethods)
})
}
-listAcceptedFollowerUrlsForApi = function (accountId: number, start?: number, count?: number) {
- return createListAcceptedFollowForApiQuery('followers', accountId, start, count)
+listAcceptedFollowerUrlsForApi = function (accountIds: number[], start?: number, count?: number) {
+ return createListAcceptedFollowForApiQuery('followers', accountIds, start, count)
}
-listAcceptedFollowingUrlsForApi = function (accountId: number, start?: number, count?: number) {
- return createListAcceptedFollowForApiQuery('following', accountId, start, count)
+listAcceptedFollowerSharedInboxUrls = function (accountIds: number[]) {
+ return createListAcceptedFollowForApiQuery('followers', accountIds, undefined, undefined, 'sharedInboxUrl')
+}
+
+listAcceptedFollowingUrlsForApi = function (accountIds: number[], start?: number, count?: number) {
+ return createListAcceptedFollowForApiQuery('following', accountIds, start, count)
}
// ------------------------------ UTILS ------------------------------
-async function createListAcceptedFollowForApiQuery (type: 'followers' | 'following', accountId: number, start?: number, count?: number) {
+async function createListAcceptedFollowForApiQuery (
+ type: 'followers' | 'following',
+ accountIds: number[],
+ start?: number,
+ count?: number,
+ columnUrl = 'url'
+) {
let firstJoin: string
let secondJoin: string
secondJoin = 'targetAccountId'
}
- const selections = [ '"Follows"."url" AS "url"', 'COUNT(*) AS "total"' ]
+ const selections = [ '"Follows"."' + columnUrl + '" AS "url"', 'COUNT(*) AS "total"' ]
const tasks: Promise<any>[] = []
for (const selection of selections) {
let query = 'SELECT ' + selection + ' FROM "Accounts" ' +
'INNER JOIN "AccountFollows" ON "AccountFollows"."' + firstJoin + '" = "Accounts"."id" ' +
'INNER JOIN "Accounts" AS "Follows" ON "AccountFollows"."' + secondJoin + '" = "Follows"."id" ' +
- 'WHERE "Accounts"."id" = $accountId AND "AccountFollows"."state" = \'accepted\' '
+ 'WHERE "Accounts"."id" IN ($accountIds) AND "AccountFollows"."state" = \'accepted\' '
if (start !== undefined) query += 'LIMIT ' + start
if (count !== undefined) query += ', ' + count
const options = {
- bind: { accountId },
+ bind: { accountIds: accountIds.join(',') },
type: Sequelize.QueryTypes.SELECT
}
tasks.push(AccountFollow['sequelize'].query(query, options))
uuid: this.uuid,
content: this.description,
name: this.name,
- published: this.createdAt,
- updated: this.updatedAt
+ published: this.createdAt.toISOString(),
+ updated: this.updatedAt.toISOString()
}
return json
for (const file of this.VideoFiles) {
url.push({
type: 'Link',
- mimeType: 'video/' + file.extname,
+ mimeType: 'video/' + file.extname.replace('.', ''),
url: getVideoFileUrl(this, file, baseUrlHttp),
width: file.resolution,
size: file.size
},
views: this.views,
nsfw: this.nsfw,
- published: this.createdAt,
- updated: this.updatedAt,
+ published: this.createdAt.toISOString(),
+ updated: this.updatedAt.toISOString(),
mediaType: 'text/markdown',
content: this.getTruncatedDescription(),
icon: {
name: string
content: string
uuid: string
- published: Date
- updated: Date
+ published: string
+ updated: string
actor?: string
}
language: ActivityIdentifierObject
views: number
nsfw: boolean
- published: Date
- updated: Date
+ published: string
+ updated: string
mediaType: 'text/markdown'
content: string
icon: ActivityIconObject