Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

View File

@@ -0,0 +1,603 @@
import { HttpStatusCode, VideoChaptersObject, VideoCommentObject, VideoRateType } from '@peertube/peertube-models'
import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
import { getContextFilter } from '@server/lib/activitypub/context.js'
import { buildChaptersAPHasPart } from '@server/lib/activitypub/video-chapters.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { getServerActor } from '@server/models/application/application.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import { VideoModel } from '@server/models/video/video.js'
import { MAccountId, MActorId, MChannelId, MVideoId } from '@server/types/models/index.js'
import cors from 'cors'
import express from 'express'
import { activityPubContextify } from '../../helpers/activity-pub-utils.js'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import { audiencify, getCommentAudience, getPlaylistAudience, getPublicAudience, getVideoAudience } from '../../lib/activitypub/audience.js'
import { buildAnnounceWithVideoAudience, buildApprovalActivity, buildLikeActivity } from '../../lib/activitypub/send/index.js'
import { buildCreateActivity } from '../../lib/activitypub/send/send-create.js'
import { buildDislikeActivity } from '../../lib/activitypub/send/send-dislike.js'
import {
getLocalVideoChaptersActivityPubUrl,
getLocalVideoCommentsActivityPubUrl,
getLocalVideoDislikesActivityPubUrl,
getLocalVideoLikesActivityPubUrl,
getLocalVideoSharesActivityPubUrl
} from '../../lib/activitypub/url.js'
import {
apVideoChaptersSetCacheKey,
buildAPVideoChaptersGroupsCache,
cacheRoute,
cacheRouteFactory
} from '../../middlewares/cache/cache.js'
import {
activityPubRateLimiter,
asyncMiddleware,
executeIfActivityPub,
videoChannelsHandleValidatorFactory,
videosCustomGetValidator,
videosShareValidator
} from '../../middlewares/index.js'
import {
accountHandleGetValidatorFactory,
getAccountVideoRateValidatorFactory,
getVideoLocalViewerValidator,
videoCommentGetValidator
} from '../../middlewares/validators/index.js'
import { videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy.js'
import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists.js'
import { AccountVideoRateModel } from '../../models/account/account-video-rate.js'
import { AccountModel } from '../../models/account/account.js'
import { ActorFollowModel } from '../../models/actor/actor-follow.js'
import { VideoCommentModel } from '../../models/video/video-comment.js'
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
import { VideoShareModel } from '../../models/video/video-share.js'
import { activityPubResponse } from './utils.js'
const activityPubClientRouter = express.Router()
activityPubClientRouter.use(cors())
// Intercept ActivityPub client requests
activityPubClientRouter.get(
[ '/accounts?/:handle', '/accounts?/:handle/video-channels', '/a/:handle', '/a/:handle/video-channels' ],
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })),
asyncMiddleware(accountController)
)
activityPubClientRouter.get(
'/accounts?/:handle/followers',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })),
asyncMiddleware(accountFollowersController)
)
activityPubClientRouter.get(
'/accounts?/:handle/following',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })),
asyncMiddleware(accountFollowingController)
)
activityPubClientRouter.get(
'/accounts?/:handle/playlists',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })),
asyncMiddleware(accountPlaylistsController)
)
activityPubClientRouter.get(
'/accounts?/:accountName/likes/:videoId',
executeIfActivityPub,
activityPubRateLimiter,
cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
asyncMiddleware(getAccountVideoRateValidatorFactory('like')),
asyncMiddleware(getAccountVideoRateFactory('like'))
)
activityPubClientRouter.get(
'/accounts?/:accountName/dislikes/:videoId',
executeIfActivityPub,
activityPubRateLimiter,
cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
asyncMiddleware(getAccountVideoRateValidatorFactory('dislike')),
asyncMiddleware(getAccountVideoRateFactory('dislike'))
)
// ---------------------------------------------------------------------------
activityPubClientRouter.get(
'/videos/watch/:id/comments',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoCommentsController)
)
activityPubClientRouter.get(
'/videos/watch/:videoId/comments/:commentId/approve-reply',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoCommentGetValidator),
asyncMiddleware(videoCommentApprovedController)
)
activityPubClientRouter.get(
'/videos/watch/:videoId/comments/:commentId/activity',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoCommentGetValidator),
asyncMiddleware(videoCommentController)
)
activityPubClientRouter.get(
[ '/videos/watch/:videoId/comments/:commentId', '/w/:videoId([^;]+);threadId=:commentId([0-9]+)' ],
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoCommentGetValidator),
asyncMiddleware(videoCommentController)
)
// ---------------------------------------------------------------------------
activityPubClientRouter.get(
[ '/videos/watch/:id', '/w/:id' ],
executeIfActivityPub,
activityPubRateLimiter,
cacheRoute(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
asyncMiddleware(videosCustomGetValidator('all')),
asyncMiddleware(videoController)
)
activityPubClientRouter.get(
'/videos/watch/:id/activity',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('all')),
asyncMiddleware(videoController)
)
activityPubClientRouter.get(
'/videos/watch/:id/announces',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoAnnouncesController)
)
activityPubClientRouter.get(
'/videos/watch/:id/announces/:actorId',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosShareValidator),
asyncMiddleware(videoAnnounceController)
)
activityPubClientRouter.get(
'/videos/watch/:id/likes',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoLikesController)
)
activityPubClientRouter.get(
'/videos/watch/:id/dislikes',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoDislikesController)
)
activityPubClientRouter.get(
'/videos/watch/:id/player-settings',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoPlayerSettingsController)
)
// ---------------------------------------------------------------------------
const { middleware: chaptersCacheRouteMiddleware, instance: chaptersApiCache } = cacheRouteFactory()
InternalEventEmitter.Instance.on('chapters-updated', ({ video }) => {
if (video.remote) return
chaptersApiCache.clearGroupSafe(buildAPVideoChaptersGroupsCache({ videoId: video.uuid }))
})
activityPubClientRouter.get(
'/videos/watch/:id/chapters',
executeIfActivityPub,
activityPubRateLimiter,
apVideoChaptersSetCacheKey,
chaptersCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS),
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(videoChaptersController)
)
// ---------------------------------------------------------------------------
activityPubClientRouter.get(
[ '/video-channels/:handle', '/video-channels/:handle/videos', '/c/:handle', '/c/:handle/videos' ],
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(videoChannelController)
)
activityPubClientRouter.get(
'/video-channels/:handle/followers',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(videoChannelFollowersController)
)
activityPubClientRouter.get(
'/video-channels/:handle/following',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(videoChannelFollowingController)
)
activityPubClientRouter.get(
'/video-channels/:handle/playlists',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(videoChannelPlaylistsController)
)
activityPubClientRouter.get(
'/video-channels/:handle/player-settings',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(channelPlayerSettingsController)
)
activityPubClientRouter.get(
'/redundancy/streaming-playlists/:streamingPlaylistType/:videoId',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoPlaylistRedundancyGetValidator),
asyncMiddleware(videoRedundancyController)
)
activityPubClientRouter.get(
[ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoPlaylistsGetValidator('all')),
asyncMiddleware(videoPlaylistController)
)
activityPubClientRouter.get(
'/video-playlists/:playlistId/videos/:playlistElementId',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(videoPlaylistElementAPGetValidator),
asyncMiddleware(videoPlaylistElementController)
)
activityPubClientRouter.get(
'/videos/local-viewer/:localViewerId',
executeIfActivityPub,
activityPubRateLimiter,
asyncMiddleware(getVideoLocalViewerValidator),
asyncMiddleware(getVideoLocalViewerController)
)
// ---------------------------------------------------------------------------
export {
activityPubClientRouter
}
// ---------------------------------------------------------------------------
async function accountController (req: express.Request, res: express.Response) {
const account = res.locals.account
return activityPubResponse(activityPubContextify(await account.toActivityPubObject(), 'Actor', getContextFilter()), res)
}
async function accountFollowersController (req: express.Request, res: express.Response) {
const account = res.locals.account
const activityPubResult = await actorFollowers(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
async function accountFollowingController (req: express.Request, res: express.Response) {
const account = res.locals.account
const activityPubResult = await actorFollowing(req, account.Actor)
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
async function accountPlaylistsController (req: express.Request, res: express.Response) {
const account = res.locals.account
const activityPubResult = await actorPlaylists(req, { account })
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
async function videoChannelPlaylistsController (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const activityPubResult = await actorPlaylists(req, { channel })
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
function getAccountVideoRateFactory (rateType: VideoRateType) {
return (req: express.Request, res: express.Response) => {
const accountVideoRate = res.locals.accountVideoRate
const byActor = accountVideoRate.Account.Actor
const APObject = rateType === 'like'
? buildLikeActivity(accountVideoRate.url, byActor, accountVideoRate.Video)
: buildDislikeActivity(accountVideoRate.url, byActor, accountVideoRate.Video)
return activityPubResponse(activityPubContextify(APObject, 'Rate', getContextFilter()), res)
}
}
async function videoController (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
if (redirectIfNotOwned(video.url, res)) return
// We need captions to render AP object
const videoAP = await video.lightAPToFullAP(undefined)
const audience = getVideoAudience({ account: videoAP.VideoChannel.Account, channel: videoAP.VideoChannel, privacy: videoAP.privacy })
const videoObject = audiencify(await videoAP.toActivityPubObject(), audience)
if (req.path.endsWith('/activity')) {
const data = buildCreateActivity(videoAP.url, video.VideoChannel.Account.Actor, videoObject, audience)
return activityPubResponse(activityPubContextify(data, 'Video', getContextFilter()), res)
}
return activityPubResponse(activityPubContextify(videoObject, 'Video', getContextFilter()), res)
}
async function videoAnnounceController (req: express.Request, res: express.Response) {
const share = res.locals.videoShare
if (redirectIfNotOwned(share.url, res)) return
const activity = buildAnnounceWithVideoAudience(share.Actor, share, res.locals.videoAll)
return activityPubResponse(activityPubContextify(activity, 'Announce', getContextFilter()), res)
}
async function videoAnnouncesController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const handler = async (start: number, count: number) => {
const result = await VideoShareModel.listAndCountByVideoId(video.id, start, count)
return {
total: result.total,
data: result.data.map(r => r.url)
}
}
const json = await activityPubCollectionPagination(getLocalVideoSharesActivityPubUrl(video), handler, req.query.page)
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
}
async function videoLikesController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const json = await videoRates(req, 'like', video, getLocalVideoLikesActivityPubUrl(video))
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
}
async function videoDislikesController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const json = await videoRates(req, 'dislike', video, getLocalVideoDislikesActivityPubUrl(video))
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
}
async function videoCommentsController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const handler = async (start: number, count: number) => {
const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
return {
total: result.total,
data: result.data.map(r => r.url)
}
}
const json = await activityPubCollectionPagination(getLocalVideoCommentsActivityPubUrl(video), handler, req.query.page)
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
}
// ---------------------------------------------------------------------------
async function videoPlayerSettingsController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const settings = await PlayerSettingModel.loadByVideoId(video.id)
const json = PlayerSettingModel.formatAPPlayerSetting({ channel: undefined, video, settings })
return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res)
}
async function channelPlayerSettingsController (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const settings = await PlayerSettingModel.loadByChannelId(channel.id)
const json = PlayerSettingModel.formatAPPlayerSetting({ channel, video: undefined, settings })
return activityPubResponse(activityPubContextify(json, 'PlayerSettings', getContextFilter()), res)
}
// ---------------------------------------------------------------------------
async function videoChannelController (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
return activityPubResponse(activityPubContextify(await videoChannel.toActivityPubObject(), 'Actor', getContextFilter()), res)
}
async function videoChannelFollowersController (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const activityPubResult = await actorFollowers(req, videoChannel.Actor)
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
async function videoChannelFollowingController (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const activityPubResult = await actorFollowing(req, videoChannel.Actor)
return activityPubResponse(activityPubContextify(activityPubResult, 'Collection', getContextFilter()), res)
}
async function videoCommentController (req: express.Request, res: express.Response) {
const videoComment = res.locals.videoCommentFull
if (redirectIfNotOwned(videoComment.url, res)) return
if (videoComment.Video.isLocal() && videoComment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment })
let videoCommentObject = videoComment.toActivityPubObject(threadParentComments)
if (videoComment.Account) {
const video = await VideoModel.loadByUrlAndPopulateAccount(videoComment.Video.url)
const audience = getCommentAudience({ comment: videoComment, video, threadParentComments })
videoCommentObject = audiencify(videoCommentObject, audience)
if (req.path.endsWith('/activity')) {
const data = buildCreateActivity(videoComment.url, videoComment.Account.Actor, videoCommentObject as VideoCommentObject, audience)
return activityPubResponse(activityPubContextify(data, 'Comment', getContextFilter()), res)
}
}
return activityPubResponse(activityPubContextify(videoCommentObject, 'Comment', getContextFilter()), res)
}
async function videoCommentApprovedController (req: express.Request, res: express.Response) {
const comment = res.locals.videoCommentFull
if (!comment.Video.isLocal() || comment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const activity = buildApprovalActivity({ comment, type: 'ApproveReply' })
return activityPubResponse(activityPubContextify(activity, 'ApproveReply', getContextFilter()), res)
}
async function videoChaptersController (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
if (redirectIfNotOwned(video.url, res)) return
const chapters = await VideoChapterModel.listChaptersOfVideo(video.id)
const chaptersObject: VideoChaptersObject = {
id: getLocalVideoChaptersActivityPubUrl(video),
hasPart: buildChaptersAPHasPart(video, chapters)
}
return activityPubResponse(activityPubContextify(chaptersObject, 'Chapters', getContextFilter()), res)
}
async function videoRedundancyController (req: express.Request, res: express.Response) {
const videoRedundancy = res.locals.videoRedundancy
if (redirectIfNotOwned(videoRedundancy.url, res)) return
const serverActor = await getServerActor()
const audience = getPublicAudience(serverActor)
const object = audiencify(videoRedundancy.toActivityPubObject(), audience)
if (req.path.endsWith('/activity')) {
const data = buildCreateActivity(videoRedundancy.url, serverActor, object, audience)
return activityPubResponse(activityPubContextify(data, 'CacheFile', getContextFilter()), res)
}
return activityPubResponse(activityPubContextify(object, 'CacheFile', getContextFilter()), res)
}
async function videoPlaylistController (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistFull
if (redirectIfNotOwned(playlist.url, res)) return
// We need more attributes
playlist.OwnerAccount = await AccountModel.load(playlist.ownerAccountId)
const json = await playlist.toActivityPubObject(req.query.page, null)
const audience = getPlaylistAudience(playlist.OwnerAccount.Actor, playlist.privacy)
const object = audiencify(json, audience)
return activityPubResponse(activityPubContextify(object, 'Playlist', getContextFilter()), res)
}
function videoPlaylistElementController (req: express.Request, res: express.Response) {
const videoPlaylistElement = res.locals.videoPlaylistElementAP
if (redirectIfNotOwned(videoPlaylistElement.url, res)) return
const json = videoPlaylistElement.toActivityPubObject()
return activityPubResponse(activityPubContextify(json, 'Playlist', getContextFilter()), res)
}
function getVideoLocalViewerController (req: express.Request, res: express.Response) {
const localViewer = res.locals.localViewerFull
return activityPubResponse(activityPubContextify(localViewer.toActivityPubObject(), 'WatchAction', getContextFilter()), res)
}
// ---------------------------------------------------------------------------
function actorFollowing (req: express.Request, actor: MActorId) {
const handler = (start: number, count: number) => {
return ActorFollowModel.listAcceptedFollowingUrlsForApi([ actor.id ], undefined, start, count)
}
return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
}
function actorFollowers (req: express.Request, actor: MActorId) {
const handler = (start: number, count: number) => {
return ActorFollowModel.listAcceptedFollowerUrlsForAP([ actor.id ], undefined, start, count)
}
return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
}
function actorPlaylists (req: express.Request, options: { account: MAccountId } | { channel: MChannelId }) {
const handler = (start: number, count: number) => {
return VideoPlaylistModel.listPublicUrlsOfForAP(options, start, count)
}
return activityPubCollectionPagination(WEBSERVER.URL + req.path, handler, req.query.page)
}
function videoRates (req: express.Request, rateType: VideoRateType, video: MVideoId, url: string) {
const handler = async (start: number, count: number) => {
const result = await AccountVideoRateModel.listAndCountAccountUrlsByVideoId(rateType, video.id, start, count)
return {
total: result.total,
data: result.data.map(r => r.url)
}
}
return activityPubCollectionPagination(url, handler, req.query.page)
}
function redirectIfNotOwned (url: string, res: express.Response) {
if (url.startsWith(WEBSERVER.URL) === false) {
res.redirect(url)
return true
}
return false
}

View File

@@ -0,0 +1,85 @@
import { Activity, ActivityPubCollection, ActivityPubOrderedCollection, HttpStatusCode, RootActivity } from '@peertube/peertube-models'
import { InboxManager } from '@server/lib/activitypub/inbox-manager.js'
import express from 'express'
import { isActivityValid } from '../../helpers/custom-validators/activitypub/activity.js'
import { logger } from '../../helpers/logger.js'
import {
accountHandleGetValidatorFactory,
activityPubRateLimiter,
asyncMiddleware,
checkSignature,
signatureValidator,
videoChannelsHandleValidatorFactory
} from '../../middlewares/index.js'
import { activityPubValidator } from '../../middlewares/validators/activitypub/activity.js'
const inboxRouter = express.Router()
inboxRouter.post(
'/inbox',
activityPubRateLimiter,
signatureValidator,
asyncMiddleware(checkSignature),
asyncMiddleware(activityPubValidator),
inboxController
)
inboxRouter.post(
'/accounts/:handle/inbox',
activityPubRateLimiter,
signatureValidator,
asyncMiddleware(checkSignature),
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })),
asyncMiddleware(activityPubValidator),
inboxController
)
inboxRouter.post(
'/video-channels/:handle/inbox',
activityPubRateLimiter,
signatureValidator,
asyncMiddleware(checkSignature),
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(activityPubValidator),
inboxController
)
// ---------------------------------------------------------------------------
export {
inboxRouter
}
// ---------------------------------------------------------------------------
function inboxController (req: express.Request, res: express.Response) {
const rootActivity: RootActivity = req.body
let activities: Activity[]
if ([ 'Collection', 'CollectionPage' ].includes(rootActivity.type)) {
activities = (rootActivity as ActivityPubCollection).items
} else if ([ 'OrderedCollection', 'OrderedCollectionPage' ].includes(rootActivity.type)) {
activities = (rootActivity as ActivityPubOrderedCollection<Activity>).orderedItems
} else {
activities = [ rootActivity as Activity ]
}
// Only keep activities we are able to process
logger.debug('Filtering %d activities...', activities.length, { activities })
activities = activities.filter(a => isActivityValid(a))
logger.debug('We keep %d activities.', activities.length, { activities })
const accountOrChannel = res.locals.account || res.locals.videoChannel
logger.info('Receiving inbox requests for %d activities by %s.', activities.length, res.locals.signature.actor.url)
InboxManager.Instance.addInboxMessage({
activities,
signatureActor: res.locals.signature.actor,
inboxActor: accountOrChannel
? accountOrChannel.Actor
: undefined
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,16 @@
import express from 'express'
import { activityPubClientRouter } from './client.js'
import { inboxRouter } from './inbox.js'
import { outboxRouter } from './outbox.js'
const activityPubRouter = express.Router()
activityPubRouter.use('/', inboxRouter)
activityPubRouter.use('/', outboxRouter)
activityPubRouter.use('/', activityPubClientRouter)
// ---------------------------------------------------------------------------
export {
activityPubRouter
}

View File

@@ -0,0 +1,88 @@
import { Activity } from '@peertube/peertube-models'
import { activityPubContextify } from '@server/helpers/activity-pub-utils.js'
import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js'
import { getContextFilter } from '@server/lib/activitypub/context.js'
import { MActorLight } from '@server/types/models/index.js'
import express from 'express'
import { logger } from '../../helpers/logger.js'
import { getVideoAudience } from '../../lib/activitypub/audience.js'
import { buildAnnounceActivity, buildCreateActivity } from '../../lib/activitypub/send/index.js'
import {
accountHandleGetValidatorFactory,
activityPubRateLimiter,
asyncMiddleware,
videoChannelsHandleValidatorFactory
} from '../../middlewares/index.js'
import { apPaginationValidator } from '../../middlewares/validators/activitypub/index.js'
import { VideoModel } from '../../models/video/video.js'
import { activityPubResponse } from './utils.js'
const outboxRouter = express.Router()
outboxRouter.get(
'/accounts/:handle/outbox',
activityPubRateLimiter,
apPaginationValidator,
accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false }),
asyncMiddleware(outboxController)
)
outboxRouter.get(
'/video-channels/:handle/outbox',
activityPubRateLimiter,
apPaginationValidator,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(outboxController)
)
// ---------------------------------------------------------------------------
export {
outboxRouter
}
// ---------------------------------------------------------------------------
async function outboxController (req: express.Request, res: express.Response) {
const accountOrVideoChannel = res.locals.account || res.locals.videoChannel
const actor = accountOrVideoChannel.Actor
const actorOutboxUrl = actor.url + '/outbox'
logger.info('Receiving outbox request for %s.', actorOutboxUrl)
const handler = (start: number, count: number) => buildActivities(actor, start, count)
const json = await activityPubCollectionPagination(actorOutboxUrl, handler, req.query.page, req.query.size)
return activityPubResponse(activityPubContextify(json, 'Collection', getContextFilter()), res)
}
async function buildActivities (actor: MActorLight, start: number, count: number) {
const data = await VideoModel.listAllAndSharedByActorForOutbox(actor.id, start, count)
const activities: Activity[] = []
for (const video of data.data) {
const byActor = video.VideoChannel.Account.Actor
const createActivityAudience = getVideoAudience({
account: video.VideoChannel.Account,
channel: video.VideoChannel,
privacy: video.privacy
})
// This is a shared video
if (video.VideoShares !== undefined && video.VideoShares.length !== 0) {
const videoShare = video.VideoShares[0]
const announceActivity = buildAnnounceActivity(videoShare.url, actor, video.url, createActivityAudience)
activities.push(announceActivity)
} else {
const createActivity = buildCreateActivity(video.url, byActor, video.url, createActivityAudience)
activities.push(createActivity)
}
}
return {
data: activities,
total: data.total
}
}

View File

@@ -0,0 +1,11 @@
import express from 'express'
export async function activityPubResponse (promise: Promise<any>, res: express.Response) {
const data = await promise
if (!res.headersSent) {
res.type('application/activity+json; charset=utf-8')
}
return res.json(data)
}

View File

@@ -0,0 +1,270 @@
import express from 'express'
import { logger } from '@server/helpers/logger.js'
import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation.js'
import { Notifier } from '@server/lib/notifier/index.js'
import { AbuseMessageModel } from '@server/models/abuse/abuse-message.js'
import { AbuseModel } from '@server/models/abuse/abuse.js'
import { getServerActor } from '@server/models/application/application.js'
import { abusePredefinedReasonsMap } from '@peertube/peertube-core-utils'
import { AbuseCreate, AbuseState, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { getFormattedObjects } from '../../helpers/utils.js'
import { sequelizeTypescript } from '../../initializers/database.js'
import {
abuseGetValidator,
abuseListForAdminsValidator,
abuseReportValidator,
abusesSortValidator,
abuseUpdateValidator,
addAbuseMessageValidator,
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
checkAbuseValidForMessagesValidator,
deleteAbuseMessageValidator,
ensureUserHasRight,
getAbuseValidator,
openapiOperationDoc,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../middlewares/index.js'
import { AccountModel } from '../../models/account/account.js'
const abuseRouter = express.Router()
abuseRouter.use(apiRateLimiter)
abuseRouter.get('/',
openapiOperationDoc({ operationId: 'getAbuses' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_ABUSES),
paginationValidator,
abusesSortValidator,
setDefaultSort,
setDefaultPagination,
abuseListForAdminsValidator,
asyncMiddleware(listAbusesForAdmins)
)
abuseRouter.put('/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(abuseUpdateValidator),
asyncRetryTransactionMiddleware(updateAbuse)
)
abuseRouter.post('/',
authenticate,
asyncMiddleware(abuseReportValidator),
asyncRetryTransactionMiddleware(reportAbuse)
)
abuseRouter.delete('/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(abuseGetValidator),
asyncRetryTransactionMiddleware(deleteAbuse)
)
abuseRouter.get('/:id/messages',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
asyncRetryTransactionMiddleware(listAbuseMessages)
)
abuseRouter.post('/:id/messages',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
addAbuseMessageValidator,
asyncRetryTransactionMiddleware(addAbuseMessage)
)
abuseRouter.delete('/:id/messages/:messageId',
authenticate,
asyncMiddleware(getAbuseValidator),
checkAbuseValidForMessagesValidator,
asyncMiddleware(deleteAbuseMessageValidator),
asyncRetryTransactionMiddleware(deleteAbuseMessage)
)
// ---------------------------------------------------------------------------
export {
abuseRouter
}
// ---------------------------------------------------------------------------
async function listAbusesForAdmins (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
const serverActor = await getServerActor()
const resultList = await AbuseModel.listForAdminApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
id: req.query.id,
filter: req.query.filter,
predefinedReason: req.query.predefinedReason,
search: req.query.search,
state: req.query.state,
videoIs: req.query.videoIs,
searchReporter: req.query.searchReporter,
searchReportee: req.query.searchReportee,
searchVideo: req.query.searchVideo,
searchVideoChannel: req.query.searchVideoChannel,
serverAccountId: serverActor.Account.id,
user
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedAdminJSON())
})
}
async function updateAbuse (req: express.Request, res: express.Response) {
const abuse = res.locals.abuse
let stateUpdated = false
if (req.body.moderationComment !== undefined) abuse.moderationComment = req.body.moderationComment
if (req.body.state !== undefined) {
abuse.state = req.body.state
// We consider the abuse has been processed when its state change
if (!abuse.processedAt) abuse.processedAt = new Date()
stateUpdated = true
}
await sequelizeTypescript.transaction(t => {
return abuse.save({ transaction: t })
})
if (stateUpdated === true) {
AbuseModel.loadFull(abuse.id)
.then(abuseFull => Notifier.Instance.notifyOnAbuseStateChange(abuseFull))
.catch(err => logger.error('Cannot notify on abuse state change', { err }))
}
// Do not send the delete to other instances, we updated OUR copy of this abuse
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function deleteAbuse (req: express.Request, res: express.Response) {
const abuse = res.locals.abuse
await sequelizeTypescript.transaction(t => {
return abuse.destroy({ transaction: t })
})
// Do not send the delete to other instances, we delete OUR copy of this abuse
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function reportAbuse (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll
const commentInstance = res.locals.videoCommentFull
const accountInstance = res.locals.account
const body: AbuseCreate = req.body
const { id } = await sequelizeTypescript.transaction(async t => {
const user = res.locals.oauth.token.User
// Don't send abuse notification if reporter is an admin/moderator
const skipNotification = user.hasRight(UserRight.MANAGE_ABUSES)
const reporterAccount = await AccountModel.load(user.Account.id, t)
const predefinedReasons = body.predefinedReasons?.map(r => abusePredefinedReasonsMap[r])
const baseAbuse = {
reporterAccountId: reporterAccount.id,
reason: body.reason,
state: AbuseState.PENDING,
predefinedReasons
}
if (body.video) {
return createVideoAbuse({
baseAbuse,
videoInstance,
reporterAccount,
transaction: t,
startAt: body.video.startAt,
endAt: body.video.endAt,
skipNotification
})
}
if (body.comment) {
return createVideoCommentAbuse({
baseAbuse,
commentInstance,
reporterAccount,
transaction: t,
skipNotification
})
}
// Account report
return createAccountAbuse({
baseAbuse,
accountInstance,
reporterAccount,
transaction: t,
skipNotification
})
})
return res.json({ abuse: { id } })
}
async function listAbuseMessages (req: express.Request, res: express.Response) {
const abuse = res.locals.abuse
const resultList = await AbuseMessageModel.listForApi(abuse.id)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function addAbuseMessage (req: express.Request, res: express.Response) {
const abuse = res.locals.abuse
const user = res.locals.oauth.token.user
const byModerator = abuse.reporterAccountId !== user.Account.id
const abuseMessage = await AbuseMessageModel.create({
message: req.body.message,
byModerator,
accountId: user.Account.id,
abuseId: abuse.id
})
// If a moderator created an abuse message, we consider it as processed
if (byModerator && !abuse.processedAt) {
abuse.processedAt = new Date()
await abuse.save()
}
AbuseModel.loadFull(abuse.id)
.then(abuseFull => Notifier.Instance.notifyOnAbuseMessage(abuseFull, abuseMessage))
.catch(err => logger.error('Cannot notify on new abuse message', { err }))
return res.json({
abuseMessage: {
id: abuseMessage.id
}
})
}
async function deleteAbuseMessage (req: express.Request, res: express.Response) {
const abuseMessage = res.locals.abuseMessage
await sequelizeTypescript.transaction(t => {
return abuseMessage.destroy({ transaction: t })
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,289 @@
import { VideoPlaylistForAccountListQuery } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import express from 'express'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js'
import { getFormattedObjects } from '../../helpers/utils.js'
import { JobQueue } from '../../lib/job-queue/index.js'
import { Hooks } from '../../lib/plugins/hooks.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
commonVideosFiltersValidatorFactory,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort,
setDefaultVideosSort,
videoPlaylistsSortValidator,
videoRatesSortValidator,
videoRatingValidator
} from '../../middlewares/index.js'
import {
accountHandleGetValidatorFactory,
accountsFollowersSortValidator,
accountsSortValidator,
listAccountChannelsSyncValidator,
listAccountChannelsValidator,
videoChannelsSortValidator,
videoChannelSyncsSortValidator,
videosSortValidator
} from '../../middlewares/validators/index.js'
import {
commonVideoPlaylistFiltersValidator,
videoPlaylistsAccountValidator,
videoPlaylistsSearchValidator
} from '../../middlewares/validators/videos/video-playlists.js'
import { AccountVideoRateModel } from '../../models/account/account-video-rate.js'
import { AccountModel } from '../../models/account/account.js'
import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js'
import { VideoChannelModel } from '../../models/video/video-channel.js'
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
import { VideoModel } from '../../models/video/video.js'
const accountsRouter = express.Router()
accountsRouter.use(apiRateLimiter)
accountsRouter.get(
'/',
paginationValidator,
accountsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccounts)
)
accountsRouter.get(
'/:handle',
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })),
getAccount
)
accountsRouter.get(
'/:handle/videos',
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })),
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
optionalAuthenticate,
commonVideosFiltersValidatorFactory(),
asyncMiddleware(listAccountVideos)
)
accountsRouter.get(
'/:handle/video-channels',
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })),
listAccountChannelsValidator,
paginationValidator,
videoChannelsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccountChannels)
)
accountsRouter.get(
'/:handle/video-playlists',
optionalAuthenticate,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })),
paginationValidator,
videoPlaylistsSortValidator,
setDefaultSort,
setDefaultPagination,
commonVideoPlaylistFiltersValidator,
videoPlaylistsSearchValidator,
videoPlaylistsAccountValidator,
asyncMiddleware(listAccountPlaylists)
)
accountsRouter.get(
'/:handle/video-channel-syncs',
authenticate,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })),
paginationValidator,
videoChannelSyncsSortValidator,
setDefaultSort,
setDefaultPagination,
listAccountChannelsSyncValidator,
asyncMiddleware(listAccountChannelsSync)
)
accountsRouter.get(
'/:handle/ratings',
authenticate,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })),
paginationValidator,
videoRatesSortValidator,
setDefaultSort,
setDefaultPagination,
videoRatingValidator,
asyncMiddleware(listAccountRatings)
)
accountsRouter.get(
'/:handle/followers',
authenticate,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })),
paginationValidator,
accountsFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccountFollowers)
)
// ---------------------------------------------------------------------------
export {
accountsRouter
}
// ---------------------------------------------------------------------------
function getAccount (req: express.Request, res: express.Response) {
const account = res.locals.account
if (account.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: account.Actor.url } })
}
return res.json(account.toFormattedJSON())
}
async function listAccounts (req: express.Request, res: express.Response) {
const resultList = await AccountModel.listForApi(req.query.start, req.query.count, req.query.sort)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountChannels (req: express.Request, res: express.Response) {
const resultList = await VideoChannelModel.listByAccountForAPI({
accountId: res.locals.account.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
withStats: req.query.withStats,
includeCollaborations: req.query.includeCollaborations,
search: req.query.search
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountChannelsSync (req: express.Request, res: express.Response) {
const options = {
accountId: res.locals.account.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
includeCollaborations: req.query.includeCollaborations
}
const resultList = await VideoChannelSyncModel.listByAccountForAPI(options)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountPlaylists (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const query = req.query as VideoPlaylistForAccountListQuery
// Allow users to see their private/unlisted video playlists
let listMyPlaylists = false
if (res.locals.oauth && res.locals.oauth.token.User.Account.id === res.locals.account.id) {
listMyPlaylists = true
}
const resultList = await VideoPlaylistModel.listForApi({
followerActorId: isUserAbleToSearchRemoteURI(res)
? null
: serverActor.id,
accountId: res.locals.account.id,
listMyPlaylists,
start: query.start,
count: query.count,
sort: query.sort,
search: query.search,
type: query.playlistType,
channelNameOneOf: req.query.channelNameOneOf,
includeCollaborationsForAccount: listMyPlaylists && query.includeCollaborations
? res.locals.oauth.token.User.Account.id
: undefined
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountVideos (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const account = res.locals.account
const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res)
? null
: {
actorId: serverActor.id,
orLocalVideos: true
}
const countVideos = getCountVideos(req)
const query = pickCommonVideoQuery(req.query)
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower,
accountId: account.id,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos
}, 'filter:api.accounts.videos.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
apiOptions,
'filter:api.accounts.videos.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
}
async function listAccountRatings (req: express.Request, res: express.Response) {
const account = res.locals.account
const resultList = await AccountVideoRateModel.listByAccountForApi({
accountId: account.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
type: req.query.rating
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listAccountFollowers (req: express.Request, res: express.Response) {
const account = res.locals.account
const channels = await VideoChannelModel.listAllOwnedByAccount(account.id)
const actorIds = [ account.Actor.id ].concat(channels.map(c => c.Actor.id))
const resultList = await ActorFollowModel.listFollowersForApi({
actorIds,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
state: 'accepted'
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}

View File

@@ -0,0 +1,82 @@
import { AutomaticTagPolicy, CommentAutomaticTagPoliciesUpdate, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import { setAccountAutomaticTagsPolicy } from '@server/lib/automatic-tags/automatic-tags.js'
import {
manageAccountAutomaticTagsValidator,
updateAutomaticTagPoliciesValidator
} from '@server/middlewares/validators/automatic-tags.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight
} from '../../middlewares/index.js'
const automaticTagRouter = express.Router()
automaticTagRouter.use(apiRateLimiter)
automaticTagRouter.get('/policies/accounts/:accountName/comments',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(getAutomaticTagPolicies)
)
automaticTagRouter.put('/policies/accounts/:accountName/comments',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(updateAutomaticTagPoliciesValidator),
asyncMiddleware(updateAutomaticTagPolicies)
)
// ---------------------------------------------------------------------------
automaticTagRouter.get('/accounts/:accountName/available',
authenticate,
asyncMiddleware(manageAccountAutomaticTagsValidator),
asyncMiddleware(getAccountAutomaticTagAvailable)
)
automaticTagRouter.get('/server/available',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_AUTO_TAGS),
asyncMiddleware(getServerAutomaticTagAvailable)
)
// ---------------------------------------------------------------------------
export {
automaticTagRouter
}
// ---------------------------------------------------------------------------
async function getAutomaticTagPolicies (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagPolicies(res.locals.account)
return res.json(result)
}
async function updateAutomaticTagPolicies (req: express.Request, res: express.Response) {
await setAccountAutomaticTagsPolicy({
account: res.locals.account,
policy: AutomaticTagPolicy.REVIEW_COMMENT,
tags: (req.body as CommentAutomaticTagPoliciesUpdate).review
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function getAccountAutomaticTagAvailable (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagAvailable(res.locals.account)
return res.json(result)
}
async function getServerAutomaticTagAvailable (req: express.Request, res: express.Response) {
const result = await AutomaticTagger.getAutomaticTagAvailable((await getServerActor()).Account)
return res.json(result)
}

View File

@@ -0,0 +1,110 @@
import express from 'express'
import { handleToNameAndHost } from '@server/helpers/actors.js'
import { logger } from '@server/helpers/logger.js'
import { AccountBlocklistModel } from '@server/models/account/account-blocklist.js'
import { getServerActor } from '@server/models/application/application.js'
import { ServerBlocklistModel } from '@server/models/server/server-blocklist.js'
import { MActorAccountId, MUserAccountId } from '@server/types/models/index.js'
import { BlockStatus } from '@peertube/peertube-models'
import { apiRateLimiter, asyncMiddleware, blocklistStatusValidator, optionalAuthenticate } from '../../middlewares/index.js'
const blocklistRouter = express.Router()
blocklistRouter.use(apiRateLimiter)
blocklistRouter.get('/status',
optionalAuthenticate,
blocklistStatusValidator,
asyncMiddleware(getBlocklistStatus)
)
// ---------------------------------------------------------------------------
export {
blocklistRouter
}
// ---------------------------------------------------------------------------
async function getBlocklistStatus (req: express.Request, res: express.Response) {
const hosts = req.query.hosts as string[]
const accounts = req.query.accounts as string[]
const user = res.locals.oauth?.token.User
const serverActor = await getServerActor()
const byAccountIds = [ serverActor.Account.id ]
if (user) byAccountIds.push(user.Account.id)
const status: BlockStatus = {
accounts: {},
hosts: {}
}
const baseOptions = {
byAccountIds,
user,
serverActor,
status
}
await Promise.all([
populateServerBlocklistStatus({ ...baseOptions, hosts }),
populateAccountBlocklistStatus({ ...baseOptions, accounts })
])
return res.json(status)
}
async function populateServerBlocklistStatus (options: {
byAccountIds: number[]
user?: MUserAccountId
serverActor: MActorAccountId
hosts: string[]
status: BlockStatus
}) {
const { byAccountIds, user, serverActor, hosts, status } = options
if (!hosts || hosts.length === 0) return
const serverBlocklistStatus = await ServerBlocklistModel.getBlockStatus(byAccountIds, hosts)
logger.debug('Got server blocklist status.', { serverBlocklistStatus, byAccountIds, hosts })
for (const host of hosts) {
const block = serverBlocklistStatus.find(b => b.host === host)
status.hosts[host] = getStatus(block, serverActor, user)
}
}
async function populateAccountBlocklistStatus (options: {
byAccountIds: number[]
user?: MUserAccountId
serverActor: MActorAccountId
accounts: string[]
status: BlockStatus
}) {
const { byAccountIds, user, serverActor, accounts, status } = options
if (!accounts || accounts.length === 0) return
const accountBlocklistStatus = await AccountBlocklistModel.getBlockStatus(byAccountIds, accounts)
logger.debug('Got account blocklist status.', { accountBlocklistStatus, byAccountIds, accounts })
for (const account of accounts) {
const sanitizedHandle = handleToNameAndHost(account)
const block = accountBlocklistStatus.find(b => b.name === sanitizedHandle.name && b.host === sanitizedHandle.host)
status.accounts[sanitizedHandle.handle] = getStatus(block, serverActor, user)
}
}
function getStatus (block: { accountId: number }, serverActor: MActorAccountId, user?: MUserAccountId) {
return {
blockedByServer: !!(block && block.accountId === serverActor.Account.id),
blockedByUser: !!(block && user && block.accountId === user.Account.id)
}
}

View File

@@ -0,0 +1,39 @@
import express from 'express'
import { BulkRemoveCommentsOfBody, HttpStatusCode } from '@peertube/peertube-models'
import { removeComment } from '@server/lib/video-comment.js'
import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk.js'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import { apiRateLimiter, asyncMiddleware, authenticate } from '../../middlewares/index.js'
const bulkRouter = express.Router()
bulkRouter.use(apiRateLimiter)
bulkRouter.post('/remove-comments-of', authenticate, asyncMiddleware(bulkRemoveCommentsOfValidator), asyncMiddleware(bulkRemoveCommentsOf))
// ---------------------------------------------------------------------------
export {
bulkRouter
}
// ---------------------------------------------------------------------------
async function bulkRemoveCommentsOf (req: express.Request, res: express.Response) {
const account = res.locals.account
const body = req.body as BulkRemoveCommentsOfBody
const user = res.locals.oauth.token.User
const filter = body.scope === 'my-videos' || body.scope === 'my-videos-and-collaborations'
? { onVideosOfAccount: user.Account, includeCollaborations: body.scope === 'my-videos-and-collaborations' }
: {}
const comments = await VideoCommentModel.listForBulkDelete(account, filter)
// Don't wait result
res.status(HttpStatusCode.NO_CONTENT_204).end()
for (const comment of comments) {
await removeComment(comment, req, res)
}
}

View File

@@ -0,0 +1,36 @@
import { is18nLocale } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { setClientLanguageCookie } from '@server/helpers/i18n.js'
import express from 'express'
import { apiRateLimiter } from '../../middlewares/index.js'
const clientConfigRouter = express.Router()
clientConfigRouter.use(apiRateLimiter)
clientConfigRouter.post(
'/update-interface-language',
updateLanguage
)
// ---------------------------------------------------------------------------
export {
clientConfigRouter
}
// ---------------------------------------------------------------------------
function updateLanguage (req: express.Request, res: express.Response) {
const language = req.body.language
if (language !== null && !is18nLocale(language)) {
return res.fail({
message: req.t('{language} is not a valid language', { language })
})
}
setClientLanguageCookie(res, language)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,646 @@
import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, LogoType, UserRight } from '@peertube/peertube-models'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { deleteUploadImages, logoTypeToUploadImageEnum, replaceUploadImage } from '@server/lib/upload-image.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { getServerActor } from '@server/models/application/application.js'
import { ModelCache } from '@server/models/shared/model-cache.js'
import express from 'express'
import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator'
import { CustomConfigAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js'
import { ClientHtml } from '../../lib/html/client-html.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
openapiOperationDoc,
updateAvatarValidator,
updateBannerValidator
} from '../../middlewares/index.js'
import {
customConfigUpdateValidator,
ensureConfigIsEditable,
updateInstanceLogoValidator,
updateOrDeleteLogoValidator
} from '../../middlewares/validators/config.js'
const configRouter = express.Router()
configRouter.use(apiRateLimiter)
const auditLogger = auditLoggerFactory('config')
configRouter.get('/', openapiOperationDoc({ operationId: 'getConfig' }), asyncMiddleware(getConfig))
configRouter.get('/about', openapiOperationDoc({ operationId: 'getAbout' }), asyncMiddleware(getAbout))
configRouter.get(
'/custom',
openapiOperationDoc({ operationId: 'getCustomConfig' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
getCustomConfig
)
configRouter.put(
'/custom',
openapiOperationDoc({ operationId: 'putCustomConfig' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
ensureConfigIsEditable,
customConfigUpdateValidator,
asyncMiddleware(updateCustomConfig)
)
configRouter.delete(
'/custom',
openapiOperationDoc({ operationId: 'delCustomConfig' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
ensureConfigIsEditable,
asyncMiddleware(deleteCustomConfig)
)
// ---------------------------------------------------------------------------
configRouter.post(
'/instance-banner/pick',
authenticate,
createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateBannerValidator,
asyncMiddleware(updateInstanceImageFactory(ActorImageType.BANNER))
)
configRouter.delete(
'/instance-banner',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceImageFactory(ActorImageType.BANNER))
)
// ---------------------------------------------------------------------------
configRouter.post(
'/instance-avatar/pick',
authenticate,
createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateAvatarValidator,
asyncMiddleware(updateInstanceImageFactory(ActorImageType.AVATAR))
)
configRouter.delete(
'/instance-avatar',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceImageFactory(ActorImageType.AVATAR))
)
// ---------------------------------------------------------------------------
configRouter.post(
'/instance-logo/:logoType/pick',
authenticate,
createReqFiles([ 'logofile' ], MIMETYPES.LOGO_IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateOrDeleteLogoValidator,
updateInstanceLogoValidator,
asyncMiddleware(updateInstanceLogo)
)
configRouter.delete(
'/instance-logo/:logoType',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateOrDeleteLogoValidator,
asyncMiddleware(deleteInstanceLogo)
)
// ---------------------------------------------------------------------------
async function getConfig (req: express.Request, res: express.Response) {
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
return res.json(json)
}
async function getAbout (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const about: About = {
instance: {
name: CONFIG.INSTANCE.NAME,
shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
description: CONFIG.INSTANCE.DESCRIPTION,
terms: CONFIG.INSTANCE.TERMS,
codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,
hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,
creationReason: CONFIG.INSTANCE.CREATION_REASON,
moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
administrator: CONFIG.INSTANCE.ADMINISTRATOR,
maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
languages: CONFIG.INSTANCE.LANGUAGES,
categories: CONFIG.INSTANCE.CATEGORIES,
banners: serverActor.Banners.map(b => b.toFormattedJSON()),
avatars: serverActor.Avatars.map(a => a.toFormattedJSON())
}
}
return res.json(about)
}
function getCustomConfig (req: express.Request, res: express.Response) {
const data = customConfig()
return res.json(data)
}
async function deleteCustomConfig (req: express.Request, res: express.Response) {
await remove(CONFIG.CUSTOM_FILE)
auditLogger.delete(getAuditIdFromRes(res), new CustomConfigAuditView(customConfig()))
await reloadConfig()
ClientHtml.invalidateCache()
const data = customConfig()
return res.json(data)
}
async function updateCustomConfig (req: express.Request, res: express.Response) {
const oldCustomConfigAuditKeys = new CustomConfigAuditView(customConfig())
// camelCase to snake_case key + Force number conversion
const toUpdateJSON = convertCustomConfigBody(req.body)
await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
await reloadConfig()
ClientHtml.invalidateCache()
const data = customConfig()
auditLogger.update(
getAuditIdFromRes(res),
new CustomConfigAuditView(data),
oldCustomConfigAuditKeys
)
return res.json(data)
}
// ---------------------------------------------------------------------------
function updateInstanceImageFactory (imageType: ActorImageType_Type) {
return async (req: express.Request, res: express.Response) => {
const field = imageType === ActorImageType.BANNER
? 'bannerfile'
: 'avatarfile'
const imagePhysicalFile = req.files[field][0]
const serverActor = await getServerActor()
await updateLocalActorImageFiles({
accountOrChannel: serverActor.Account,
imagePhysicalFile,
type: imageType,
sendActorUpdate: false
})
await updateServerActorImages(imageType)
ClientHtml.invalidateCache()
ModelCache.Instance.clearCache('server-account')
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
}
function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
return async (req: express.Request, res: express.Response) => {
const serverActor = await getServerActor()
await deleteLocalActorImageFile(serverActor.Account, imageType)
await updateServerActorImages(imageType)
ClientHtml.invalidateCache()
ModelCache.Instance.clearCache('server-account')
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
}
async function updateServerActorImages (imageType: ActorImageType_Type) {
const serverActor = await getServerActor()
const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB
if (imageType === ActorImageType.BANNER) serverActor.Banners = updatedImages
else serverActor.Avatars = updatedImages
return serverActor
}
// ---------------------------------------------------------------------------
async function updateInstanceLogo (req: express.Request, res: express.Response) {
const imagePhysicalFile = req.files['logofile'][0]
await replaceUploadImage({
actor: await getServerActor(),
imagePhysicalFile,
type: logoTypeToUploadImageEnum(req.params.logoType as LogoType)
})
ClientHtml.invalidateCache()
ModelCache.Instance.clearCache('server-account')
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteInstanceLogo (req: express.Request, res: express.Response) {
await deleteUploadImages({
actor: await getServerActor(),
type: logoTypeToUploadImageEnum(req.params.logoType as LogoType)
})
ClientHtml.invalidateCache()
ModelCache.Instance.clearCache('server-account')
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
export {
configRouter
}
// ---------------------------------------------------------------------------
function customConfig (): CustomConfig {
return {
instance: {
name: CONFIG.INSTANCE.NAME,
shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
description: CONFIG.INSTANCE.DESCRIPTION,
terms: CONFIG.INSTANCE.TERMS,
codeOfConduct: CONFIG.INSTANCE.CODE_OF_CONDUCT,
creationReason: CONFIG.INSTANCE.CREATION_REASON,
moderationInformation: CONFIG.INSTANCE.MODERATION_INFORMATION,
administrator: CONFIG.INSTANCE.ADMINISTRATOR,
maintenanceLifetime: CONFIG.INSTANCE.MAINTENANCE_LIFETIME,
businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
hardwareInformation: CONFIG.INSTANCE.HARDWARE_INFORMATION,
defaultLanguage: CONFIG.INSTANCE.DEFAULT_LANGUAGE,
languages: CONFIG.INSTANCE.LANGUAGES,
categories: CONFIG.INSTANCE.CATEGORIES,
isNSFW: CONFIG.INSTANCE.IS_NSFW,
defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
serverCountry: CONFIG.INSTANCE.SERVER_COUNTRY,
support: {
text: CONFIG.INSTANCE.SUPPORT.TEXT
},
social: {
blueskyLink: CONFIG.INSTANCE.SOCIAL.BLUESKY,
mastodonLink: CONFIG.INSTANCE.SOCIAL.MASTODON_LINK,
xLink: CONFIG.INSTANCE.SOCIAL.X_LINK,
externalLink: CONFIG.INSTANCE.SOCIAL.EXTERNAL_LINK
},
defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
customizations: {
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS,
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
}
},
theme: {
default: CONFIG.THEME.DEFAULT,
customization: {
primaryColor: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR,
onPrimaryColor: CONFIG.THEME.CUSTOMIZATION.ON_PRIMARY_COLOR,
foregroundColor: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR,
backgroundColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR,
backgroundSecondaryColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_SECONDARY_COLOR,
menuForegroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_FOREGROUND_COLOR,
menuBackgroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_BACKGROUND_COLOR,
menuBorderRadius: CONFIG.THEME.CUSTOMIZATION.MENU_BORDER_RADIUS,
headerForegroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_FOREGROUND_COLOR,
headerBackgroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_BACKGROUND_COLOR,
inputBorderRadius: CONFIG.THEME.CUSTOMIZATION.INPUT_BORDER_RADIUS
}
},
services: {
twitter: {
username: CONFIG.SERVICES.TWITTER.USERNAME
}
},
client: {
header: {
hideInstanceName: CONFIG.CLIENT.HEADER.HIDE_INSTANCE_NAME
},
videos: {
miniature: {
preferAuthorDisplayName: CONFIG.CLIENT.VIDEOS.MINIATURE.PREFER_AUTHOR_DISPLAY_NAME
}
},
browseVideos: {
defaultSort: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SORT,
defaultScope: CONFIG.CLIENT.BROWSE_VIDEOS.DEFAULT_SCOPE
},
menu: {
login: {
redirectOnSingleExternalAuth: CONFIG.CLIENT.MENU.LOGIN.REDIRECT_ON_SINGLE_EXTERNAL_AUTH
}
}
},
cache: {
previews: {
size: CONFIG.CACHE.PREVIEWS.SIZE
},
captions: {
size: CONFIG.CACHE.VIDEO_CAPTIONS.SIZE
},
torrents: {
size: CONFIG.CACHE.TORRENTS.SIZE
},
storyboards: {
size: CONFIG.CACHE.STORYBOARDS.SIZE
}
},
signup: {
enabled: CONFIG.SIGNUP.ENABLED,
limit: CONFIG.SIGNUP.LIMIT,
requiresApproval: CONFIG.SIGNUP.REQUIRES_APPROVAL,
requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
},
admin: {
email: CONFIG.ADMIN.EMAIL
},
contactForm: {
enabled: CONFIG.CONTACT_FORM.ENABLED
},
user: {
history: {
videos: {
enabled: CONFIG.USER.HISTORY.VIDEOS.ENABLED
}
},
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY,
defaultChannelName: CONFIG.USER.DEFAULT_CHANNEL_NAME
},
videoChannels: {
maxPerUser: CONFIG.VIDEO_CHANNELS.MAX_PER_USER
},
transcoding: {
enabled: CONFIG.TRANSCODING.ENABLED,
originalFile: {
keep: CONFIG.TRANSCODING.ORIGINAL_FILE.KEEP
},
remoteRunners: {
enabled: CONFIG.TRANSCODING.REMOTE_RUNNERS.ENABLED
},
allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
threads: CONFIG.TRANSCODING.THREADS,
concurrency: CONFIG.TRANSCODING.CONCURRENCY,
profile: CONFIG.TRANSCODING.PROFILE,
resolutions: {
'0p': CONFIG.TRANSCODING.RESOLUTIONS['0p'],
'144p': CONFIG.TRANSCODING.RESOLUTIONS['144p'],
'240p': CONFIG.TRANSCODING.RESOLUTIONS['240p'],
'360p': CONFIG.TRANSCODING.RESOLUTIONS['360p'],
'480p': CONFIG.TRANSCODING.RESOLUTIONS['480p'],
'720p': CONFIG.TRANSCODING.RESOLUTIONS['720p'],
'1080p': CONFIG.TRANSCODING.RESOLUTIONS['1080p'],
'1440p': CONFIG.TRANSCODING.RESOLUTIONS['1440p'],
'2160p': CONFIG.TRANSCODING.RESOLUTIONS['2160p']
},
alwaysTranscodeOriginalResolution: CONFIG.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
fps: {
max: CONFIG.TRANSCODING.FPS.MAX
},
webVideos: {
enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
},
hls: {
enabled: CONFIG.TRANSCODING.HLS.ENABLED,
splitAudioAndVideo: CONFIG.TRANSCODING.HLS.SPLIT_AUDIO_AND_VIDEO
}
},
live: {
enabled: CONFIG.LIVE.ENABLED,
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
latencySetting: {
enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
},
maxDuration: CONFIG.LIVE.MAX_DURATION,
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
remoteRunners: {
enabled: CONFIG.LIVE.TRANSCODING.REMOTE_RUNNERS.ENABLED
},
threads: CONFIG.LIVE.TRANSCODING.THREADS,
profile: CONFIG.LIVE.TRANSCODING.PROFILE,
resolutions: {
'0p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['0p'],
'144p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['144p'],
'240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
'360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
'480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'],
'720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'],
'1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
'1440p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1440p'],
'2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
},
alwaysTranscodeOriginalResolution: CONFIG.LIVE.TRANSCODING.ALWAYS_TRANSCODE_ORIGINAL_RESOLUTION,
fps: {
max: CONFIG.LIVE.TRANSCODING.FPS.MAX
}
}
},
videoStudio: {
enabled: CONFIG.VIDEO_STUDIO.ENABLED,
remoteRunners: {
enabled: CONFIG.VIDEO_STUDIO.REMOTE_RUNNERS.ENABLED
}
},
videoTranscription: {
enabled: CONFIG.VIDEO_TRANSCRIPTION.ENABLED,
remoteRunners: {
enabled: CONFIG.VIDEO_TRANSCRIPTION.REMOTE_RUNNERS.ENABLED
}
},
videoFile: {
update: {
enabled: CONFIG.VIDEO_FILE.UPDATE.ENABLED
}
},
import: {
videos: {
concurrency: CONFIG.IMPORT.VIDEOS.CONCURRENCY,
http: {
enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
},
torrent: {
enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
}
},
videoChannelSynchronization: {
enabled: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED,
maxPerUser: CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER
},
users: {
enabled: CONFIG.IMPORT.USERS.ENABLED
}
},
export: {
users: {
enabled: CONFIG.EXPORT.USERS.ENABLED,
exportExpiration: CONFIG.EXPORT.USERS.EXPORT_EXPIRATION,
maxUserVideoQuota: CONFIG.EXPORT.USERS.MAX_USER_VIDEO_QUOTA
}
},
trending: {
videos: {
algorithms: {
enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
}
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
}
}
},
followers: {
instance: {
enabled: CONFIG.FOLLOWERS.INSTANCE.ENABLED,
manualApproval: CONFIG.FOLLOWERS.INSTANCE.MANUAL_APPROVAL
},
channels: {
enabled: CONFIG.FOLLOWERS.CHANNELS.ENABLED
}
},
followings: {
instance: {
autoFollowBack: {
enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED
},
autoFollowIndex: {
enabled: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.ENABLED,
indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
}
}
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
level: CONFIG.BROADCAST_MESSAGE.LEVEL,
dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
},
search: {
remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
},
searchIndex: {
enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
url: CONFIG.SEARCH.SEARCH_INDEX.URL,
disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
}
},
storyboards: {
enabled: CONFIG.STORYBOARDS.ENABLED,
remoteRunners: {
enabled: CONFIG.STORYBOARDS.REMOTE_RUNNERS.ENABLED
}
},
defaults: {
publish: {
downloadEnabled: CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
commentsPolicy: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY,
privacy: CONFIG.DEFAULTS.PUBLISH.PRIVACY,
licence: CONFIG.DEFAULTS.PUBLISH.LICENCE
},
p2p: {
webapp: {
enabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED
},
embed: {
enabled: CONFIG.DEFAULTS.P2P.EMBED.ENABLED
}
},
player: {
theme: CONFIG.DEFAULTS.PLAYER.THEME,
autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY
}
},
email: {
body: {
signature: CONFIG.EMAIL.BODY.SIGNATURE
},
subject: {
prefix: CONFIG.EMAIL.SUBJECT.PREFIX
}
},
videoComments: {
acceptRemoteComments: CONFIG.VIDEO_COMMENTS.ACCEPT_REMOTE_COMMENTS
}
}
}
function convertCustomConfigBody (body: CustomConfig) {
function keyConverter (k: string) {
// Transcoding resolutions exception
if (/^\d{3,4}p$/.exec(k)) return k
if (k === '0p') return k
if (k === 'p2p') return k
return snakeCase(k)
}
function valueConverter (v: any) {
if (validator.isNumeric(v + '')) return parseInt('' + v, 10)
return v
}
return objectConverter(body, keyConverter, valueConverter)
}

View File

@@ -0,0 +1,48 @@
import express from 'express'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page.js'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares/index.js'
const customPageRouter = express.Router()
customPageRouter.use(apiRateLimiter)
customPageRouter.get('/homepage/instance',
asyncMiddleware(getInstanceHomepage)
)
customPageRouter.put('/homepage/instance',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE),
asyncMiddleware(updateInstanceHomepage)
)
// ---------------------------------------------------------------------------
export {
customPageRouter
}
// ---------------------------------------------------------------------------
async function getInstanceHomepage (req: express.Request, res: express.Response) {
const page = await ActorCustomPageModel.loadInstanceHomepage()
if (!page) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Instance homepage could not be found'
})
}
return res.json(page.toFormattedJSON())
}
async function updateInstanceHomepage (req: express.Request, res: express.Response) {
const content = req.body.content
await ActorCustomPageModel.updateInstanceHomepage(content)
ServerConfigManager.Instance.updateHomepageState(content)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,537 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, EventAuditView } from '@server/helpers/audit-logger.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { createHash } from 'crypto'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
paginationValidator,
setDefaultPagination
} from '@server/middlewares/index.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import {
createEventValidator,
updateEventValidator,
deleteEventValidator,
updateEventStateValidator,
getEventValidator
} from '@server/middlewares/validators/events.js'
import {
createEventRegistrationValidator,
deleteEventRegistrationValidator,
getEventRegistrationValidator,
listEventRegistrationsValidator,
updateEventRegistrationValidator
} from '@server/middlewares/validators/event-registrations.js'
import { EventRegistrationModel } from '@server/models/video/event-registration.js'
import { EventModel, EventState } from '@server/models/video/event.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
import { CONFIG } from '@server/initializers/config.js'
import { processImageFromWorker } from '@server/lib/worker/parent-process.js'
import { join } from 'path'
import { ensureDir, remove } from 'fs-extra'
import { readFile } from 'fs/promises'
import { generateImageFilename } from '@server/helpers/image-utils.js'
const auditLogger = auditLoggerFactory('events')
const eventsRouter = express.Router()
const SPECIAL_THUMBNAILS_TMP_DIR = join(CONFIG.STORAGE.TMP_DIR, 'special-thumbnails')
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
eventsRouter.use(apiRateLimiter)
// List public events
eventsRouter.get(
'/',
openapiOperationDoc({ operationId: 'getEvents' }),
paginationValidator,
setDefaultPagination,
asyncMiddleware(listPublicEvents)
)
eventsRouter.get(
'/registrations',
openapiOperationDoc({ operationId: 'getAllEventRegistrations' }),
authenticate,
paginationValidator,
setDefaultPagination,
asyncMiddleware(listAllEventRegistrations)
)
// Get event by ID (public)
eventsRouter.get(
'/:id',
openapiOperationDoc({ operationId: 'getEvent' }),
getEventValidator,
asyncMiddleware(getEvent)
)
eventsRouter.get(
'/:eventId/registrations',
openapiOperationDoc({ operationId: 'getEventRegistrations' }),
authenticate,
paginationValidator,
setDefaultPagination,
listEventRegistrationsValidator,
asyncMiddleware(listEventRegistrations)
)
eventsRouter.get(
'/:eventId/registrations/:registrationId',
openapiOperationDoc({ operationId: 'getEventRegistration' }),
authenticate,
getEventRegistrationValidator,
asyncMiddleware(getEventRegistration)
)
eventsRouter.post(
'/:eventId/registrations',
openapiOperationDoc({ operationId: 'createEventRegistration' }),
createEventRegistrationValidator,
asyncMiddleware(createEventRegistration)
)
eventsRouter.put(
'/:eventId/registrations/:registrationId',
openapiOperationDoc({ operationId: 'updateEventRegistration' }),
authenticate,
updateEventRegistrationValidator,
asyncMiddleware(updateEventRegistration)
)
eventsRouter.delete(
'/:eventId/registrations/:registrationId',
openapiOperationDoc({ operationId: 'deleteEventRegistration' }),
authenticate,
deleteEventRegistrationValidator,
asyncMiddleware(deleteEventRegistration)
)
// Create event (admin/channel owner only)
eventsRouter.post(
'/:channelId/events',
openapiOperationDoc({ operationId: 'createEvent' }),
authenticate,
reqThumbnailFile,
createEventValidator,
asyncMiddleware(createEvent)
)
// Update event (admin/channel owner only)
eventsRouter.put(
'/:channelId/events/:eventId',
openapiOperationDoc({ operationId: 'updateEvent' }),
authenticate,
reqThumbnailFile,
updateEventValidator,
asyncMiddleware(updateEvent)
)
// Delete event (admin/channel owner only)
eventsRouter.delete(
'/:channelId/events/:eventId',
openapiOperationDoc({ operationId: 'deleteEvent' }),
authenticate,
deleteEventValidator,
asyncMiddleware(deleteEvent)
)
// Change event state (admin/channel owner only)
eventsRouter.patch(
'/:channelId/events/:eventId/state',
openapiOperationDoc({ operationId: 'updateEventState' }),
authenticate,
updateEventStateValidator,
asyncMiddleware(updateEventState)
)
// List channel events (admin/channel owner only)
eventsRouter.get(
'/:channelId/events',
openapiOperationDoc({ operationId: 'getChannelEvents' }),
authenticate,
asyncMiddleware(listChannelEvents)
)
async function listPublicEvents (req: express.Request, res: express.Response) {
const state = req.query.state as string | undefined
const states = state ? state.split(',').filter(s => s) : undefined
const events = await EventModel.listPublicEvents(undefined, states)
const count = events.length
return res.json(getFormattedObjects(events, count))
}
async function getEvent (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
return res.json(event.toFormattedJSON())
}
async function listEventRegistrations (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
if (!isEventOwnerOrAdmin(res, event)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this event'
})
}
const { count, rows } = await EventRegistrationModel.listForApi({
eventId: event.id,
start: req.query.start as number,
count: req.query.count as number
})
return res.json(getFormattedObjects(rows, count))
}
async function listAllEventRegistrations (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
// Only admin allowed (or extend if needed)
if (user.role !== 0) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admin can view all event registrations'
})
}
const { count, rows } = await EventRegistrationModel.findAndCountAll({
include: [
{
model: EventModel,
attributes: ['id', 'title', 'startTime', 'endTime', 'thumbnailFilename']
}
],
offset: req.query.start as number,
limit: req.query.count as number,
order: [['createdAt', 'DESC']]
})
const data = rows.map((row: any) => {
const json = row.toJSON()
return {
...json,
event: json.Event
? {
id: json.Event.id,
title: json.Event.title,
startTime: json.Event.startTime,
endTime: json.Event.endTime,
thumbnailFilename: json.Event.thumbnailFilename
}
: null
}
})
return res.json({
total: count,
data
})
}
async function getEventRegistration (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
const registration = (res.locals as any).eventRegistration
if (!isEventOwnerOrAdmin(res, event)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this event'
})
}
return res.json(registration.toFormattedJSON())
}
async function listChannelEvents (req: express.Request, res: express.Response) {
const { channelId } = req.params
const channel = await VideoChannelModel.scope([ 'WITH_ACCOUNT' ]).findByPk(channelId)
if (!channel) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Channel not found'
})
}
// Check if user is channel owner or admin
const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id
if (!isChannelOwner && res.locals.oauth.token.User.role !== 0) { // 0 = admin
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this channel'
})
}
const events = await EventModel.listByChannelId(parseInt(channelId))
const count = events.length
return res.json(getFormattedObjects(events, count))
}
async function createEvent (req: express.Request, res: express.Response) {
const { channelId } = req.params
const { title, description, startTime, endTime, state, liveUrl } = req.body
const channel = (res.locals as any).channel
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined
const thumbnailFile = files?.thumbnailfile?.[0]
// Check if user is channel owner or admin
const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id
if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this channel'
})
}
const event = await sequelizeTypescript.transaction(async t => {
let thumbnail: { filename: string, data: Buffer, size: number, mimeType: string, etag: string } | null = null
if (thumbnailFile) {
thumbnail = await saveEventThumbnailToTemporaryFile(thumbnailFile.path)
}
const created = await EventModel.create(
{
title,
description: description || null,
startTime: new Date(startTime),
endTime: new Date(endTime),
state: state || EventState.ACTIVE,
liveUrl: liveUrl || null,
channelId: parseInt(channelId),
thumbnailFilename: thumbnail?.filename || null,
thumbnailData: thumbnail?.data || null,
thumbnailMimeType: thumbnail?.mimeType || null,
thumbnailSize: thumbnail?.size || null,
thumbnailEtag: thumbnail?.etag || null
},
{ transaction: t }
)
return created
})
auditLogger.create(getAuditIdFromRes(res), new EventAuditView(event))
return res.status(HttpStatusCode.CREATED_201).json(event.toFormattedJSON())
}
async function updateEvent (req: express.Request, res: express.Response) {
const { title, description, startTime, endTime, state, liveUrl } = req.body
const event = (res.locals as any).event
const channel = (res.locals as any).channel
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined
const thumbnailFile = files?.thumbnailfile?.[0]
const oldEventAuditView = new EventAuditView(event.toFormattedJSON())
// Check if user is channel owner or admin
const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id
if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this channel'
})
}
const updatedEvent = await sequelizeTypescript.transaction(async t => {
let thumbnailFilename = event.thumbnailFilename
if (thumbnailFile) {
const thumbnail = await saveEventThumbnailToTemporaryFile(thumbnailFile.path)
thumbnailFilename = thumbnail.filename
Object.assign(event, {
thumbnailData: thumbnail.data,
thumbnailMimeType: thumbnail.mimeType,
thumbnailSize: thumbnail.size,
thumbnailEtag: thumbnail.etag
})
}
await event.update(
{
title: title || event.title,
description: description !== undefined ? description : event.description,
startTime: startTime ? new Date(startTime) : event.startTime,
endTime: endTime ? new Date(endTime) : event.endTime,
state: state || event.state,
liveUrl: liveUrl !== undefined ? liveUrl : event.liveUrl,
thumbnailFilename,
thumbnailData: event.thumbnailData,
thumbnailMimeType: event.thumbnailMimeType,
thumbnailSize: event.thumbnailSize,
thumbnailEtag: event.thumbnailEtag
},
{ transaction: t }
)
return event
})
auditLogger.update(getAuditIdFromRes(res), new EventAuditView(updatedEvent.toFormattedJSON()), oldEventAuditView)
return res.json(updatedEvent.toFormattedJSON())
}
async function deleteEvent (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
const channel = (res.locals as any).channel
// Check if user is channel owner or admin
const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id
if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this channel'
})
}
await sequelizeTypescript.transaction(async t => {
await event.destroy({ transaction: t })
})
auditLogger.delete(getAuditIdFromRes(res), new EventAuditView(event.toFormattedJSON()))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function saveEventThumbnailToTemporaryFile (sourcePath: string) {
const thumbnailFilename = generateImageFilename('.jpg')
const destination = join(SPECIAL_THUMBNAILS_TMP_DIR, thumbnailFilename)
await ensureDir(SPECIAL_THUMBNAILS_TMP_DIR)
await processImageFromWorker({
path: sourcePath,
destination,
newSize: THUMBNAILS_SIZE,
keepOriginal: true
})
const data = await readFile(destination)
await remove(destination).catch(() => undefined)
return {
filename: thumbnailFilename,
data,
size: data.byteLength,
mimeType: 'image/jpeg',
etag: createHash('sha256').update(data).digest('hex')
}
}
async function updateEventState (req: express.Request, res: express.Response) {
const { state } = req.body
const event = (res.locals as any).event
const channel = (res.locals as any).channel
const oldEventAuditView = new EventAuditView(event.toFormattedJSON())
// Check if user is channel owner or admin
const isChannelOwner = channel.Account.userId === (res.locals as any).oauth.token.User.id
if (!isChannelOwner && (res.locals as any).oauth.token.User.role !== 0) { // 0 = admin
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to manage this channel'
})
}
await sequelizeTypescript.transaction(async t => {
await event.update({ state }, { transaction: t })
})
auditLogger.update(getAuditIdFromRes(res), new EventAuditView(event.toFormattedJSON()), oldEventAuditView)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function createEventRegistration (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
const registration = await sequelizeTypescript.transaction(async t => {
return EventRegistrationModel.create({
firstName: req.body.firstName,
lastName: req.body.lastName,
email: req.body.email,
phone: req.body.phone,
companyName: req.body.companyName,
jobTitle: req.body.jobTitle,
country: req.body.country,
eventId: event.id
}, {
transaction: t
})
})
return res.status(HttpStatusCode.CREATED_201).json(registration.toFormattedJSON())
}
async function updateEventRegistration (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
const registration = (res.locals as any).eventRegistration as EventRegistrationModel
if (!canManageRegistration(res, event, registration)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to update this registration'
})
}
await sequelizeTypescript.transaction(async t => {
await registration.update({
firstName: req.body.firstName ?? registration.firstName,
lastName: req.body.lastName ?? registration.lastName,
email: req.body.email ?? registration.email,
phone: req.body.phone ?? registration.phone,
companyName: req.body.companyName ?? registration.companyName,
jobTitle: req.body.jobTitle ?? registration.jobTitle,
country: req.body.country ?? registration.country
}, {
transaction: t
})
})
return res.json(registration.toFormattedJSON())
}
async function deleteEventRegistration (req: express.Request, res: express.Response) {
const event = (res.locals as any).event
const registration = (res.locals as any).eventRegistration as EventRegistrationModel
if (!canManageRegistration(res, event, registration)) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'User is not allowed to delete this registration'
})
}
await sequelizeTypescript.transaction(async t => {
await registration.destroy({ transaction: t })
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
function isEventOwnerOrAdmin (res: express.Response, event: EventModel) {
const user = res.locals.oauth.token.User
const isEventOwner = event.Channel?.Account?.userId === user.id
return isEventOwner || user.role === 0
}
function canManageRegistration (res: express.Response, event: EventModel, registration: EventRegistrationModel) {
return isEventOwnerOrAdmin(res, event)
}
export { eventsRouter }

View File

@@ -0,0 +1,86 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import cors from 'cors'
import express from 'express'
import { abuseRouter } from './abuse.js'
import { accountsRouter } from './accounts.js'
import { automaticTagRouter } from './automatic-tags.js'
import { blocklistRouter } from './blocklist.js'
import { bulkRouter } from './bulk.js'
import { clientConfigRouter } from './client-config.js'
import { configRouter } from './config.js'
import { customPageRouter } from './custom-page.js'
import { eventsRouter } from './events.js'
import { jobsRouter } from './jobs.js'
import { liveStreamRouter } from './live-stream.js'
import { liveVideosRouter } from './live-videos.js'
import { metricsRouter } from './metrics.js'
import { oauthClientsRouter } from './oauth-clients.js'
import { overviewsRouter } from './overviews.js'
import { playerSettingsRouter } from './player-settings.js'
import { pluginRouter } from './plugins.js'
import { runnersRouter } from './runners/index.js'
import { searchRouter } from './search/index.js'
import { serverRouter } from './server/index.js'
import { usersRouter } from './users/index.js'
import { videoChannelSyncRouter } from './video-channel-sync.js'
import { videoChannelRouter } from './video-channels/index.js'
import { videoPlaylistRouter } from './video-playlist.js'
import { videosRouter } from './videos/index.js'
import { watchedWordsRouter } from './watched-words.js'
const apiRouter = express.Router()
apiRouter.use(cors({
origin: '*',
exposedHeaders: 'Retry-After',
credentials: true
}))
apiRouter.use('/server', serverRouter)
apiRouter.use('/abuses', abuseRouter)
apiRouter.use('/bulk', bulkRouter)
apiRouter.use('/oauth-clients', oauthClientsRouter)
apiRouter.use('/config', configRouter)
apiRouter.use('/users', usersRouter)
apiRouter.use('/accounts', accountsRouter)
apiRouter.use('/video-channels', videoChannelRouter)
apiRouter.use('/video-channel-syncs', videoChannelSyncRouter)
apiRouter.use('/video-playlists', videoPlaylistRouter)
apiRouter.use('/videos', videosRouter)
apiRouter.use('/jobs', jobsRouter)
apiRouter.use('/metrics', metricsRouter)
apiRouter.use('/search', searchRouter)
apiRouter.use('/overviews', overviewsRouter)
apiRouter.use('/player-settings', playerSettingsRouter)
apiRouter.use('/plugins', pluginRouter)
apiRouter.use('/custom-pages', customPageRouter)
apiRouter.use('/blocklist', blocklistRouter)
apiRouter.use('/runners', runnersRouter)
apiRouter.use('/watched-words', watchedWordsRouter)
apiRouter.use('/automatic-tags', automaticTagRouter)
apiRouter.use('/client-config', clientConfigRouter)
apiRouter.use('/events', eventsRouter)
apiRouter.use('/live', liveStreamRouter)
apiRouter.use('/live-videos', liveVideosRouter)
apiRouter.use('/ping', pong)
apiRouter.use('/*', badRequest)
// ---------------------------------------------------------------------------
export { apiRouter }
// ---------------------------------------------------------------------------
function pong (req: express.Request, res: express.Response) {
return res.send('pong').status(HttpStatusCode.OK_200).end()
}
function badRequest (req: express.Request, res: express.Response) {
logger.debug(`API express handler not found: bad PeerTube request for ${req.method} - ${req.originalUrl}`)
return res.type('json')
.status(HttpStatusCode.BAD_REQUEST_400)
.end()
}

View File

@@ -0,0 +1,112 @@
import { HttpStatusCode, Job, JobState, JobType, ResultList, UserRight } from '@peertube/peertube-models'
import { Job as BullJob } from 'bullmq'
import express from 'express'
import { isArray } from '../../helpers/custom-validators/misc.js'
import { JobQueue } from '../../lib/job-queue/index.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
jobsSortValidator,
openapiOperationDoc,
paginationValidatorBuilder,
setDefaultPagination,
setDefaultSort
} from '../../middlewares/index.js'
import { listJobsValidator } from '../../middlewares/validators/jobs.js'
const jobsRouter = express.Router()
jobsRouter.use(apiRateLimiter)
jobsRouter.post('/pause',
authenticate,
ensureUserHasRight(UserRight.MANAGE_JOBS),
asyncMiddleware(pauseJobQueue)
)
jobsRouter.post('/resume',
authenticate,
ensureUserHasRight(UserRight.MANAGE_JOBS),
resumeJobQueue
)
jobsRouter.get('/:state?',
openapiOperationDoc({ operationId: 'getJobs' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_JOBS),
paginationValidatorBuilder([ 'jobs' ]),
jobsSortValidator,
setDefaultSort,
setDefaultPagination,
listJobsValidator,
asyncMiddleware(listJobs)
)
// ---------------------------------------------------------------------------
export {
jobsRouter
}
// ---------------------------------------------------------------------------
async function pauseJobQueue (req: express.Request, res: express.Response) {
await JobQueue.Instance.pause()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
function resumeJobQueue (req: express.Request, res: express.Response) {
JobQueue.Instance.resume()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listJobs (req: express.Request, res: express.Response) {
const state = req.params.state as JobState
const asc = req.query.sort === 'createdAt'
const jobType = req.query.jobType
const jobs = await JobQueue.Instance.listForApi({
state,
start: req.query.start,
count: req.query.count,
asc,
jobType
})
const total = await JobQueue.Instance.count(state, jobType)
const result: ResultList<Job> = {
total,
data: await Promise.all(jobs.map(j => formatJob(j, state)))
}
return res.json(result)
}
async function formatJob (job: BullJob, state?: JobState): Promise<Job> {
return {
id: job.id,
state: state || await job.getState(),
type: job.queueName as JobType,
data: job.data,
parent: job.parent
? { id: job.parent.id }
: undefined,
progress: job.progress as number,
priority: job.opts.priority,
error: getJobError(job),
createdAt: new Date(job.timestamp),
finishedOn: new Date(job.finishedOn),
processedOn: new Date(job.processedOn)
}
}
function getJobError (job: BullJob) {
if (isArray(job.stacktrace) && job.stacktrace.length !== 0) return job.stacktrace[0]
if (job.failedReason) return job.failedReason
return null
}

View File

@@ -0,0 +1,541 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { buildRtspSinkUrlForTargetCamera } from '@server/lib/live-videos/rtsp-url.js'
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
import { CONFIG } from '@server/initializers/config.js'
import { asyncMiddleware } from '@server/middlewares/async.js'
import express from 'express'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { mkdir, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { setTimeout as wait } from 'node:timers/promises'
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
import yaml from 'js-yaml'
type StreamConfig = {
streams?: Record<string, string | string[]>
}
type LiveSession = {
ffmpegProcess?: ChildProcessWithoutNullStreams
keepAliveUntil?: number
startedAt: number
lastAccessAt: number
startingPromise?: Promise<void>
restartTimer?: NodeJS.Timeout
lastError?: string
stopped?: boolean
}
const SESSION_IDLE_TIMEOUT_MS = 300_000
const SESSION_SWEEP_INTERVAL_MS = 20_000
const MANIFEST_WAIT_TIMEOUT_MS = 20_000
const MANIFEST_WAIT_INTERVAL_MS = 250
const LIVE_CONFIG_PATH = join(process.cwd(), 'livestream-sources.yaml')
const LIVE_WORKDIR = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, 'native-live')
const liveStreamRouter = express.Router()
const liveSessions = new Map<string, LiveSession>()
if(!CONFIG.LIVE.AUTO_START_FFMPEG_STREAMING) {
const sweepTimer = setInterval(cleanupInactiveSessions, SESSION_SWEEP_INTERVAL_MS)
sweepTimer.unref()
}
liveStreamRouter.get('/streams', asyncMiddleware(listStreams))
liveStreamRouter.get('/streams/:streamKey/index.m3u8', asyncMiddleware(getManifest))
liveStreamRouter.get('/streams/:streamKey/:segmentName', asyncMiddleware(getSegment))
liveStreamRouter.get('/scheduled-streams/:cameraId/index.m3u8', asyncMiddleware(getScheduledManifest))
liveStreamRouter.get('/scheduled-streams/:cameraId/:segmentName', asyncMiddleware(getScheduledSegment))
export { liveStreamRouter, initLiveStreams, startScheduledLiveSession, stopScheduledLiveSession }
async function listStreams (req: express.Request, res: express.Response) {
const streams = await readConfiguredStreams()
return res.json({
data: Object.keys(streams)
})
}
async function getManifest (req: express.Request, res: express.Response) {
const streamKey = sanitizeStreamKey(req.params.streamKey)
if (!streamKey) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid stream key.'
})
}
const sourceUrl = await findStreamSourceUrl(streamKey)
if (!sourceUrl) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Unknown stream key "${streamKey}".`
})
}
return serveManifest(res, streamKey, sourceUrl)
}
async function getScheduledManifest (req: express.Request, res: express.Response) {
const cameraId = sanitizeStreamKey(req.params.cameraId)
if (!cameraId) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid camera id.'
})
}
const activeLiveVideo = await loadActiveScheduledLiveVideo(cameraId)
if (!activeLiveVideo) {
await stopScheduledLiveSession(cameraId)
.catch(err => logger.error('Cannot stop stale scheduled sink stream session after expired manifest request.', { err, cameraId }))
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
return serveManifest(res, buildScheduledStreamSessionKey(cameraId), buildRtspSinkUrlForTargetCamera(cameraId))
}
async function getSegment (req: express.Request, res: express.Response) {
const streamKey = sanitizeStreamKey(req.params.streamKey)
const segmentName = req.params.segmentName
if (!streamKey) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid stream key.'
})
}
if (!segmentName || !/^segment_[0-9]{6}\.ts$/.test(segmentName)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid segment name.'
})
}
const sourceUrl = await findStreamSourceUrl(streamKey)
if (!sourceUrl) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Unknown stream key "${streamKey}".`
})
}
return serveSegment(res, streamKey, segmentName, sourceUrl)
}
async function getScheduledSegment (req: express.Request, res: express.Response) {
const cameraId = sanitizeStreamKey(req.params.cameraId)
const segmentName = req.params.segmentName
if (!cameraId) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid camera id.'
})
}
if (!segmentName || !/^segment_[0-9]{6}\.ts$/.test(segmentName)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid segment name.'
})
}
const activeLiveVideo = await loadActiveScheduledLiveVideo(cameraId)
if (!activeLiveVideo) {
await stopScheduledLiveSession(cameraId)
.catch(err => logger.error('Cannot stop stale scheduled sink stream session after expired segment request.', { err, cameraId, segmentName }))
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
return serveSegment(res, buildScheduledStreamSessionKey(cameraId), segmentName, buildRtspSinkUrlForTargetCamera(cameraId))
}
async function serveManifest (res: express.Response, streamKey: string, sourceUrl: string) {
const readyToServe = await ensureLiveSession(streamKey, sourceUrl)
if (!readyToServe) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
const manifestPath = buildStreamPath(streamKey, 'index.m3u8')
const ready = await waitForFile(manifestPath, MANIFEST_WAIT_TIMEOUT_MS)
if (!ready) {
const session = liveSessions.get(streamKey)
return res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: session?.lastError || 'Stream is starting, please retry in a few seconds.'
})
}
touchSession(streamKey)
res.setHeader('Cache-Control', 'public, max-age=1, must-revalidate')
return res.sendFile(resolve(manifestPath))
}
async function serveSegment (res: express.Response, streamKey: string, segmentName: string, sourceUrl: string) {
const readyToServe = await ensureLiveSession(streamKey, sourceUrl)
if (!readyToServe) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
const segmentPath = buildStreamPath(streamKey, segmentName)
if (!existsSync(segmentPath)) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Segment not found.'
})
}
touchSession(streamKey)
res.setHeader('Cache-Control', 'public, max-age=30, immutable')
return res.sendFile(resolve(segmentPath))
}
async function readConfiguredStreams () {
if (!existsSync(LIVE_CONFIG_PATH)) {
return {}
}
const raw = await readFile(LIVE_CONFIG_PATH, 'utf-8')
const parsed = yaml.load(raw) as StreamConfig | undefined
return parsed?.streams || {}
}
async function findStreamSourceUrl (streamKey: string) {
const streams = await readConfiguredStreams()
const streamValue = streams[streamKey]
if (!streamValue) {
return buildRtspSinkUrlForTargetCamera(streamKey)
}
if (typeof streamValue === 'string') return streamValue
const firstRtsp = streamValue.find(v => typeof v === 'string' && v.startsWith('rtsp://'))
if (firstRtsp) return firstRtsp
return streamValue.find(v => typeof v === 'string') || buildRtspSinkUrlForTargetCamera(streamKey)
}
function sanitizeStreamKey (streamKey: string) {
if (!streamKey) return undefined
if (!/^[A-Za-z0-9._-]+$/.test(streamKey)) return undefined
return streamKey
}
function buildStreamPath (streamKey: string, fileName: string) {
return join(LIVE_WORKDIR, streamKey, fileName)
}
function buildScheduledStreamSessionKey (cameraId: string) {
return `scheduled-${cameraId}`
}
async function startScheduledLiveSession (cameraId: string, options: { keepAliveUntil?: Date | number } = {}) {
const streamKey = buildScheduledStreamSessionKey(cameraId)
const keepAliveUntil = normalizeKeepAliveUntil(options.keepAliveUntil)
return ensureLiveSession(streamKey, buildRtspSinkUrlForTargetCamera(cameraId), { keepAliveUntil })
}
async function stopScheduledLiveSession (cameraId: string) {
return stopLiveSession(buildScheduledStreamSessionKey(cameraId))
}
function touchSession (streamKey: string) {
const session = liveSessions.get(streamKey)
if (!session) return
session.lastAccessAt = Date.now()
}
async function ensureLiveSession (streamKey: string, sourceUrl: string, options: { keepAliveUntil?: number } = {}) {
let session = liveSessions.get(streamKey)
const now = Date.now()
if ((options.keepAliveUntil || 0) <= now && options.keepAliveUntil !== undefined) {
await stopLiveSession(streamKey)
return false
}
if (session && (session.keepAliveUntil || 0) <= now && session.keepAliveUntil !== undefined) {
await stopLiveSession(streamKey)
return false
}
if (!session) {
session = {
startedAt: 0,
lastAccessAt: Date.now(),
lastError: undefined,
stopped: false
}
liveSessions.set(streamKey, session)
}
session.stopped = false
clearRestartTimer(session)
if (options.keepAliveUntil) {
session.keepAliveUntil = Math.max(session.keepAliveUntil || 0, options.keepAliveUntil)
}
if (isChildProcessAlive(session.ffmpegProcess)) {
touchSession(streamKey)
return true
}
if (session.startingPromise) {
await session.startingPromise
touchSession(streamKey)
return true
}
session.startingPromise = startSession(streamKey, sourceUrl)
try {
await session.startingPromise
} finally {
session.startingPromise = undefined
}
touchSession(streamKey)
return true
}
async function startSession (streamKey: string, sourceUrl: string) {
const outputDirectory = join(LIVE_WORKDIR, streamKey)
await rm(outputDirectory, { recursive: true, force: true })
await mkdir(outputDirectory, { recursive: true })
const manifestPath = join(outputDirectory, 'index.m3u8')
const segmentPattern = join(outputDirectory, 'segment_%06d.ts')
const ffmpegPath = CONFIG.LIVE.FFMPEG.PATH?.trim() || 'ffmpeg'
const segmentDuration = Math.max(1, CONFIG.LIVE.FFMPEG.HLS_TIME)
const hlsListSize = Math.max(1, CONFIG.LIVE.FFMPEG.HLS_LIST_SIZE)
const ffmpegArgs = [
'-hide_banner',
'-loglevel', 'warning',
'-fflags', '+genpts+igndts+discardcorrupt',
'-rtsp_transport', 'tcp',
'-thread_queue_size', '512',
'-i', sourceUrl,
'-map', '0:v:0',
'-map', '0:a:0?',
'-c', 'copy',
'-f', 'hls',
'-hls_time', String(segmentDuration),
'-hls_list_size', String(hlsListSize),
'-hls_delete_threshold', '2',
'-hls_flags', 'delete_segments+independent_segments+omit_endlist',
'-hls_segment_filename', segmentPattern,
manifestPath
]
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: [ 'ignore', 'ignore', 'pipe' ]
})
const session = liveSessions.get(streamKey)
if (!session || session.stopped) {
ffmpegProcess.kill('SIGTERM')
return
}
session.ffmpegProcess = ffmpegProcess
session.startedAt = Date.now()
session.lastAccessAt = Date.now()
session.lastError = undefined
let stderrBuffer = ''
ffmpegProcess.stderr.on('data', chunk => {
// Keep pipe drained to avoid stalling ffmpeg and keep last lines for diagnostics.
const line = chunk?.toString?.() || ''
stderrBuffer = (stderrBuffer + line).slice(-4_000)
})
ffmpegProcess.once('error', err => {
logger.error('Cannot start ffmpeg live session', { err, streamKey })
const existing = liveSessions.get(streamKey)
if (!existing) return
existing.lastError = `Cannot start ffmpeg: ${err.message}`
})
ffmpegProcess.once('exit', () => {
const existing = liveSessions.get(streamKey)
if (!existing) return
if (existing.ffmpegProcess !== ffmpegProcess) return
existing.ffmpegProcess = undefined
existing.startedAt = 0
const trimmedError = stderrBuffer.trim()
if (trimmedError) {
const lines = trimmedError.split('\n')
existing.lastError = lines.slice(-3).join(' | ')
}
const shouldRestart = CONFIG.LIVE.AUTO_START_FFMPEG_STREAMING ||
((existing.keepAliveUntil || 0) > Date.now())
if (shouldRestart && !existing.stopped) {
const keepAliveUntil = existing.keepAliveUntil
clearRestartTimer(existing)
existing.restartTimer = setTimeout(() => {
const current = liveSessions.get(streamKey)
if (current !== existing || current?.stopped) return
void ensureLiveSession(streamKey, sourceUrl, { keepAliveUntil })
}, 3_000)
}
})
}
async function stopLiveSession (streamKey: string) {
const session = liveSessions.get(streamKey)
if (!session) return false
session.stopped = true
session.keepAliveUntil = undefined
session.startingPromise = undefined
clearRestartTimer(session)
if (session.ffmpegProcess) {
stopSessionProcess(session.ffmpegProcess, streamKey)
}
liveSessions.delete(streamKey)
return true
}
async function waitForFile (filePath: string, timeoutMs: number) {
const end = Date.now() + timeoutMs
while (Date.now() < end) {
if (existsSync(filePath)) return true
await wait(MANIFEST_WAIT_INTERVAL_MS)
}
return false
}
async function initLiveStreams () {
const streams = await readConfiguredStreams()
for (const streamKey of Object.keys(streams)) {
const sourceUrl = await findStreamSourceUrl(streamKey)
if (sourceUrl) {
try {
await ensureLiveSession(streamKey, sourceUrl)
} catch (err) {
logger.warn('Failed to start live stream %s', streamKey, { err })
}
}
}
}
function cleanupInactiveSessions () {
const now = Date.now()
for (const [ streamKey, session ] of liveSessions.entries()) {
if ((session.keepAliveUntil || 0) > now) {
continue
}
if (!isChildProcessAlive(session.ffmpegProcess)) {
liveSessions.delete(streamKey)
continue
}
if (now - session.lastAccessAt < SESSION_IDLE_TIMEOUT_MS) continue
stopSessionProcess(session.ffmpegProcess, streamKey)
liveSessions.delete(streamKey)
}
}
function normalizeKeepAliveUntil (keepAliveUntil?: Date | number) {
if (keepAliveUntil instanceof Date) return keepAliveUntil.getTime()
if (typeof keepAliveUntil === 'number' && Number.isFinite(keepAliveUntil)) return keepAliveUntil
return undefined
}
function clearRestartTimer (session: LiveSession) {
if (!session.restartTimer) return
clearTimeout(session.restartTimer)
session.restartTimer = undefined
}
function isChildProcessAlive (childProcess?: ChildProcessWithoutNullStreams) {
if (!childProcess?.pid) return false
if (childProcess.exitCode !== null || childProcess.signalCode !== null) return false
try {
process.kill(childProcess.pid, 0)
return true
} catch {
return false
}
}
function stopSessionProcess (childProcess: ChildProcessWithoutNullStreams, streamKey: string) {
const pid = childProcess.pid
if (!pid || !isChildProcessAlive(childProcess)) return
childProcess.kill('SIGTERM')
const forceKillTimer = setTimeout(() => {
if (!isChildProcessAlive(childProcess)) return
logger.warn('Force killing stale live session ffmpeg process for %s.', streamKey, { pid })
try {
process.kill(pid, 'SIGKILL')
} catch (err) {
logger.warn('Cannot force kill live session ffmpeg process for %s.', streamKey, { err, pid })
}
}, 5_000)
forceKillTimer.unref()
}
async function loadActiveScheduledLiveVideo (cameraId: string) {
const now = new Date()
const liveVideos = await LiveVideoModel.listForStarting({
startBefore: now,
endAfter: now
})
return liveVideos.find(liveVideo =>
liveVideo.targetCamera === cameraId &&
liveVideo.status === LiveVideoStatus.SCHEDULED
)
}

View File

@@ -0,0 +1,403 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { stopScheduledLiveSession } from './live-stream.js'
import { tryAtomicMove } from '@server/helpers/fs.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { generateImageFilename } from '@server/helpers/image-utils.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { logger } from '@server/helpers/logger.js'
import { createHash } from 'crypto'
import { CONFIG } from '@server/initializers/config.js'
import { MIMETYPES, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { removeLiveVideoStartJob, syncLiveVideoStartJob } from '@server/lib/live-videos/live-video-jobs.js'
import { LiveVideoProcessManager } from '@server/lib/live-videos/live-video-process-manager.js'
import {
getLiveVideoBurnedPath,
getLiveVideoSubtitlePath,
syncPreprocessedLiveVideo
} from '@server/lib/live-videos/preprocessed-video.js'
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
import { processImageFromWorker } from '@server/lib/worker/parent-process.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
paginationValidator,
setDefaultPagination
} from '@server/middlewares/index.js'
import {
createLiveVideoValidator,
deleteLiveVideoValidator,
getLiveVideoValidator,
listLiveVideosValidator,
updateLiveVideoValidator
} from '@server/middlewares/validators/live-videos.js'
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
import express from 'express'
import { ensureDir, remove } from 'fs-extra/esm'
import { readFile } from 'fs/promises'
import { basename, extname, join } from 'node:path'
const liveVideosRouter = express.Router()
const reqLiveVideoFiles = createReqFiles(
[ 'thumbnailfile', 'videofile', 'subtitlefile' ],
{ ...MIMETYPES.IMAGE.MIMETYPE_EXT, ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT }
)
const LIVE_VIDEOS_STORAGE_DIR = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, 'live-videos')
const SPECIAL_THUMBNAILS_TMP_DIR = join(CONFIG.STORAGE.TMP_DIR, 'special-thumbnails')
liveVideosRouter.use(apiRateLimiter)
liveVideosRouter.get(
'/',
paginationValidator,
setDefaultPagination,
listLiveVideosValidator,
asyncMiddleware(listLiveVideos)
)
liveVideosRouter.get(
'/:id',
getLiveVideoValidator,
asyncMiddleware(getLiveVideo)
)
liveVideosRouter.post(
'/',
authenticate,
reqLiveVideoFiles,
createLiveVideoValidator,
asyncMiddleware(createLiveVideo)
)
liveVideosRouter.put(
'/:id',
authenticate,
reqLiveVideoFiles,
updateLiveVideoValidator,
asyncMiddleware(updateLiveVideo)
)
liveVideosRouter.delete(
'/:id',
authenticate,
deleteLiveVideoValidator,
asyncMiddleware(deleteLiveVideo)
)
export {
liveVideosRouter
}
async function listLiveVideos (req: express.Request, res: express.Response) {
const result = await LiveVideoModel.listForApi({
start: req.query.start as number,
count: req.query.count as number,
targetCamera: req.query.targetCamera as string,
status: req.query.status as string
})
const formatted = getFormattedObjects(result.rows, result.count)
formatted.data = formatted.data.map(v => attachSubtitlePath(v))
return res.json(formatted)
}
async function getLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
const json = liveVideo.toFormattedJSON()
return res.json(attachSubtitlePath(json))
}
async function createLiveVideo (req: express.Request, res: express.Response) {
const files = req.files as { [fieldname: string]: Express.Multer.File[] }
const thumbnailFile = files.thumbnailfile?.[0]
const videoFile = files.videofile?.[0]
const subtitleFile = files.subtitlefile?.[0]
const user = res.locals.oauth.token.User
const liveVideo = await sequelizeTypescript.transaction(async t => {
const thumbnail = thumbnailFile
? await saveThumbnailToTemporaryFile(thumbnailFile.path)
: null
const videoStoragePath = await storeVideoFile(videoFile)
if (subtitleFile) {
await storeSubtitleFile(subtitleFile, videoStoragePath)
}
await syncPreprocessedLiveVideo(videoStoragePath)
const created = await LiveVideoModel.create({
title: req.body.title,
thumbnailFilename: thumbnail?.filename || null,
thumbnailData: thumbnail?.data || null,
thumbnailMimeType: thumbnail?.mimeType || null,
thumbnailSize: thumbnail?.size || null,
thumbnailEtag: thumbnail?.etag || null,
videoOriginalName: basename(videoFile.originalname),
videoStoragePath,
videoMimeType: videoFile.mimetype,
videoSize: videoFile.size,
startTime: new Date(req.body.startTime),
endTime: new Date(req.body.endTime),
targetCamera: req.body.targetCamera,
status: LiveVideoStatus.SCHEDULED,
createdByUserId: user.id
}, {
transaction: t
})
return created
})
await syncLiveVideoStartJob(liveVideo, { replace: true })
.catch(err => logger.error('Cannot sync scheduled live video start job after creation.', { err, liveVideoId: liveVideo.id }))
await PeerTubeSocket.Instance.closeLiveChatRoom(liveVideo.targetCamera, 'rescheduled')
.catch(err => logger.error('Cannot reset live chat room after live video creation.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
const json = liveVideo.toFormattedJSON()
return res.status(HttpStatusCode.CREATED_201).json(attachSubtitlePath(json))
}
async function updateLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
const previousTargetCamera = liveVideo.targetCamera
const previousStartTime = liveVideo.startTime.getTime()
const previousEndTime = liveVideo.endTime.getTime()
const previousStatus = liveVideo.status
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined
const thumbnailFile = files?.thumbnailfile?.[0]
const videoFile = files?.videofile?.[0]
const subtitleFile = files?.subtitlefile?.[0]
const updatedLiveVideo = await sequelizeTypescript.transaction(async t => {
let thumbnailFilename = liveVideo.thumbnailFilename
let videoStoragePath = liveVideo.videoStoragePath
let videoOriginalName = liveVideo.videoOriginalName
let videoMimeType = liveVideo.videoMimeType
let videoSize = liveVideo.videoSize
if (thumbnailFile) {
const thumbnail = await saveThumbnailToTemporaryFile(thumbnailFile.path)
thumbnailFilename = thumbnail.filename
Object.assign(liveVideo, {
thumbnailData: thumbnail.data,
thumbnailMimeType: thumbnail.mimeType,
thumbnailSize: thumbnail.size,
thumbnailEtag: thumbnail.etag
})
}
if (videoFile) {
await remove(liveVideo.videoStoragePath).catch(() => undefined)
await remove(getLiveVideoBurnedPath(liveVideo.videoStoragePath)).catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
videoStoragePath = await storeVideoFile(videoFile)
videoOriginalName = basename(videoFile.originalname)
videoMimeType = videoFile.mimetype
videoSize = videoFile.size
}
if (subtitleFile) {
if (!videoFile) {
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
}
await storeSubtitleFile(subtitleFile, videoStoragePath)
}
if (videoFile || subtitleFile) {
await syncPreprocessedLiveVideo(videoStoragePath)
}
await liveVideo.update({
title: req.body.title ?? liveVideo.title,
thumbnailFilename,
thumbnailData: thumbnailFile ? liveVideo.thumbnailData : liveVideo.thumbnailData,
thumbnailMimeType: thumbnailFile ? liveVideo.thumbnailMimeType : liveVideo.thumbnailMimeType,
thumbnailSize: thumbnailFile ? liveVideo.thumbnailSize : liveVideo.thumbnailSize,
thumbnailEtag: thumbnailFile ? liveVideo.thumbnailEtag : liveVideo.thumbnailEtag,
videoOriginalName,
videoStoragePath,
videoMimeType,
videoSize,
startTime: req.body.startTime ? new Date(req.body.startTime) : liveVideo.startTime,
endTime: req.body.endTime ? new Date(req.body.endTime) : liveVideo.endTime,
targetCamera: req.body.targetCamera ?? liveVideo.targetCamera,
status: req.body.status ?? liveVideo.status
}, {
transaction: t
})
return liveVideo
})
const scheduleChanged = previousTargetCamera !== updatedLiveVideo.targetCamera ||
previousStartTime !== updatedLiveVideo.startTime.getTime() ||
previousEndTime !== updatedLiveVideo.endTime.getTime()
if (updatedLiveVideo.status === LiveVideoStatus.SCHEDULED) {
if (scheduleChanged) {
await LiveVideoProcessManager.Instance.stop(updatedLiveVideo.id, 'manual')
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process after reschedule.', { err, liveVideoId: updatedLiveVideo.id }))
await stopScheduledLiveSession(previousTargetCamera)
.catch(err => logger.error('Cannot stop previous scheduled sink stream session after reschedule.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera: previousTargetCamera
}))
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
await stopScheduledLiveSession(updatedLiveVideo.targetCamera)
.catch(err => logger.error('Cannot stop new scheduled sink stream session before reschedule restart.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera: updatedLiveVideo.targetCamera
}))
}
}
await syncLiveVideoStartJob(updatedLiveVideo, { replace: true })
.catch(err => logger.error('Cannot sync scheduled live video start job after update.', { err, liveVideoId: updatedLiveVideo.id }))
} else {
await removeLiveVideoStartJob(updatedLiveVideo.id)
.catch(err => logger.error('Cannot remove scheduled live video start job after update.', { err, liveVideoId: updatedLiveVideo.id }))
await LiveVideoProcessManager.Instance.stop(
updatedLiveVideo.id,
updatedLiveVideo.status === LiveVideoStatus.CANCELLED ? 'cancelled' : 'manual'
)
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process after update.', { err, liveVideoId: updatedLiveVideo.id }))
await stopScheduledLiveSession(updatedLiveVideo.targetCamera)
.catch(err => logger.error('Cannot stop scheduled sink stream session after update.', { err, liveVideoId: updatedLiveVideo.id, targetCamera: updatedLiveVideo.targetCamera }))
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
await stopScheduledLiveSession(previousTargetCamera)
.catch(err => logger.error('Cannot stop previous scheduled sink stream session after update.', { err, liveVideoId: updatedLiveVideo.id, targetCamera: previousTargetCamera }))
}
}
const shouldResetChat = previousTargetCamera !== updatedLiveVideo.targetCamera ||
previousStartTime !== updatedLiveVideo.startTime.getTime() ||
previousEndTime !== updatedLiveVideo.endTime.getTime() ||
previousStatus !== updatedLiveVideo.status
if (!shouldResetChat) {
return res.json(attachSubtitlePath(updatedLiveVideo.toFormattedJSON()))
}
const chatRoomsToClose = new Set<string>([ updatedLiveVideo.targetCamera ])
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
chatRoomsToClose.add(previousTargetCamera)
}
for (const targetCamera of chatRoomsToClose) {
await PeerTubeSocket.Instance.closeLiveChatRoom(
targetCamera,
updatedLiveVideo.status === LiveVideoStatus.SCHEDULED ? 'rescheduled' : 'live-ended'
)
.catch(err => logger.error('Cannot reset live chat room after live video update.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera
}))
}
const json = updatedLiveVideo.toFormattedJSON()
return res.json(attachSubtitlePath(json))
}
function attachSubtitlePath (liveVideo: any) {
const basePath = liveVideo.videoStoragePath
if (!basePath) {
liveVideo.subtitlePath = null
return liveVideo
}
liveVideo.subtitlePath = getLiveVideoSubtitlePath(basePath)
return liveVideo
}
async function deleteLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
await removeLiveVideoStartJob(liveVideo.id)
.catch(err => logger.error('Cannot remove scheduled live video start job before deletion.', { err, liveVideoId: liveVideo.id }))
await LiveVideoProcessManager.Instance.stop(liveVideo.id, 'deleted')
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process before deletion.', { err, liveVideoId: liveVideo.id }))
await stopScheduledLiveSession(liveVideo.targetCamera)
.catch(err => logger.error('Cannot stop scheduled sink stream session before deletion.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
await PeerTubeSocket.Instance.closeLiveChatRoom(liveVideo.targetCamera, 'deleted')
.catch(err => logger.error('Cannot reset live chat room before live video deletion.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
await sequelizeTypescript.transaction(async t => {
await liveVideo.destroy({ transaction: t })
})
await remove(liveVideo.videoStoragePath).catch(() => undefined)
await remove(getLiveVideoBurnedPath(liveVideo.videoStoragePath)).catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function saveThumbnailToTemporaryFile (sourcePath: string) {
const thumbnailFilename = generateImageFilename('.jpg')
const destination = join(SPECIAL_THUMBNAILS_TMP_DIR, thumbnailFilename)
await ensureDir(SPECIAL_THUMBNAILS_TMP_DIR)
await processImageFromWorker({
path: sourcePath,
destination,
newSize: THUMBNAILS_SIZE,
keepOriginal: false
})
const data = await readFile(destination)
await remove(destination).catch(() => undefined)
return {
filename: thumbnailFilename,
data,
size: data.byteLength,
mimeType: 'image/jpeg',
etag: createHash('sha256').update(data).digest('hex')
}
}
async function storeVideoFile (file: Express.Multer.File) {
const extension = extname(file.originalname) || '.mp4'
const filename = `${Date.now()}-${basename(file.filename, extname(file.filename))}${extension}`
const destination = join(LIVE_VIDEOS_STORAGE_DIR, filename)
await ensureDir(LIVE_VIDEOS_STORAGE_DIR)
await tryAtomicMove(file.path, destination)
return destination
}
async function storeSubtitleFile (file: Express.Multer.File, videoPath: string) {
const extension = extname(file.originalname) || '.srt'
const destination = videoPath + extension
await tryAtomicMove(file.path, destination)
return destination
}

View File

@@ -0,0 +1,34 @@
import express from 'express'
import { CONFIG } from '@server/initializers/config.js'
import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics.js'
import { HttpStatusCode, PlaybackMetricCreate } from '@peertube/peertube-models'
import { addPlaybackMetricValidator, apiRateLimiter, asyncMiddleware } from '../../middlewares/index.js'
const metricsRouter = express.Router()
metricsRouter.use(apiRateLimiter)
metricsRouter.post('/playback',
asyncMiddleware(addPlaybackMetricValidator),
addPlaybackMetric
)
// ---------------------------------------------------------------------------
export {
metricsRouter
}
// ---------------------------------------------------------------------------
function addPlaybackMetric (req: express.Request, res: express.Response) {
if (!CONFIG.OPEN_TELEMETRY.METRICS.ENABLED) {
return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
}
const body: PlaybackMetricCreate = req.body
OpenTelemetryMetrics.Instance.observePlaybackMetric(res.locals.onlyImmutableVideo, body)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,54 @@
import express from 'express'
import { HttpStatusCode, OAuthClientLocal } from '@peertube/peertube-models'
import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
import { OAuthClientModel } from '@server/models/oauth/oauth-client.js'
import { logger } from '../../helpers/logger.js'
import { CONFIG } from '../../initializers/config.js'
import { apiRateLimiter, asyncMiddleware, openapiOperationDoc } from '../../middlewares/index.js'
const oauthClientsRouter = express.Router()
oauthClientsRouter.use(apiRateLimiter)
oauthClientsRouter.get('/local',
openapiOperationDoc({ operationId: 'getOAuthClient' }),
asyncMiddleware(getLocalClient)
)
// Get the client credentials for the PeerTube front end
async function getLocalClient (req: express.Request, res: express.Response, next: express.NextFunction) {
const serverHostname = CONFIG.WEBSERVER.HOSTNAME
const serverPort = CONFIG.WEBSERVER.PORT
let headerHostShouldBe = serverHostname
if (serverPort !== 80 && serverPort !== 443) {
headerHostShouldBe += ':' + serverPort
}
// Don't make this check if this is a test instance
if (!isTestOrDevInstance() && req.get('host') !== headerHostShouldBe) {
logger.info(
'Getting client tokens for host %s is forbidden (expected %s).', req.get('host'), headerHostShouldBe,
{ webserverConfig: CONFIG.WEBSERVER }
)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: `Getting client tokens for host ${req.get('host')} is forbidden`
})
}
const client = await OAuthClientModel.loadFirstClient()
if (!client) throw new Error('No client available.')
const json: OAuthClientLocal = {
client_id: client.clientId,
client_secret: client.clientSecret
}
return res.json(json)
}
// ---------------------------------------------------------------------------
export {
oauthClientsRouter
}

View File

@@ -0,0 +1,142 @@
import { CategoryOverview, ChannelOverview, TagOverview, VideosOverview } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoModel } from '@server/models/video/video.js'
import express from 'express'
import memoizee from 'memoizee'
import { buildNSFWFilters } from '../../helpers/express-utils.js'
import { MEMOIZE_TTL, OVERVIEWS } from '../../initializers/constants.js'
import { apiRateLimiter, asyncMiddleware, optionalAuthenticate, videosOverviewValidator } from '../../middlewares/index.js'
import { TagModel } from '../../models/video/tag.js'
const overviewsRouter = express.Router()
overviewsRouter.use(apiRateLimiter)
overviewsRouter.get('/videos', videosOverviewValidator, optionalAuthenticate, asyncMiddleware(getVideosOverview))
// ---------------------------------------------------------------------------
export { overviewsRouter }
// ---------------------------------------------------------------------------
const buildSamples = memoizee(async function () {
const [ categories, channels, tags ] = await Promise.all([
VideoModel.getRandomFieldSamples('category', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
VideoModel.getRandomFieldSamples('channelId', OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT),
TagModel.getRandomSamples(OVERVIEWS.VIDEOS.SAMPLE_THRESHOLD, OVERVIEWS.VIDEOS.SAMPLES_COUNT)
])
const result = { categories, channels, tags }
logger.debug('Building samples for overview endpoint.', { result })
return result
}, { maxAge: MEMOIZE_TTL.OVERVIEWS_SAMPLE })
function removeRootChannel (channelIds: number[]) {
return channelIds.filter(channelId => channelId!== 1)
}
// This endpoint could be quite long, but we cache it
async function getVideosOverview (req: express.Request, res: express.Response) {
const attributes = await buildSamples()
attributes.channels = removeRootChannel(attributes.channels);
const page = req.query.page || 1
const index = page - 1
const categories: CategoryOverview[] = []
const channels: ChannelOverview[] = []
const tags: TagOverview[] = []
await Promise.all([
getVideosByCategory(attributes.categories, index, res, categories),
getVideosByChannel(attributes.channels, index, res, channels),
getVideosByTag(attributes.tags, index, res, tags)
])
const result: VideosOverview = {
categories,
channels,
tags
}
return res.json(result)
}
async function getVideosByTag (tagsSample: string[], index: number, res: express.Response, acc: TagOverview[]) {
if (tagsSample.length <= index) return
const tag = tagsSample[index]
const videos = await getVideos(res, { tagsOneOf: [ tag ] })
if (videos.length === 0) return
acc.push({
tag,
videos
})
}
async function getVideosByCategory (categoriesSample: number[], index: number, res: express.Response, acc: CategoryOverview[]) {
if (categoriesSample.length <= index) return
const category = categoriesSample[index]
const videos = await getVideos(res, { categoryOneOf: [ category ] })
if (videos.length === 0) return
acc.push({
category: videos[0].category,
videos
})
}
async function getVideosByChannel (channelsSample: number[], index: number, res: express.Response, acc: ChannelOverview[]) {
if (channelsSample.length <= index) return
const channelId = channelsSample[index]
const videos = await getVideos(res, { videoChannelId: channelId })
if (videos.length === 0) return
acc.push({
channel: videos[0].channel,
videos
})
}
async function getVideos (
res: express.Response,
where: { videoChannelId?: number, tagsOneOf?: string[], categoryOneOf?: number[] }
) {
const serverActor = await getServerActor()
const query = await Hooks.wrapObject({
...buildNSFWFilters({ res }),
start: 0,
count: 12,
sort: '-createdAt',
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos: false,
...where
}, 'filter:api.overviews.videos.list.params')
const { data } = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
query,
'filter:api.overviews.videos.list.result'
)
return data.map(d => d.toFormattedJSON())
}

View File

@@ -0,0 +1,112 @@
import { PlayerChannelSettingsUpdate, PlayerVideoSettingsUpdate } from '@peertube/peertube-models'
import { sendUpdateChannelPlayerSettings, sendUpdateVideoPlayerSettings } from '@server/lib/activitypub/send/send-update.js'
import { upsertPlayerSettings } from '@server/lib/player-settings.js'
import {
getChannelPlayerSettingsValidator,
getVideoPlayerSettingsValidator,
updatePlayerSettingsValidatorFactory,
updateVideoPlayerSettingsValidator
} from '@server/middlewares/validators/player-settings.js'
import { PlayerSettingModel } from '@server/models/video/player-setting.js'
import express from 'express'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
optionalAuthenticate,
videoChannelsHandleValidatorFactory
} from '../../middlewares/index.js'
const playerSettingsRouter = express.Router()
playerSettingsRouter.use(apiRateLimiter)
playerSettingsRouter.get(
'/videos/:videoId',
optionalAuthenticate,
asyncMiddleware(getVideoPlayerSettingsValidator),
asyncMiddleware(getVideoPlayerSettings)
)
playerSettingsRouter.put(
'/videos/:videoId',
authenticate,
asyncMiddleware(updateVideoPlayerSettingsValidator),
updatePlayerSettingsValidatorFactory('video'),
asyncMiddleware(updateVideoPlayerSettings)
)
playerSettingsRouter.get(
'/video-channels/:handle',
optionalAuthenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(getChannelPlayerSettingsValidator),
asyncMiddleware(getChannelPlayerSettings)
)
playerSettingsRouter.put(
'/video-channels/:handle',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updatePlayerSettingsValidatorFactory('channel'),
asyncMiddleware(updateChannelPlayerSettings)
)
// ---------------------------------------------------------------------------
export {
playerSettingsRouter
}
// ---------------------------------------------------------------------------
async function getVideoPlayerSettings (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo || res.locals.videoAll
const { videoSetting, channelSetting } = await PlayerSettingModel.loadByVideoIdOrChannelId({
channelId: video.channelId,
videoId: video.id
})
if (req.query.raw === true) {
return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(videoSetting))
}
return res.json(PlayerSettingModel.formatVideoPlayerSetting({ videoSetting, channelSetting }))
}
async function getChannelPlayerSettings (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const channelSetting = await PlayerSettingModel.loadByChannelId(channel.id)
if (req.query.raw === true) {
return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(channelSetting))
}
return res.json(PlayerSettingModel.formatChannelPlayerSetting({ channelSetting }))
}
// ---------------------------------------------------------------------------
async function updateVideoPlayerSettings (req: express.Request, res: express.Response) {
const body: PlayerVideoSettingsUpdate = req.body
const video = res.locals.videoAll
const setting = await upsertPlayerSettings({ user: res.locals.oauth.token.User, settings: body, channel: undefined, video })
await sendUpdateVideoPlayerSettings(video, setting, undefined)
return res.json(PlayerSettingModel.formatVideoPlayerRawSetting(setting))
}
async function updateChannelPlayerSettings (req: express.Request, res: express.Response) {
const body: PlayerChannelSettingsUpdate = req.body
const channel = res.locals.videoChannel
const settings = await upsertPlayerSettings({ user: res.locals.oauth.token.User, settings: body, channel, video: undefined })
await sendUpdateChannelPlayerSettings(channel, settings, undefined)
return res.json(PlayerSettingModel.formatChannelPlayerRawSetting(settings))
}

View File

@@ -0,0 +1,230 @@
import express from 'express'
import { logger } from '@server/helpers/logger.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { listAvailablePluginsFromIndex } from '@server/lib/plugins/plugin-index.js'
import { PluginManager } from '@server/lib/plugins/plugin-manager.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
availablePluginsSortValidator,
ensureUserHasRight,
openapiOperationDoc,
paginationValidator,
pluginsSortValidator,
setDefaultPagination,
setDefaultSort
} from '@server/middlewares/index.js'
import {
existingPluginValidator,
installOrUpdatePluginValidator,
listAvailablePluginsValidator,
listPluginsValidator,
uninstallPluginValidator,
updatePluginSettingsValidator
} from '@server/middlewares/validators/plugins.js'
import { PluginModel } from '@server/models/server/plugin.js'
import {
HttpStatusCode,
InstallOrUpdatePlugin,
ManagePlugin,
PeertubePluginIndexList,
PublicServerSetting,
RegisteredServerSettings,
UserRight
} from '@peertube/peertube-models'
const pluginRouter = express.Router()
pluginRouter.use(apiRateLimiter)
pluginRouter.get('/available',
openapiOperationDoc({ operationId: 'getAvailablePlugins' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
listAvailablePluginsValidator,
paginationValidator,
availablePluginsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAvailablePlugins)
)
pluginRouter.get('/',
openapiOperationDoc({ operationId: 'getPlugins' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
listPluginsValidator,
paginationValidator,
pluginsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listPlugins)
)
pluginRouter.get('/:npmName/registered-settings',
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
asyncMiddleware(existingPluginValidator),
getPluginRegisteredSettings
)
pluginRouter.get('/:npmName/public-settings',
asyncMiddleware(existingPluginValidator),
getPublicPluginSettings
)
pluginRouter.put('/:npmName/settings',
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
updatePluginSettingsValidator,
asyncMiddleware(existingPluginValidator),
asyncMiddleware(updatePluginSettings)
)
pluginRouter.get('/:npmName',
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
asyncMiddleware(existingPluginValidator),
getPlugin
)
pluginRouter.post('/install',
openapiOperationDoc({ operationId: 'addPlugin' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
installOrUpdatePluginValidator,
asyncMiddleware(installPlugin)
)
pluginRouter.post('/update',
openapiOperationDoc({ operationId: 'updatePlugin' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
installOrUpdatePluginValidator,
asyncMiddleware(updatePlugin)
)
pluginRouter.post('/uninstall',
openapiOperationDoc({ operationId: 'uninstallPlugin' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_PLUGINS),
uninstallPluginValidator,
asyncMiddleware(uninstallPlugin)
)
// ---------------------------------------------------------------------------
export {
pluginRouter
}
// ---------------------------------------------------------------------------
async function listPlugins (req: express.Request, res: express.Response) {
const pluginType = req.query.pluginType
const uninstalled = req.query.uninstalled
const resultList = await PluginModel.listForApi({
pluginType,
uninstalled,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
function getPlugin (req: express.Request, res: express.Response) {
const plugin = res.locals.plugin
return res.json(plugin.toFormattedJSON())
}
async function installPlugin (req: express.Request, res: express.Response) {
const body: InstallOrUpdatePlugin = req.body
const fromDisk = !!body.path
const toInstall = body.npmName || body.path
const pluginVersion = body.pluginVersion && body.npmName
? body.pluginVersion
: undefined
try {
const plugin = await PluginManager.Instance.install({ toInstall, version: pluginVersion, fromDisk })
return res.json(plugin.toFormattedJSON())
} catch (err) {
logger.warn('Cannot install plugin %s.', toInstall, { err })
return res.fail({ message: 'Cannot install plugin ' + toInstall })
}
}
async function updatePlugin (req: express.Request, res: express.Response) {
const body: InstallOrUpdatePlugin = req.body
const fromDisk = !!body.path
const toUpdate = body.npmName || body.path
try {
const plugin = await PluginManager.Instance.update(toUpdate, fromDisk)
return res.json(plugin.toFormattedJSON())
} catch (err) {
logger.warn('Cannot update plugin %s.', toUpdate, { err })
return res.fail({ message: 'Cannot update plugin ' + toUpdate })
}
}
async function uninstallPlugin (req: express.Request, res: express.Response) {
const body: ManagePlugin = req.body
await PluginManager.Instance.uninstall({ npmName: body.npmName })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
function getPublicPluginSettings (req: express.Request, res: express.Response) {
const plugin = res.locals.plugin
const registeredSettings = PluginManager.Instance.getRegisteredSettings(req.params.npmName)
const publicSettings = plugin.getPublicSettings(registeredSettings)
const json: PublicServerSetting = { publicSettings }
return res.json(json)
}
function getPluginRegisteredSettings (req: express.Request, res: express.Response) {
const registeredSettings = PluginManager.Instance.getRegisteredSettings(req.params.npmName)
const json: RegisteredServerSettings = { registeredSettings }
return res.json(json)
}
async function updatePluginSettings (req: express.Request, res: express.Response) {
const plugin = res.locals.plugin
plugin.settings = req.body.settings
await plugin.save()
await PluginManager.Instance.onSettingsChanged(plugin.name, plugin.settings)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function listAvailablePlugins (req: express.Request, res: express.Response) {
const query: PeertubePluginIndexList = req.query
const resultList = await listAvailablePluginsFromIndex(query)
if (!resultList) {
return res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: 'Plugin index unavailable. Please retry later'
})
}
return res.json(resultList)
}

View File

@@ -0,0 +1,20 @@
import express from 'express'
import { runnerJobsRouter } from './jobs.js'
import { runnerJobFilesRouter } from './jobs-files.js'
import { manageRunnersRouter } from './manage-runners.js'
import { runnerRegistrationTokensRouter } from './registration-tokens.js'
const runnersRouter = express.Router()
// No api route limiter here, they are defined in child routers
runnersRouter.use('/', manageRunnersRouter)
runnersRouter.use('/', runnerJobsRouter)
runnersRouter.use('/', runnerJobFilesRouter)
runnersRouter.use('/', runnerRegistrationTokensRouter)
// ---------------------------------------------------------------------------
export {
runnersRouter
}

View File

@@ -0,0 +1,165 @@
import { FileStorage, RunnerJobState, VideoFileStream } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage/index.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { getStudioTaskFilePath } from '@server/lib/video-studio.js'
import { apiRateLimiter, asyncMiddleware } from '@server/middlewares/index.js'
import { jobOfRunnerGetValidatorFactory } from '@server/middlewares/validators/runners/index.js'
import {
runnerJobGetVideoStudioTaskFileValidator,
runnerJobGetVideoTranscodingFileValidator
} from '@server/middlewares/validators/runners/job-files.js'
import { MVideoFileStreamingPlaylistVideo, MVideoFileVideo, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
const lTags = loggerTagsFactory('api', 'runner')
const runnerJobFilesRouter = express.Router()
runnerJobFilesRouter.post(
'/jobs/:jobUUID/files/videos/:videoId/max-quality/audio',
apiRateLimiter,
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
asyncMiddleware(getMaxQualitySeparatedAudioFile)
)
runnerJobFilesRouter.post(
'/jobs/:jobUUID/files/videos/:videoId/max-quality',
apiRateLimiter,
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
asyncMiddleware(getMaxQualityVideoFile)
)
runnerJobFilesRouter.post(
'/jobs/:jobUUID/files/videos/:videoId/previews/max-quality',
apiRateLimiter,
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
getMaxQualityVideoPreview
)
runnerJobFilesRouter.post(
'/jobs/:jobUUID/files/videos/:videoId/studio/task-files/:filename',
apiRateLimiter,
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
asyncMiddleware(runnerJobGetVideoTranscodingFileValidator),
runnerJobGetVideoStudioTaskFileValidator,
getVideoStudioTaskFile
)
// ---------------------------------------------------------------------------
export {
runnerJobFilesRouter
}
// ---------------------------------------------------------------------------
async function getMaxQualitySeparatedAudioFile (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const video = res.locals.videoAll
logger.info(
'Get max quality separated audio file of video %s of job %s for runner %s',
video.uuid,
runnerJob.uuid,
runner.name,
lTags(runner.name, runnerJob.id, runnerJob.type)
)
const file = video.getMaxQualityFile(VideoFileStream.AUDIO) || video.getMaxQualityFile(VideoFileStream.VIDEO)
return serveVideoFile({ video, file, req, res })
}
async function getMaxQualityVideoFile (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const video = res.locals.videoAll
logger.info(
'Get max quality file of video %s of job %s for runner %s',
video.uuid,
runnerJob.uuid,
runner.name,
lTags(runner.name, runnerJob.id, runnerJob.type)
)
const file = video.getMaxQualityFile(VideoFileStream.VIDEO) || video.getMaxQualityFile(VideoFileStream.AUDIO)
return serveVideoFile({ video, file, req, res })
}
async function serveVideoFile (options: {
video: MVideoFullLight
file: MVideoFileVideo | MVideoFileStreamingPlaylistVideo
req: express.Request
res: express.Response
}) {
const { video, file, req, res } = options
if (file.storage === FileStorage.OBJECT_STORAGE) {
if (file.isHLS()) {
return proxifyHLS({
req,
res,
filename: file.filename,
playlist: video.getHLSPlaylist(),
reinjectVideoFileToken: false,
video
})
}
// Web video
return proxifyWebVideoFile({
req,
res,
filename: file.filename
})
}
return VideoPathManager.Instance.makeAvailableVideoFile(file, videoPath => {
return res.sendFile(videoPath)
})
}
// ---------------------------------------------------------------------------
function getMaxQualityVideoPreview (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const video = res.locals.videoAll
logger.info(
'Get max quality preview file of video %s of job %s for runner %s',
video.uuid,
runnerJob.uuid,
runner.name,
lTags(runner.name, runnerJob.id, runnerJob.type)
)
const file = video.getPreview()
return res.sendFile(file.getPath())
}
function getVideoStudioTaskFile (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const video = res.locals.videoAll
const filename = req.params.filename
logger.info(
'Get video studio task file %s of video %s of job %s for runner %s',
filename,
video.uuid,
runnerJob.uuid,
runner.name,
lTags(runner.name, runnerJob.id, runnerJob.type)
)
return res.sendFile(getStudioTaskFilePath(filename))
}

View File

@@ -0,0 +1,472 @@
import {
AbortRunnerJobBody,
AcceptRunnerJobResult,
ErrorRunnerJobBody,
HttpStatusCode,
ListRunnerJobsQuery,
LiveRTMPHLSTranscodingUpdatePayload,
RequestRunnerJobBody,
RequestRunnerJobResult,
RunnerJobState,
RunnerJobSuccessBody,
RunnerJobSuccessPayload,
RunnerJobType,
RunnerJobUpdateBody,
RunnerJobUpdatePayload,
ServerErrorCode,
TranscriptionSuccess,
GenerateStoryboardSuccess,
UserRight,
VODAudioMergeTranscodingSuccess,
VODHLSTranscodingSuccess,
VODWebVideoTranscodingSuccess,
VideoStudioTranscodingSuccess
} from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { generateRunnerJobToken } from '@server/helpers/token-generator.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { getRunnerJobHandlerClass, runnerJobCanBeCancelled, updateLastRunnerContact } from '@server/lib/runners/index.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
runnerJobsSortValidator,
setDefaultPagination,
setDefaultSort
} from '@server/middlewares/index.js'
import {
abortRunnerJobValidator,
acceptRunnerJobValidator,
cancelRunnerJobValidator,
errorRunnerJobValidator,
getRunnerFromTokenValidator,
jobOfRunnerGetValidatorFactory,
listRunnerJobsValidator,
requestRunnerJobValidator,
runnerJobGetValidator,
successRunnerJobValidator,
updateRunnerJobValidator
} from '@server/middlewares/validators/runners/index.js'
import { RunnerJobModel } from '@server/models/runner/runner-job.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import express, { UploadFiles } from 'express'
const postRunnerJobSuccessVideoFiles = createReqFiles(
[ 'payload[videoFile]', 'payload[resolutionPlaylistFile]', 'payload[vttFile]', 'payload[storyboardFile]' ],
{
...MIMETYPES.VIDEO.MIMETYPE_EXT,
...MIMETYPES.M3U8.MIMETYPE_EXT,
...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT,
...MIMETYPES.IMAGE.MIMETYPE_EXT
}
)
const runnerJobUpdateVideoFiles = createReqFiles(
[ 'payload[videoChunkFile]', 'payload[resolutionPlaylistFile]', 'payload[masterPlaylistFile]' ],
{ ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.M3U8.MIMETYPE_EXT }
)
const lTags = loggerTagsFactory('api', 'runner')
const runnerJobsRouter = express.Router()
// ---------------------------------------------------------------------------
// Controllers for runners
// ---------------------------------------------------------------------------
runnerJobsRouter.post(
'/jobs/request',
apiRateLimiter,
requestRunnerJobValidator,
asyncMiddleware(getRunnerFromTokenValidator),
asyncMiddleware(requestRunnerJob)
)
runnerJobsRouter.post(
'/jobs/:jobUUID/accept',
apiRateLimiter,
asyncMiddleware(runnerJobGetValidator),
acceptRunnerJobValidator,
asyncMiddleware(getRunnerFromTokenValidator),
asyncMiddleware(acceptRunnerJob)
)
runnerJobsRouter.post(
'/jobs/:jobUUID/abort',
apiRateLimiter,
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
abortRunnerJobValidator,
asyncMiddleware(abortRunnerJob)
)
runnerJobsRouter.post(
'/jobs/:jobUUID/update',
runnerJobUpdateVideoFiles,
apiRateLimiter, // Has to be after multer middleware to parse runner token
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING, RunnerJobState.COMPLETING, RunnerJobState.COMPLETED ])),
updateRunnerJobValidator,
asyncMiddleware(updateRunnerJobController)
)
runnerJobsRouter.post(
'/jobs/:jobUUID/error',
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
errorRunnerJobValidator,
asyncMiddleware(errorRunnerJob)
)
runnerJobsRouter.post(
'/jobs/:jobUUID/success',
postRunnerJobSuccessVideoFiles,
apiRateLimiter, // Has to be after multer middleware to parse runner token
asyncMiddleware(jobOfRunnerGetValidatorFactory([ RunnerJobState.PROCESSING ])),
successRunnerJobValidator,
asyncMiddleware(postRunnerJobSuccess)
)
// ---------------------------------------------------------------------------
// Controllers for admins
// ---------------------------------------------------------------------------
runnerJobsRouter.post(
'/jobs/:jobUUID/cancel',
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(runnerJobGetValidator),
cancelRunnerJobValidator,
asyncMiddleware(cancelRunnerJob)
)
runnerJobsRouter.get(
'/jobs',
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
paginationValidator,
runnerJobsSortValidator,
setDefaultSort,
setDefaultPagination,
listRunnerJobsValidator,
asyncMiddleware(listRunnerJobs)
)
runnerJobsRouter.delete(
'/jobs/:jobUUID',
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(runnerJobGetValidator),
asyncMiddleware(deleteRunnerJob)
)
// ---------------------------------------------------------------------------
export {
runnerJobsRouter
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Controllers for runners
// ---------------------------------------------------------------------------
async function requestRunnerJob (req: express.Request, res: express.Response) {
const runner = res.locals.runner
const body = req.body as RequestRunnerJobBody
const availableJobs = await RunnerJobModel.listAvailableJobs(body.jobTypes)
logger.debug('Runner %s requests for a job.', runner.name, { availableJobs, ...lTags(runner.name) })
const result: RequestRunnerJobResult = {
availableJobs: availableJobs.map(j => ({
uuid: j.uuid,
type: j.type,
payload: j.payload
}))
}
if (body.version && runner.version !== body.version) {
runner.version = body.version
await runner.save()
}
updateLastRunnerContact(req, runner)
return res.json(result)
}
async function acceptRunnerJob (req: express.Request, res: express.Response) {
const runner = res.locals.runner
const runnerJob = res.locals.runnerJob
const newRunnerJob = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
await runnerJob.reload({ transaction })
if (runnerJob.state !== RunnerJobState.PENDING) {
res.fail({
type: ServerErrorCode.RUNNER_JOB_NOT_IN_PENDING_STATE,
message: 'This job is not in pending state anymore',
status: HttpStatusCode.CONFLICT_409
})
return undefined
}
runnerJob.state = RunnerJobState.PROCESSING
runnerJob.processingJobToken = generateRunnerJobToken()
runnerJob.startedAt = new Date()
runnerJob.runnerId = runner.id
return runnerJob.save({ transaction })
})
})
if (!newRunnerJob) return
newRunnerJob.Runner = runner as RunnerModel
const result: AcceptRunnerJobResult = {
job: {
...newRunnerJob.toFormattedJSON(),
jobToken: newRunnerJob.processingJobToken
}
}
updateLastRunnerContact(req, runner)
logger.info(
'Remote runner %s has accepted job %s (%s)',
runner.name,
runnerJob.uuid,
runnerJob.type,
lTags(runner.name, runnerJob.uuid, runnerJob.type)
)
return res.json(result)
}
async function abortRunnerJob (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const body: AbortRunnerJobBody = req.body
logger.info(
'Remote runner %s is aborting job %s (%s)',
runner.name,
runnerJob.uuid,
runnerJob.type,
{ reason: body.reason, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
)
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().abort({ runnerJob })
updateLastRunnerContact(req, runnerJob.Runner)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function errorRunnerJob (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const body: ErrorRunnerJobBody = req.body
runnerJob.failures += 1
logger.error(
'Remote runner %s had an error with job %s (%s)',
runner.name,
runnerJob.uuid,
runnerJob.type,
{ errorMessage: body.message, totalFailures: runnerJob.failures, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
)
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().error({ runnerJob, message: body.message })
updateLastRunnerContact(req, runnerJob.Runner)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
const jobUpdateBuilders: {
[id in RunnerJobType]?: (payload: RunnerJobUpdatePayload, files?: UploadFiles) => RunnerJobUpdatePayload
} = {
'live-rtmp-hls-transcoding': (payload: LiveRTMPHLSTranscodingUpdatePayload, files) => {
return {
...payload,
masterPlaylistFile: files['payload[masterPlaylistFile]']?.[0].path,
resolutionPlaylistFile: files['payload[resolutionPlaylistFile]']?.[0].path,
videoChunkFile: files['payload[videoChunkFile]']?.[0].path
}
}
}
async function updateRunnerJobController (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const body: RunnerJobUpdateBody = req.body
if (runnerJob.state === RunnerJobState.COMPLETING || runnerJob.state === RunnerJobState.COMPLETED) {
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
const payloadBuilder = jobUpdateBuilders[runnerJob.type]
const updatePayload = payloadBuilder
? payloadBuilder(body.payload, req.files as UploadFiles)
: undefined
logger.debug(
'Remote runner %s is updating job %s (%s)',
runnerJob.Runner.name,
runnerJob.uuid,
runnerJob.type,
{ body, updatePayload, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
)
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().update({
runnerJob,
progress: req.body.progress,
updatePayload
})
updateLastRunnerContact(req, runnerJob.Runner)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
const jobSuccessPayloadBuilders: {
[id in RunnerJobType]: (payload: RunnerJobSuccessPayload, files?: UploadFiles) => RunnerJobSuccessPayload
} = {
'vod-web-video-transcoding': (payload: VODWebVideoTranscodingSuccess, files) => {
return {
...payload,
videoFile: files['payload[videoFile]'][0].path
}
},
'vod-hls-transcoding': (payload: VODHLSTranscodingSuccess, files) => {
return {
...payload,
videoFile: files['payload[videoFile]'][0].path,
resolutionPlaylistFile: files['payload[resolutionPlaylistFile]'][0].path
}
},
'vod-audio-merge-transcoding': (payload: VODAudioMergeTranscodingSuccess, files) => {
return {
...payload,
videoFile: files['payload[videoFile]'][0].path
}
},
'video-studio-transcoding': (payload: VideoStudioTranscodingSuccess, files) => {
return {
...payload,
videoFile: files['payload[videoFile]'][0].path
}
},
'live-rtmp-hls-transcoding': () => ({}),
'video-transcription': (payload: TranscriptionSuccess, files) => {
return {
...payload,
vttFile: files['payload[vttFile]'][0].path
}
},
'generate-video-storyboard': (payload: GenerateStoryboardSuccess, files) => {
return {
...payload,
storyboardFile: files['payload[storyboardFile]'][0].path
}
}
}
async function postRunnerJobSuccess (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
const runner = runnerJob.Runner
const body: RunnerJobSuccessBody = req.body
const resultPayload = jobSuccessPayloadBuilders[runnerJob.type](body.payload, req.files as UploadFiles)
logger.info(
'Remote runner %s is sending success result for job %s (%s)',
runnerJob.Runner.name,
runnerJob.uuid,
runnerJob.type,
{ resultPayload, ...lTags(runner.name, runnerJob.uuid, runnerJob.type) }
)
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().complete({ runnerJob, resultPayload })
updateLastRunnerContact(req, runnerJob.Runner)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
// Controllers for admins
// ---------------------------------------------------------------------------
async function cancelRunnerJob (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
logger.info('Cancelling job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().cancel({ runnerJob })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteRunnerJob (req: express.Request, res: express.Response) {
const runnerJob = res.locals.runnerJob
logger.info('Deleting job %s (%s)', runnerJob.uuid, runnerJob.type, lTags(runnerJob.uuid, runnerJob.type))
if (runnerJobCanBeCancelled(runnerJob)) {
const RunnerJobHandler = getRunnerJobHandlerClass(runnerJob)
await new RunnerJobHandler().cancel({ runnerJob })
}
await runnerJob.destroy()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listRunnerJobs (req: express.Request, res: express.Response) {
const query: ListRunnerJobsQuery = req.query
const resultList = await RunnerJobModel.listForApi({
start: query.start,
count: query.count,
sort: query.sort,
search: query.search,
stateOneOf: query.stateOneOf
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedAdminJSON())
})
}

View File

@@ -0,0 +1,122 @@
import { HttpStatusCode, ListRunnersQuery, RegisterRunnerBody, UserRight } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { generateRunnerToken } from '@server/helpers/token-generator.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
runnersSortValidator,
setDefaultPagination,
setDefaultSort
} from '@server/middlewares/index.js'
import {
deleteRunnerValidator,
getRunnerFromTokenValidator,
registerRunnerValidator
} from '@server/middlewares/validators/runners/index.js'
import { RunnerModel } from '@server/models/runner/runner.js'
import express from 'express'
const lTags = loggerTagsFactory('api', 'runner')
const manageRunnersRouter = express.Router()
manageRunnersRouter.post(
'/register',
apiRateLimiter,
asyncMiddleware(registerRunnerValidator),
asyncMiddleware(registerRunner)
)
manageRunnersRouter.post(
'/unregister',
apiRateLimiter,
asyncMiddleware(getRunnerFromTokenValidator),
asyncMiddleware(unregisterRunner)
)
manageRunnersRouter.delete(
'/:runnerId',
apiRateLimiter,
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(deleteRunnerValidator),
asyncMiddleware(deleteRunner)
)
manageRunnersRouter.get(
'/',
apiRateLimiter,
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
paginationValidator,
runnersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listRunners)
)
// ---------------------------------------------------------------------------
export {
manageRunnersRouter
}
// ---------------------------------------------------------------------------
async function registerRunner (req: express.Request, res: express.Response) {
const body: RegisterRunnerBody = req.body
const runnerToken = generateRunnerToken()
const runner = new RunnerModel({
runnerToken,
name: body.name,
description: body.description,
lastContact: new Date(),
ip: req.ip,
version: body.version,
runnerRegistrationTokenId: res.locals.runnerRegistrationToken.id
})
await runner.save()
logger.info('Registered new runner %s', runner.name, { ...lTags(runner.name) })
return res.json({ id: runner.id, runnerToken })
}
async function unregisterRunner (req: express.Request, res: express.Response) {
const runner = res.locals.runner
await runner.destroy()
logger.info('Unregistered runner %s', runner.name, { ...lTags(runner.name) })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteRunner (req: express.Request, res: express.Response) {
const runner = res.locals.runner
await runner.destroy()
logger.info('Deleted runner %s', runner.name, { ...lTags(runner.name) })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listRunners (req: express.Request, res: express.Response) {
const query: ListRunnersQuery = req.query
const resultList = await RunnerModel.listForApi({
start: query.start,
count: query.count,
sort: query.sort
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedJSON())
})
}

View File

@@ -0,0 +1,91 @@
import express from 'express'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { generateRunnerRegistrationToken } from '@server/helpers/token-generator.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
runnerRegistrationTokensSortValidator,
setDefaultPagination,
setDefaultSort
} from '@server/middlewares/index.js'
import { deleteRegistrationTokenValidator } from '@server/middlewares/validators/runners/index.js'
import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token.js'
import { HttpStatusCode, ListRunnerRegistrationTokensQuery, UserRight } from '@peertube/peertube-models'
const lTags = loggerTagsFactory('api', 'runner')
const runnerRegistrationTokensRouter = express.Router()
runnerRegistrationTokensRouter.post('/registration-tokens/generate',
apiRateLimiter,
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(generateRegistrationToken)
)
runnerRegistrationTokensRouter.delete('/registration-tokens/:id',
apiRateLimiter,
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
asyncMiddleware(deleteRegistrationTokenValidator),
asyncMiddleware(deleteRegistrationToken)
)
runnerRegistrationTokensRouter.get('/registration-tokens',
apiRateLimiter,
authenticate,
ensureUserHasRight(UserRight.MANAGE_RUNNERS),
paginationValidator,
runnerRegistrationTokensSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listRegistrationTokens)
)
// ---------------------------------------------------------------------------
export {
runnerRegistrationTokensRouter
}
// ---------------------------------------------------------------------------
async function generateRegistrationToken (req: express.Request, res: express.Response) {
logger.info('Generating new runner registration token.', lTags())
const registrationToken = new RunnerRegistrationTokenModel({
registrationToken: generateRunnerRegistrationToken()
})
await registrationToken.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteRegistrationToken (req: express.Request, res: express.Response) {
logger.info('Removing runner registration token.', lTags())
const runnerRegistrationToken = res.locals.runnerRegistrationToken
await runnerRegistrationToken.destroy()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listRegistrationTokens (req: express.Request, res: express.Response) {
const query: ListRunnerRegistrationTokensQuery = req.query
const resultList = await RunnerRegistrationTokenModel.listForApi({
start: query.start,
count: query.count,
sort: query.sort
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedJSON())
})
}

View File

@@ -0,0 +1,19 @@
import express from 'express'
import { apiRateLimiter } from '@server/middlewares/index.js'
import { searchChannelsRouter } from './search-video-channels.js'
import { searchPlaylistsRouter } from './search-video-playlists.js'
import { searchVideosRouter } from './search-videos.js'
const searchRouter = express.Router()
searchRouter.use(apiRateLimiter)
searchRouter.use('/', searchVideosRouter)
searchRouter.use('/', searchChannelsRouter)
searchRouter.use('/', searchPlaylistsRouter)
// ---------------------------------------------------------------------------
export {
searchRouter
}

View File

@@ -0,0 +1,152 @@
import express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils.js'
import { pickSearchChannelQuery } from '@server/helpers/query.js'
import { doJSONRequest } from '@server/helpers/requests.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { findLatestAPRedirection } from '@server/lib/activitypub/activity.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search.js'
import { getServerActor } from '@server/models/application/application.js'
import { HttpStatusCode, ResultList, VideoChannel, VideoChannelsSearchQueryAfterSanitize } from '@peertube/peertube-models'
import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors/index.js'
import {
asyncMiddleware,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videoChannelsListSearchValidator,
videoChannelsSearchSortValidator
} from '../../../middlewares/index.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { MChannelAccountDefault } from '../../../types/models/index.js'
import { searchLocalUrl } from './shared/index.js'
const searchChannelsRouter = express.Router()
searchChannelsRouter.get(
'/video-channels',
openapiOperationDoc({ operationId: 'searchChannels' }),
paginationValidator,
setDefaultPagination,
videoChannelsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
videoChannelsListSearchValidator,
asyncMiddleware(searchVideoChannels)
)
// ---------------------------------------------------------------------------
export { searchChannelsRouter }
// ---------------------------------------------------------------------------
function searchVideoChannels (req: express.Request, res: express.Response) {
const query = pickSearchChannelQuery(req.query)
const search = query.search || ''
const parts = search.split('@')
// Handle strings like @toto@example.com
if (parts.length === 3 && parts[0].length === 0) parts.shift()
const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, res)
// @username -> username to search in DB
if (search.startsWith('@')) query.search = search.replace(/^@/, '')
if (isSearchIndexSearch(query)) {
return searchVideoChannelsIndex(query, res)
}
return searchVideoChannelsDB(query, res)
}
async function searchVideoChannelsIndex (query: VideoChannelsSearchQueryAfterSanitize, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
try {
logger.debug('Doing video channels search index request on %s.', url, { body })
const searchIndexResult = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-channels.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video channels search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video channels search'
})
}
}
async function searchVideoChannelsDB (query: VideoChannelsSearchQueryAfterSanitize, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
...query,
actorId: serverActor.id
}, 'filter:api.search.video-channels.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoChannelModel.listForApi.bind(VideoChannelModel),
apiOptions,
'filter:api.search.video-channels.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoChannelURI (search: string, res: express.Response) {
let videoChannel: MChannelAccountDefault
let uri = search
if (!isURISearch(search)) {
try {
uri = await loadActorUrlOrGetFromWebfinger(search)
} catch (err) {
logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
return res.json({ total: 0, data: [] })
}
}
if (isUserAbleToSearchRemoteURI(res)) {
try {
const latestUri = await findLatestAPRedirection(uri)
const actor = await getOrCreateAPActor(latestUri, 'all', true, true)
videoChannel = actor.VideoChannel
} catch (err) {
logger.info('Cannot search remote video channel %s.', uri, { err })
}
} else {
videoChannel = await searchLocalUrl(sanitizeLocalUrl(uri), url => VideoChannelModel.loadByUrlAndPopulateAccount(url))
}
return res.json({
total: videoChannel ? 1 : 0,
data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative channel URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/')
}

View File

@@ -0,0 +1,131 @@
import express from 'express'
import { sanitizeUrl } from '@server/helpers/core-utils.js'
import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils.js'
import { logger } from '@server/helpers/logger.js'
import { pickSearchPlaylistQuery } from '@server/helpers/query.js'
import { doJSONRequest } from '@server/helpers/requests.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { findLatestAPRedirection } from '@server/lib/activitypub/activity.js'
import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { MVideoPlaylistFullSummary } from '@server/types/models/index.js'
import { HttpStatusCode, ResultList, VideoPlaylist, VideoPlaylistsSearchQueryAfterSanitize } from '@peertube/peertube-models'
import {
asyncMiddleware,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videoPlaylistsListSearchValidator,
videoPlaylistsSearchSortValidator
} from '../../../middlewares/index.js'
import { searchLocalUrl } from './shared/index.js'
const searchPlaylistsRouter = express.Router()
searchPlaylistsRouter.get('/video-playlists',
openapiOperationDoc({ operationId: 'searchPlaylists' }),
paginationValidator,
setDefaultPagination,
videoPlaylistsSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
videoPlaylistsListSearchValidator,
asyncMiddleware(searchVideoPlaylists)
)
// ---------------------------------------------------------------------------
export { searchPlaylistsRouter }
// ---------------------------------------------------------------------------
function searchVideoPlaylists (req: express.Request, res: express.Response) {
const query = pickSearchPlaylistQuery(req.query)
const search = query.search
if (isURISearch(search)) return searchVideoPlaylistsURI(search, res)
if (isSearchIndexSearch(query)) {
return searchVideoPlaylistsIndex(query, res)
}
return searchVideoPlaylistsDB(query, res)
}
async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQueryAfterSanitize, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists'
try {
logger.debug('Doing video playlists search index request on %s.', url, { body })
const searchIndexResult = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.video-playlists.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video playlists search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video playlists search'
})
}
}
async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQueryAfterSanitize, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
...query,
followerActorId: serverActor.id
}, 'filter:api.search.video-playlists.local.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoPlaylistModel.searchForApi.bind(VideoPlaylistModel),
apiOptions,
'filter:api.search.video-playlists.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function searchVideoPlaylistsURI (search: string, res: express.Response) {
let videoPlaylist: MVideoPlaylistFullSummary
if (isUserAbleToSearchRemoteURI(res)) {
try {
const url = await findLatestAPRedirection(search)
videoPlaylist = await getOrCreateAPVideoPlaylist(url)
} catch (err) {
logger.info('Cannot search remote video playlist %s.', search, { err })
}
} else {
videoPlaylist = await searchLocalUrl(sanitizeLocalUrl(search), url => VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(url))
}
return res.json({
total: videoPlaylist ? 1 : 0,
data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative channel URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/')
.replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/')
}

View File

@@ -0,0 +1,173 @@
import { HttpStatusCode, ResultList, Video, VideosSearchQueryAfterSanitize } from '@peertube/peertube-models'
import { sanitizeUrl } from '@server/helpers/core-utils.js'
import { pickSearchVideoQuery } from '@server/helpers/query.js'
import { doJSONRequest } from '@server/helpers/requests.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { findLatestAPRedirection } from '@server/lib/activitypub/activity.js'
import { getOrCreateAPVideo } from '@server/lib/activitypub/videos/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
asyncMiddleware,
commonVideosFiltersValidatorFactory,
openapiOperationDoc,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSearchSort,
videosSearchSortValidator,
videosSearchValidator
} from '../../../middlewares/index.js'
import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter/index.js'
import { VideoModel } from '../../../models/video/video.js'
import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models/index.js'
import { searchLocalUrl } from './shared/index.js'
const searchVideosRouter = express.Router()
searchVideosRouter.get(
'/videos',
openapiOperationDoc({ operationId: 'searchVideos' }),
paginationValidator,
setDefaultPagination,
videosSearchSortValidator,
setDefaultSearchSort,
optionalAuthenticate,
commonVideosFiltersValidatorFactory(),
videosSearchValidator,
asyncMiddleware(searchVideos)
)
// ---------------------------------------------------------------------------
export { searchVideosRouter }
// ---------------------------------------------------------------------------
function searchVideos (req: express.Request, res: express.Response) {
const query = pickSearchVideoQuery(req.query)
const search = query.search
if (isURISearch(search)) {
return searchVideoURI(search, res)
}
if (isSearchIndexSearch(query)) {
return searchVideosIndex(query, res)
}
return searchVideosDB(query, req, res)
}
async function searchVideosIndex (query: VideosSearchQueryAfterSanitize, res: express.Response) {
const result = await buildMutedForSearchIndex(res)
let body = { ...query, ...result }
// Use the default instance NSFW policy if not specified
if (!body.nsfw) {
const nsfwPolicy = res.locals.oauth
? res.locals.oauth.token.User.nsfwPolicy
: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
body.nsfw = nsfwPolicy === 'do_not_list'
? 'false'
: 'both'
}
body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
try {
logger.debug('Doing videos search index request on %s.', url, { body })
const searchIndexResult = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body, preventSSRF: false })
const jsonResult = await Hooks.wrapObject(searchIndexResult.body, 'filter:api.search.videos.index.list.result')
return res.json(jsonResult)
} catch (err) {
logger.warn('Cannot use search index to make video search.', { err })
return res.fail({
status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
message: 'Cannot use search index to make video search'
})
}
}
async function searchVideosDB (query: VideosSearchQueryAfterSanitize, req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject(
{
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
countVideos: getCountVideos(req),
user: res.locals.oauth
? res.locals.oauth.token.User
: undefined
},
'filter:api.search.videos.local.list.params',
{ req, res }
)
const resultList = await Hooks.wrapPromiseFun(
VideoModel.searchAndPopulateAccountAndServer.bind(VideoModel),
apiOptions,
'filter:api.search.videos.local.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
}
async function searchVideoURI (url: string, res: express.Response) {
let video: MVideoAccountLightBlacklistAllFiles
// Check if we can fetch a remote video with the URL
if (isUserAbleToSearchRemoteURI(res)) {
try {
const syncParam = {
rates: false,
shares: false,
comments: false,
refreshVideo: false
}
const result = await getOrCreateAPVideo({
videoObject: await findLatestAPRedirection(url),
syncParam
})
video = result ? result.video : undefined
} catch (err) {
logger.info('Cannot search remote video %s.', url, { err })
}
} else {
video = await searchLocalUrl(sanitizeLocalUrl(url), url => VideoModel.loadByUrlAndPopulateAccountAndFiles(url))
}
return res.json({
total: video ? 1 : 0,
data: video ? [ video.toFormattedJSON() ] : []
})
}
function sanitizeLocalUrl (url: string) {
if (!url) return ''
// Handle alternative video URLs
return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/')
}

View File

@@ -0,0 +1 @@
export * from './utils.js'

View File

@@ -0,0 +1,16 @@
async function searchLocalUrl <T> (url: string, finder: (url: string) => Promise<T>) {
const data = await finder(url)
if (data) return data
return finder(removeQueryParams(url))
}
export {
searchLocalUrl
}
// ---------------------------------------------------------------------------
function removeQueryParams (url: string) {
return url.split('?').shift()
}

View File

@@ -0,0 +1,30 @@
import express from 'express'
import { ContactForm, HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { Emailer } from '../../../lib/emailer.js'
import { Redis } from '../../../lib/redis.js'
import { asyncMiddleware, contactAdministratorValidator } from '../../../middlewares/index.js'
const contactRouter = express.Router()
contactRouter.post('/contact', asyncMiddleware(contactAdministratorValidator), asyncMiddleware(contactAdministrator))
async function contactAdministrator (req: express.Request, res: express.Response) {
const data = req.body as ContactForm
Emailer.Instance.addContactFormJob({ fromEmail: data.fromEmail, fromName: data.fromName, subject: data.subject, body: data.body })
try {
await Redis.Instance.setContactFormIp(req.ip)
} catch (err) {
logger.error(err)
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
// ---------------------------------------------------------------------------
export {
contactRouter
}

View File

@@ -0,0 +1,189 @@
import {
Debug,
HttpStatusCode,
SendDebugCommand,
SendDebugTestEmails,
UserExportState,
UserImportResultSummary,
UserRegistrationState,
UserRight
} from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { InboxManager } from '@server/lib/activitypub/inbox-manager.js'
import { Emailer } from '@server/lib/emailer.js'
import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler.js'
import { RemoveExpiredUserExportsScheduler } from '@server/lib/schedulers/remove-expired-user-exports-scheduler.js'
import { UpdateVideosScheduler } from '@server/lib/schedulers/update-videos-scheduler.js'
import { VideoChannelSyncLatestScheduler } from '@server/lib/schedulers/video-channel-sync-latest-scheduler.js'
import { VideoViewsBufferScheduler } from '@server/lib/schedulers/video-views-buffer-scheduler.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import express from 'express'
import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares/index.js'
import { RemoveOldViewsScheduler } from '@server/lib/schedulers/remove-old-views-scheduler.js'
const debugRouter = express.Router()
debugRouter.get(
'/debug',
authenticate,
ensureUserHasRight(UserRight.MANAGE_DEBUG),
getDebug
)
debugRouter.post(
'/debug/run-command',
authenticate,
ensureUserHasRight(UserRight.MANAGE_DEBUG),
asyncMiddleware(runCommand)
)
// ---------------------------------------------------------------------------
export {
debugRouter
}
// ---------------------------------------------------------------------------
function getDebug (req: express.Request, res: express.Response) {
return res.json({
ip: req.ip,
activityPubMessagesWaiting: InboxManager.Instance.getActivityPubMessagesWaiting()
} as Debug)
}
async function runCommand (req: express.Request, res: express.Response) {
const body: SendDebugCommand = req.body
const processors: { [id in SendDebugCommand['command']]: () => Promise<any> } = {
'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(),
'remove-expired-user-exports': () => RemoveExpiredUserExportsScheduler.Instance.execute(),
'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(),
'process-video-viewers': () => VideoViewsManager.Instance.processViewerStats(),
'process-update-videos-scheduler': () => UpdateVideosScheduler.Instance.execute(),
'process-video-channel-sync-latest': () => VideoChannelSyncLatestScheduler.Instance.execute(),
'process-remove-old-views': () => RemoveOldViewsScheduler.Instance.execute(),
'test-emails': () => testEmails(req, res)
}
if (!processors[body.command]) {
return res.fail({ message: 'Invalid command' })
}
await processors[body.command]()
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function testEmails (req: express.Request, res: express.Response) {
const email = (req.body as SendDebugTestEmails).email
if (!email) {
return res.fail({ message: 'Email is required for test-emails command' })
}
if (!email.includes('@')) {
return res.fail({ message: 'Invalid email format for test-emails command' })
}
const fakeUrl = WEBSERVER.URL + '/test'
const language = CONFIG.INSTANCE.DEFAULT_LANGUAGE
const to = { email, language }
Emailer.Instance.addPasswordResetEmailJob({ username: 'test-username', to: email, language, resetPasswordUrl: fakeUrl })
Emailer.Instance.addPasswordCreateEmailJob({ username: 'test-username', to: email, language, createPasswordUrl: fakeUrl })
Emailer.Instance.addUserVerifyChangeEmailJob({ username: 'test-username', to: email, language, verifyEmailUrl: fakeUrl })
{
Emailer.Instance.addRegistrationVerifyEmailJob({
username: 'test-username',
isRegistrationRequest: true,
to: email,
language,
verifyEmailUrl: fakeUrl
})
Emailer.Instance.addRegistrationVerifyEmailJob({
username: 'test-username',
isRegistrationRequest: false,
to: email,
language,
verifyEmailUrl: fakeUrl
})
}
{
const user = { username: 'test-username', email, language }
Emailer.Instance.addUserBlockJob({ ...user, blocked: true, reason: 'Test reason for blocking' })
Emailer.Instance.addUserBlockJob({ ...user, blocked: false, reason: 'Test reason for blocking' })
Emailer.Instance.addUserBlockJob({ ...user, blocked: true })
Emailer.Instance.addUserBlockJob({ ...user, blocked: false })
}
Emailer.Instance.addContactFormJob({ body: 'test contact form', fromEmail: email, fromName: 'Test User', subject: 'Test Subject' })
{
Emailer.Instance.addUserRegistrationRequestProcessedJob({
username: 'test-username',
state: UserRegistrationState.ACCEPTED,
email,
moderationResponse: 'Test moderation response'
})
Emailer.Instance.addUserRegistrationRequestProcessedJob({
username: 'test-username',
state: UserRegistrationState.REJECTED,
email,
moderationResponse: 'Test moderation response'
})
}
{
await Emailer.Instance.addUserExportCompletedOrErroredJob({ error: 'error', state: UserExportState.ERRORED, userId: 1 }, to)
await Emailer.Instance.addUserExportCompletedOrErroredJob({ error: null, state: UserExportState.COMPLETED, userId: 1 }, to)
}
await Emailer.Instance.addUserImportErroredJob({ error: 'Test error', userId: 1 }, to)
{
const summary = {
success: 1,
duplicates: 2,
errors: 3
}
const resultSummary: UserImportResultSummary = {
stats: {
blocklist: summary,
channels: summary,
likes: summary,
dislikes: summary,
following: summary,
videoPlaylists: summary,
videos: summary,
account: summary,
userSettings: summary,
userVideoHistory: summary,
watchedWordsLists: summary,
commentAutoTagPolicies: summary
}
}
await Emailer.Instance.addUserImportSuccessJob({ resultSummary, userId: 1 }, to)
}
{
const user = { username: 'test-username', email, language }
Emailer.Instance.addUserBlockJob({ ...user, blocked: true, reason: 'Test reason for blocking' })
Emailer.Instance.addUserBlockJob({ ...user, blocked: false })
}
logger.info(`Sent test email to ${email}`)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,217 @@
import express from 'express'
import { HttpStatusCode, ServerFollowCreate, UserRight } from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow.js'
import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send/index.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import { removeRedundanciesOfServer } from '../../../lib/redundancy.js'
import {
asyncMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
setBodyHostsPort,
setDefaultPagination,
setDefaultSort
} from '../../../middlewares/index.js'
import {
acceptFollowerValidator,
followValidator,
getFollowerValidator,
instanceFollowersSortValidator,
instanceFollowingSortValidator,
listFollowsValidator,
rejectFollowerValidator,
removeFollowingValidator
} from '../../../middlewares/validators/index.js'
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
const serverFollowsRouter = express.Router()
serverFollowsRouter.get(
'/following',
listFollowsValidator,
paginationValidator,
instanceFollowingSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listFollowing)
)
serverFollowsRouter.post(
'/following',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
followValidator,
setBodyHostsPort,
asyncMiddleware(addFollow)
)
serverFollowsRouter.delete(
'/following/:hostOrHandle',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
asyncMiddleware(removeFollowingValidator),
asyncMiddleware(removeFollowing)
)
serverFollowsRouter.get(
'/followers',
listFollowsValidator,
paginationValidator,
instanceFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listFollowers)
)
serverFollowsRouter.delete(
'/followers/:handle',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
asyncMiddleware(getFollowerValidator),
asyncMiddleware(removeFollower)
)
serverFollowsRouter.post(
'/followers/:handle/reject',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
asyncMiddleware(getFollowerValidator),
rejectFollowerValidator,
asyncMiddleware(rejectFollower)
)
serverFollowsRouter.post(
'/followers/:handle/accept',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
asyncMiddleware(getFollowerValidator),
acceptFollowerValidator,
asyncMiddleware(acceptFollower)
)
// ---------------------------------------------------------------------------
export {
serverFollowsRouter
}
// ---------------------------------------------------------------------------
async function listFollowing (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listInstanceFollowingForApi({
followerId: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
actorType: req.query.actorType,
state: req.query.state
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listFollowers (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listFollowersForApi({
actorIds: [ serverActor.id ],
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
actorType: req.query.actorType,
state: req.query.state
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function addFollow (req: express.Request, res: express.Response) {
const { hosts, handles } = req.body as ServerFollowCreate
const follower = await getServerActor()
for (const host of hosts) {
const payload = {
host,
followerActorId: follower.id
}
JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
}
for (const handle of handles) {
const [ name, host ] = handle.split('@')
const payload = {
host,
name,
followerActorId: follower.id
}
JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function removeFollowing (req: express.Request, res: express.Response) {
const follow = res.locals.follow
await sequelizeTypescript.transaction(async t => {
if (follow.state === 'accepted') sendUndoFollow(follow, t)
// Disable redundancy on unfollowed instances
const server = follow.ActorFollowing.Server
server.redundancyAllowed = false
await server.save({ transaction: t })
// Async, could be long
removeRedundanciesOfServer(server.id)
.catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
await follow.destroy({ transaction: t })
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function rejectFollower (req: express.Request, res: express.Response) {
const follow = res.locals.follow
follow.state = 'rejected'
await follow.save()
sendReject(follow.url, follow.ActorFollower, follow.ActorFollowing)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function removeFollower (req: express.Request, res: express.Response) {
const follow = res.locals.follow
if (follow.state === 'accepted' || follow.state === 'pending') {
sendReject(follow.url, follow.ActorFollower, follow.ActorFollowing)
}
await follow.destroy()
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function acceptFollower (req: express.Request, res: express.Response) {
const follow = res.locals.follow
sendAccept(follow)
follow.state = 'accepted'
await follow.save()
await autoFollowBackIfNeeded(follow)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,27 @@
import express from 'express'
import { apiRateLimiter } from '@server/middlewares/index.js'
import { contactRouter } from './contact.js'
import { debugRouter } from './debug.js'
import { serverFollowsRouter } from './follows.js'
import { logsRouter } from './logs.js'
import { serverRedundancyRouter } from './redundancy.js'
import { serverBlocklistRouter } from './server-blocklist.js'
import { statsRouter } from './stats.js'
const serverRouter = express.Router()
serverRouter.use(apiRateLimiter)
serverRouter.use('/', serverFollowsRouter)
serverRouter.use('/', serverRedundancyRouter)
serverRouter.use('/', statsRouter)
serverRouter.use('/', serverBlocklistRouter)
serverRouter.use('/', contactRouter)
serverRouter.use('/', logsRouter)
serverRouter.use('/', debugRouter)
// ---------------------------------------------------------------------------
export {
serverRouter
}

View File

@@ -0,0 +1,201 @@
import express from 'express'
import { readdir, readFile } from 'fs/promises'
import { join } from 'path'
import { pick } from '@peertube/peertube-core-utils'
import { ClientLogCreate, HttpStatusCode, ServerLogLevel, UserRight } from '@peertube/peertube-models'
import { isArray } from '@server/helpers/custom-validators/misc.js'
import { logger, mtimeSortFilesDesc } from '@server/helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { AUDIT_LOG_FILENAME, LOG_FILENAME, MAX_LOGS_OUTPUT_CHARACTERS } from '../../../initializers/constants.js'
import { asyncMiddleware, authenticate, buildRateLimiter, ensureUserHasRight, optionalAuthenticate } from '../../../middlewares/index.js'
import { createClientLogValidator, getAuditLogsValidator, getLogsValidator } from '../../../middlewares/validators/logs.js'
const createClientLogRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.WINDOW_MS,
max: CONFIG.RATES_LIMIT.RECEIVE_CLIENT_LOG.MAX
})
const logsRouter = express.Router()
logsRouter.post('/logs/client',
createClientLogRateLimiter,
optionalAuthenticate,
createClientLogValidator,
createClientLog
)
logsRouter.get('/logs',
authenticate,
ensureUserHasRight(UserRight.MANAGE_LOGS),
getLogsValidator,
asyncMiddleware(getLogs)
)
logsRouter.get('/audit-logs',
authenticate,
ensureUserHasRight(UserRight.MANAGE_LOGS),
getAuditLogsValidator,
asyncMiddleware(getAuditLogs)
)
// ---------------------------------------------------------------------------
export {
logsRouter
}
// ---------------------------------------------------------------------------
function createClientLog (req: express.Request, res: express.Response) {
const logInfo = req.body as ClientLogCreate
const meta = {
tags: [ 'client' ],
username: res.locals.oauth?.token?.User?.username,
...pick(logInfo, [ 'userAgent', 'stackTrace', 'meta', 'url' ])
}
logger.log(logInfo.level, `Client log: ${logInfo.message}`, meta)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
const auditLogNameFilter = generateLogNameFilter(AUDIT_LOG_FILENAME)
async function getAuditLogs (req: express.Request, res: express.Response) {
const output = await generateOutput({
startDateQuery: req.query.startDate,
endDateQuery: req.query.endDate,
level: 'audit',
nameFilter: auditLogNameFilter
})
return res.json(output).end()
}
const logNameFilter = generateLogNameFilter(LOG_FILENAME)
async function getLogs (req: express.Request, res: express.Response) {
const output = await generateOutput({
startDateQuery: req.query.startDate,
endDateQuery: req.query.endDate,
level: req.query.level || 'info',
tagsOneOf: req.query.tagsOneOf,
nameFilter: logNameFilter
})
return res.json(output)
}
async function generateOutput (options: {
startDateQuery: string
endDateQuery?: string
level: ServerLogLevel
nameFilter: RegExp
tagsOneOf?: string[]
}) {
const { startDateQuery, level, nameFilter } = options
const tagsOneOf = Array.isArray(options.tagsOneOf) && options.tagsOneOf.length !== 0
? new Set(options.tagsOneOf)
: undefined
const logFiles = await readdir(CONFIG.STORAGE.LOG_DIR)
const sortedLogFiles = await mtimeSortFilesDesc(logFiles, CONFIG.STORAGE.LOG_DIR)
let currentSize = 0
const startDate = new Date(startDateQuery)
const endDate = options.endDateQuery ? new Date(options.endDateQuery) : new Date()
let output: string[] = []
for (const meta of sortedLogFiles) {
if (nameFilter.exec(meta.file) === null) continue
const path = join(CONFIG.STORAGE.LOG_DIR, meta.file)
logger.debug('Opening %s to fetch logs.', path)
const result = await getOutputFromFile({ path, startDate, endDate, level, currentSize, tagsOneOf })
if (!result.output) break
output = result.output.concat(output)
currentSize = result.currentSize
if (currentSize > MAX_LOGS_OUTPUT_CHARACTERS || (result.logTime && result.logTime < startDate.getTime())) break
}
return output
}
async function getOutputFromFile (options: {
path: string
startDate: Date
endDate: Date
level: ServerLogLevel
currentSize: number
tagsOneOf: Set<string>
}) {
const { path, startDate, endDate, level, tagsOneOf } = options
const startTime = startDate.getTime()
const endTime = endDate.getTime()
let currentSize = options.currentSize
let logTime: number
const logsLevel: { [ id in ServerLogLevel ]: number } = {
audit: -1,
debug: 0,
info: 1,
warn: 2,
error: 3
}
const content = await readFile(path)
const lines = content.toString().split('\n')
const output: any[] = []
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
let log: any
try {
log = JSON.parse(line)
} catch {
// Maybe there a multiple \n at the end of the file
continue
}
logTime = new Date(log.timestamp).getTime()
if (
logTime >= startTime &&
logTime <= endTime &&
logsLevel[log.level] >= logsLevel[level] &&
(!tagsOneOf || lineHasTag(log, tagsOneOf))
) {
output.push(log)
currentSize += line.length
if (currentSize > MAX_LOGS_OUTPUT_CHARACTERS) break
} else if (logTime < startTime) {
break
}
}
return { currentSize, output: output.reverse(), logTime }
}
function lineHasTag (line: { tags?: string }, tagsOneOf: Set<string>) {
if (!isArray(line.tags)) return false
for (const lineTag of line.tags) {
if (tagsOneOf.has(lineTag)) return true
}
return false
}
function generateLogNameFilter (baseName: string) {
return new RegExp('^' + baseName.replace(/\.log$/, '') + '\\d*.log$')
}

View File

@@ -0,0 +1,115 @@
import express from 'express'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { JobQueue } from '@server/lib/job-queue/index.js'
import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy.js'
import { logger } from '../../../helpers/logger.js'
import { removeRedundanciesOfServer, removeVideoRedundancy } from '../../../lib/redundancy.js'
import {
asyncMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
setDefaultPagination,
setDefaultVideoRedundanciesSort,
videoRedundanciesSortValidator
} from '../../../middlewares/index.js'
import {
addVideoRedundancyValidator,
listVideoRedundanciesValidator,
removeVideoRedundancyValidator,
updateServerRedundancyValidator
} from '../../../middlewares/validators/redundancy.js'
const serverRedundancyRouter = express.Router()
serverRedundancyRouter.put('/redundancy/:host',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVER_FOLLOW),
asyncMiddleware(updateServerRedundancyValidator),
asyncMiddleware(updateRedundancy)
)
serverRedundancyRouter.get('/redundancy/videos',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
listVideoRedundanciesValidator,
paginationValidator,
videoRedundanciesSortValidator,
setDefaultVideoRedundanciesSort,
setDefaultPagination,
asyncMiddleware(listVideoRedundancies)
)
serverRedundancyRouter.post('/redundancy/videos',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
addVideoRedundancyValidator,
asyncMiddleware(addVideoRedundancy)
)
serverRedundancyRouter.delete('/redundancy/videos/:redundancyId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES),
removeVideoRedundancyValidator,
asyncMiddleware(removeVideoRedundancyController)
)
// ---------------------------------------------------------------------------
export {
serverRedundancyRouter
}
// ---------------------------------------------------------------------------
async function listVideoRedundancies (req: express.Request, res: express.Response) {
const resultList = await VideoRedundancyModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
target: req.query.target,
strategy: req.query.strategy
})
const result = {
total: resultList.total,
data: resultList.data.map(r => VideoRedundancyModel.toFormattedJSONStatic(r))
}
return res.json(result)
}
async function addVideoRedundancy (req: express.Request, res: express.Response) {
const payload = {
videoId: res.locals.onlyVideo.id
}
await JobQueue.Instance.createJob({
type: 'video-redundancy',
payload
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function removeVideoRedundancyController (req: express.Request, res: express.Response) {
await removeVideoRedundancy(res.locals.videoRedundancy)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateRedundancy (req: express.Request, res: express.Response) {
const server = res.locals.server
server.redundancyAllowed = req.body.redundancyAllowed
await server.save()
if (server.redundancyAllowed !== true) {
// Async, could be long
removeRedundanciesOfServer(server.id)
.catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,152 @@
import 'multer'
import express from 'express'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
addAccountInBlocklist,
addServerInBlocklist,
removeAccountFromBlocklist,
removeServerFromBlocklist
} from '../../../lib/blocklist.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../../middlewares/index.js'
import {
accountsBlocklistSortValidator,
blockAccountValidator,
blockServerValidator,
serversBlocklistSortValidator,
unblockAccountByServerValidator,
unblockServerByServerValidator
} from '../../../middlewares/validators/index.js'
import { AccountBlocklistModel } from '../../../models/account/account-blocklist.js'
import { ServerBlocklistModel } from '../../../models/server/server-blocklist.js'
const serverBlocklistRouter = express.Router()
serverBlocklistRouter.get('/blocklist/accounts',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
paginationValidator,
accountsBlocklistSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listBlockedAccounts)
)
serverBlocklistRouter.post('/blocklist/accounts',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
asyncMiddleware(blockAccountValidator),
asyncRetryTransactionMiddleware(blockAccount)
)
serverBlocklistRouter.delete('/blocklist/accounts/:accountName',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST),
asyncMiddleware(unblockAccountByServerValidator),
asyncRetryTransactionMiddleware(unblockAccount)
)
serverBlocklistRouter.get('/blocklist/servers',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
paginationValidator,
serversBlocklistSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listBlockedServers)
)
serverBlocklistRouter.post('/blocklist/servers',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
asyncMiddleware(blockServerValidator),
asyncRetryTransactionMiddleware(blockServer)
)
serverBlocklistRouter.delete('/blocklist/servers/:host',
authenticate,
ensureUserHasRight(UserRight.MANAGE_SERVERS_BLOCKLIST),
asyncMiddleware(unblockServerByServerValidator),
asyncRetryTransactionMiddleware(unblockServer)
)
export {
serverBlocklistRouter
}
// ---------------------------------------------------------------------------
async function listBlockedAccounts (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await AccountBlocklistModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
accountId: serverActor.Account.id
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function blockAccount (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const accountToBlock = res.locals.account
await addAccountInBlocklist({ byAccountId: serverActor.Account.id, targetAccountId: accountToBlock.id, removeNotificationOfUserId: null })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function unblockAccount (req: express.Request, res: express.Response) {
const accountBlock = res.locals.accountBlock
await removeAccountFromBlocklist(accountBlock)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listBlockedServers (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await ServerBlocklistModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
accountId: serverActor.Account.id
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function blockServer (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const serverToBlock = res.locals.server
await addServerInBlocklist({
byAccountId: serverActor.Account.id,
targetServerId: serverToBlock.id,
removeNotificationOfUserId: null
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function unblockServer (req: express.Request, res: express.Response) {
const serverBlock = res.locals.serverBlock
await removeServerFromBlocklist(serverBlock)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,26 @@
import express from 'express'
import { StatsManager } from '@server/lib/stat-manager.js'
import { ROUTE_CACHE_LIFETIME } from '../../../initializers/constants.js'
import { asyncMiddleware } from '../../../middlewares/index.js'
import { cacheRoute } from '../../../middlewares/cache/cache.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
const statsRouter = express.Router()
statsRouter.get('/stats',
cacheRoute(ROUTE_CACHE_LIFETIME.STATS),
asyncMiddleware(getStats)
)
async function getStats (_req: express.Request, res: express.Response) {
let data = await StatsManager.Instance.getStats()
data = await Hooks.wrapObject(data, 'filter:api.server.stats.get.result')
return res.json(data)
}
// ---------------------------------------------------------------------------
export {
statsRouter
}

View File

@@ -0,0 +1,85 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import express from 'express'
import { CONFIG } from '../../../initializers/config.js'
import { sendVerifyRegistrationEmail, sendVerifyRegistrationRequestEmail, sendVerifyUserChangeEmail } from '../../../lib/user.js'
import { asyncMiddleware, buildRateLimiter } from '../../../middlewares/index.js'
import {
registrationVerifyEmailValidator,
usersAskSendRegistrationVerifyEmailValidator,
usersAskSendUserVerifyEmailValidator,
usersVerifyEmailValidator
} from '../../../middlewares/validators/index.js'
const askSendEmailLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.WINDOW_MS,
max: CONFIG.RATES_LIMIT.ASK_SEND_EMAIL.MAX
})
const emailVerificationRouter = express.Router()
emailVerificationRouter.post(
'/ask-send-verify-email',
askSendEmailLimiter,
asyncMiddleware(usersAskSendUserVerifyEmailValidator),
asyncMiddleware(reSendUserVerifyUserEmail)
)
emailVerificationRouter.post(
'/registrations/ask-send-verify-email',
askSendEmailLimiter,
asyncMiddleware(usersAskSendRegistrationVerifyEmailValidator),
asyncMiddleware(reSendRegistrationVerifyUserEmail)
)
emailVerificationRouter.post('/:id/verify-email', asyncMiddleware(usersVerifyEmailValidator), asyncMiddleware(verifyUserEmail))
emailVerificationRouter.post(
'/registrations/:registrationId/verify-email',
asyncMiddleware(registrationVerifyEmailValidator),
asyncMiddleware(verifyRegistrationEmail)
)
// ---------------------------------------------------------------------------
export {
emailVerificationRouter
}
async function reSendUserVerifyUserEmail (req: express.Request, res: express.Response) {
if (res.locals.userPendingEmail) { // User wants to change its current email
await sendVerifyUserChangeEmail(res.locals.userPendingEmail)
} else { // After an account creation
await sendVerifyRegistrationEmail(res.locals.userEmail)
}
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function reSendRegistrationVerifyUserEmail (req: express.Request, res: express.Response) {
await sendVerifyRegistrationRequestEmail(res.locals.userRegistration)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function verifyUserEmail (req: express.Request, res: express.Response) {
const user = res.locals.user
user.emailVerified = true
if (req.body.isPendingEmail === true) {
user.email = user.pendingEmail
user.pendingEmail = null
}
await user.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function verifyRegistrationEmail (req: express.Request, res: express.Response) {
const registration = res.locals.userRegistration
registration.emailVerified = true
await registration.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,340 @@
import { pick } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserCreate, UserCreateResult, UserRight, UserUpdate } from '@peertube/peertube-models'
import { tokensRouter } from '@server/controllers/api/users/token.js'
import { getResetPasswordUrl } from '@server/lib/client-urls.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
import { MUserAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, UserAuditView } from '../../../helpers/audit-logger.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { generateRandomString, getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { Emailer } from '../../../lib/emailer.js'
import { Redis } from '../../../lib/redis.js'
import { buildUser, createUserAccountAndChannelAndPlaylist } from '../../../lib/user.js'
import {
adminUsersSortValidator,
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
setDefaultPagination,
setDefaultSort,
userAutocompleteValidator,
usersAddValidator,
usersGetValidator,
usersListValidator,
usersRemoveValidator,
usersUpdateValidator
} from '../../../middlewares/index.js'
import {
usersAskResetPasswordValidator,
usersBlockToggleValidator,
usersResetPasswordValidator
} from '../../../middlewares/validators/index.js'
import { UserModel } from '../../../models/user/user.js'
import { emailVerificationRouter } from './email-verification.js'
import { meRouter } from './me.js'
import { myAbusesRouter } from './my-abuses.js'
import { myBlocklistRouter } from './my-blocklist.js'
import { myVideosHistoryRouter } from './my-history.js'
import { myNotificationsRouter } from './my-notifications.js'
import { mySubscriptionsRouter } from './my-subscriptions.js'
import { myVideoPlaylistsRouter } from './my-video-playlists.js'
import { registrationsRouter } from './registrations.js'
import { twoFactorRouter } from './two-factor.js'
import { userExportsRouter } from './user-exports.js'
import { userImportRouter } from './user-imports.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
const auditLogger = auditLoggerFactory('users')
const lTags = loggerTagsFactory('api', 'users')
const usersRouter = express.Router()
usersRouter.use(apiRateLimiter)
usersRouter.use('/', emailVerificationRouter)
usersRouter.use('/', userExportsRouter)
usersRouter.use('/', userImportRouter)
usersRouter.use('/', registrationsRouter)
usersRouter.use('/', twoFactorRouter)
usersRouter.use('/', tokensRouter)
usersRouter.use('/', myNotificationsRouter)
usersRouter.use('/', mySubscriptionsRouter)
usersRouter.use('/', myBlocklistRouter)
usersRouter.use('/', myVideosHistoryRouter)
usersRouter.use('/', myVideoPlaylistsRouter)
usersRouter.use('/', myAbusesRouter)
usersRouter.use('/', meRouter)
usersRouter.get('/autocomplete', userAutocompleteValidator, asyncMiddleware(autocompleteUsers))
usersRouter.get(
'/',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
paginationValidator,
adminUsersSortValidator,
setDefaultSort,
setDefaultPagination,
usersListValidator,
asyncMiddleware(listUsers)
)
usersRouter.post(
'/:id/block',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(usersBlockToggleValidator),
asyncMiddleware(blockUser)
)
usersRouter.post(
'/:id/unblock',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(usersBlockToggleValidator),
asyncMiddleware(unblockUser)
)
usersRouter.get('/:id', authenticate, ensureUserHasRight(UserRight.MANAGE_USERS), asyncMiddleware(usersGetValidator), getUser)
usersRouter.post(
'/',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(usersAddValidator),
asyncRetryTransactionMiddleware(createUser)
)
usersRouter.put(
'/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(usersUpdateValidator),
asyncMiddleware(updateUser)
)
usersRouter.delete(
'/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_USERS),
asyncMiddleware(usersRemoveValidator),
asyncMiddleware(removeUser)
)
usersRouter.post('/ask-reset-password', asyncMiddleware(usersAskResetPasswordValidator), asyncMiddleware(askResetUserPassword))
usersRouter.post('/:id/reset-password', asyncMiddleware(usersResetPasswordValidator), asyncMiddleware(resetUserPassword))
// ---------------------------------------------------------------------------
export {
usersRouter
}
// ---------------------------------------------------------------------------
async function createUser (req: express.Request, res: express.Response) {
const body: UserCreate = req.body
const userToCreate = buildUser({
...pick(body, [ 'username', 'password', 'email', 'role', 'videoQuota', 'videoQuotaDaily', 'adminFlags' ]),
emailVerified: null
})
// NB: due to the validator usersAddValidator, password==='' can only be true if we can send the mail.
const createPassword = userToCreate.password === ''
if (createPassword) {
userToCreate.password = await generateRandomString(20)
}
const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
userToCreate,
channelNames: body.channelName && { name: body.channelName, displayName: body.channelName }
})
auditLogger.create(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
logger.info('User %s with its channel and account created.', body.username, lTags(user.username))
if (createPassword) {
// This will send an email for newly created users, so then can set their first password
logger.info('Sending to user %s a create password email', body.username, lTags(user.username))
const verificationString = await Redis.Instance.setCreatePasswordVerificationString(user.id)
Emailer.Instance.addPasswordCreateEmailJob({
username: userToCreate.username,
to: user.email,
language: user.getLanguage(),
createPasswordUrl: getResetPasswordUrl(user, verificationString)
})
}
Hooks.runAction('action:api.user.created', { body, user, account, videoChannel, req, res })
return res.json({
user: {
id: user.id,
account: {
id: account.id
}
} as UserCreateResult
})
}
async function unblockUser (req: express.Request, res: express.Response) {
const user = res.locals.user
const byUser = res.locals.oauth.token.User
await changeUserBlock(res, user, false)
logger.info(`Unblocked user ${user.username} by moderator ${byUser.username}.`, lTags(user.username, byUser.username))
Hooks.runAction('action:api.user.unblocked', { user, req, res })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function blockUser (req: express.Request, res: express.Response) {
const user = res.locals.user
const byUser = res.locals.oauth.token.User
const reason = req.body.reason
await changeUserBlock(res, user, true, reason)
logger.info(`Blocked user ${user.username} by moderator ${byUser.username}.`, lTags(user.username, byUser.username))
Hooks.runAction('action:api.user.blocked', { user, req, res })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
function getUser (req: express.Request, res: express.Response) {
return res.json(res.locals.user.toFormattedJSON({ withAdminFlags: true }))
}
async function autocompleteUsers (req: express.Request, res: express.Response) {
const resultList = await UserModel.autoComplete(req.query.search as string)
return res.json(resultList)
}
async function listUsers (req: express.Request, res: express.Response) {
const resultList = await UserModel.listForAdminApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
blocked: req.query.blocked
})
return res.json(getFormattedObjects(resultList.data, resultList.total, { withAdminFlags: true }))
}
async function removeUser (req: express.Request, res: express.Response) {
const user = res.locals.user
const byUser = res.locals.oauth.token.User
auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => {
// Use a transaction to avoid inconsistencies with hooks (account/channel deletion & federation)
return user.destroy({ transaction: t })
})
})
logger.info(`Removed user ${user.username} by moderator ${byUser.username}.`, lTags(user.username, byUser.username))
Hooks.runAction('action:api.user.deleted', { user, req, res })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateUser (req: express.Request, res: express.Response) {
const body: UserUpdate = req.body
const userToUpdate = res.locals.user
const byUser = res.locals.oauth.token.User
const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON())
const roleChanged = body.role !== undefined && body.role !== userToUpdate.role
const keysToUpdate: (keyof UserUpdate)[] = [
'password',
'email',
'emailVerified',
'videoQuota',
'videoQuotaDaily',
'role',
'adminFlags',
'pluginAuth'
]
for (const key of keysToUpdate) {
if (body[key] !== undefined) userToUpdate.set(key, body[key])
}
const user = await userToUpdate.save()
// Destroy user token to refresh rights
if (roleChanged || body.password !== undefined) await OAuthTokenModel.deleteUserToken(userToUpdate.id)
auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
logger.info(`Updated user ${user.username} by moderator ${byUser.username}.`, lTags(user.username, byUser.username))
Hooks.runAction('action:api.user.updated', { user, req, res })
// Don't need to send this update to followers, these attributes are not federated
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function askResetUserPassword (req: express.Request, res: express.Response) {
const user = res.locals.user
const verificationString = await Redis.Instance.setResetPasswordVerificationString(user.id)
Emailer.Instance.addPasswordResetEmailJob({
username: user.username,
to: user.email,
language: user.getLanguage(),
resetPasswordUrl: getResetPasswordUrl(user, verificationString)
})
logger.info(`User ${user.username} asked password reset.`, lTags(user.username))
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function resetUserPassword (req: express.Request, res: express.Response) {
const user = res.locals.user
user.password = req.body.password
await user.save()
await Redis.Instance.removePasswordVerificationString(user.id)
logger.info(`User ${user.username} reset its password.`, lTags(user.username))
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function changeUserBlock (res: express.Response, user: MUserAccountDefault, block: boolean, reason?: string) {
const oldUserAuditView = new UserAuditView(user.toFormattedJSON())
user.blocked = block
user.blockedReason = reason || null
await sequelizeTypescript.transaction(async t => {
await OAuthTokenModel.deleteUserToken(user.id, t)
await user.save({ transaction: t })
})
Emailer.Instance.addUserBlockJob({ username: user.username, email: user.email, language: user.getLanguage(), blocked: block, reason })
auditLogger.update(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()), oldUserAuditView)
}

View File

@@ -0,0 +1,383 @@
import { getAllPrivacies, pick } from '@peertube/peertube-core-utils'
import {
ActorImageType,
UserVideoRate as FormattedUserVideoRate,
HttpStatusCode,
UserNewFeatureInfoRead,
UserUpdateMe,
UserVideoQuota,
VideoInclude
} from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { UserAuditView, auditLoggerFactory, getAuditIdFromRes } from '@server/helpers/audit-logger.js'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-api-format.js'
import { VideoCommentModel } from '@server/models/video/video-comment.js'
import express from 'express'
import 'multer'
import { createReqFiles, getCountVideos } from '../../../helpers/express-utils.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { CONFIG } from '../../../initializers/config.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { sendUpdateActor } from '../../../lib/activitypub/send/index.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js'
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserChangeEmail } from '../../../lib/user.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort,
setDefaultVideosSort,
usersUpdateMeValidator,
usersVideoRatingValidator
} from '../../../middlewares/index.js'
import { updateAvatarValidator } from '../../../middlewares/validators/actor-image.js'
import {
commonVideosFiltersValidatorFactory,
deleteMeValidator,
listMyVideoImportsValidator,
listCommentsOnUserVideosValidator,
listMyVideosValidator,
videoImportsSortValidator,
videosSortValidator,
usersNewFeatureInfoReadValidator
} from '../../../middlewares/validators/index.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { AccountModel } from '../../../models/account/account.js'
import { UserModel } from '../../../models/user/user.js'
import { VideoImportModel } from '../../../models/video/video-import.js'
import { VideoModel } from '../../../models/video/video.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
const auditLogger = auditLoggerFactory('users')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const meRouter = express.Router()
meRouter.get('/me', authenticate, asyncMiddleware(getMyInformation))
meRouter.delete('/me', authenticate, deleteMeValidator, asyncMiddleware(deleteMe))
meRouter.get('/me/video-quota-used', authenticate, asyncMiddleware(getMyVideoQuotaUsed))
meRouter.get(
'/me/videos/imports',
authenticate,
paginationValidator,
videoImportsSortValidator,
setDefaultSort,
setDefaultPagination,
listMyVideoImportsValidator,
asyncMiddleware(listMyVideoImports)
)
meRouter.get(
'/me/videos/comments',
authenticate,
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
asyncMiddleware(listCommentsOnUserVideosValidator),
asyncMiddleware(listCommentsOnUserVideos)
)
meRouter.get(
'/me/videos',
authenticate,
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
commonVideosFiltersValidatorFactory({ allowPrivacyFilterForAllUsers: true }),
asyncMiddleware(listMyVideosValidator),
asyncMiddleware(listMyVideos)
)
meRouter.get(
'/me/videos/:videoId/rating',
authenticate,
asyncMiddleware(usersVideoRatingValidator),
asyncMiddleware(getMyVideoRating)
)
meRouter.put(
'/me',
authenticate,
asyncMiddleware(usersUpdateMeValidator),
asyncRetryTransactionMiddleware(updateMe)
)
meRouter.post(
'/me/avatar/pick',
authenticate,
reqAvatarFile,
updateAvatarValidator,
asyncRetryTransactionMiddleware(updateMyAvatar)
)
meRouter.delete(
'/me/avatar',
authenticate,
asyncRetryTransactionMiddleware(deleteMyAvatar)
)
meRouter.post(
'/me/new-feature-info/read',
authenticate,
usersNewFeatureInfoReadValidator,
asyncMiddleware(usersNewFeatureInfoRead)
)
// ---------------------------------------------------------------------------
export {
meRouter
}
// ---------------------------------------------------------------------------
async function listMyVideos (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const countVideos = getCountVideos(req)
const query = pickCommonVideoQuery(req.query)
const include = (query.include || VideoInclude.NONE) | VideoInclude.BLACKLISTED | VideoInclude.NOT_PUBLISHED_STATE |
VideoInclude.BLOCKED_OWNER
const apiOptions = await Hooks.wrapObject(
{
privacyOneOf: getAllPrivacies(),
...query,
// Display all
nsfw: null,
user,
accountId: user.Account.id,
displayOnlyForFollower: null,
videoChannelId: res.locals.videoChannel?.id,
channelNameOneOf: req.query.channelNameOneOf,
includeCollaborations: req.query.includeCollaborations || false,
countVideos,
include
} satisfies Parameters<typeof VideoModel.listForApi>[0],
'filter:api.user.me.videos.list.params'
)
const resultList = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
apiOptions,
'filter:api.user.me.videos.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery({ include })))
}
async function listCommentsOnUserVideos (req: express.Request, res: express.Response) {
const userAccount = res.locals.oauth.token.User.Account
const resultList = await VideoCommentModel.listForApi({
...pick(req.query, [
'start',
'count',
'sort',
'search',
'searchAccount',
'searchVideo',
'autoTagOneOf'
]),
autoTagOfAccountId: userAccount.id,
videoAccountOwnerId: userAccount.id,
videoAccountOwnerIncludeCollaborations: req.query.includeCollaborations || false,
heldForReview: req.query.isHeldForReview,
videoChannelOwnerId: res.locals.videoChannel?.id,
videoId: res.locals.videoAll?.id
})
return res.json({
total: resultList.total,
data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
})
}
async function listMyVideoImports (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const resultList = await VideoImportModel.listUserVideoImportsForApi({
userId: user.id,
collaborationAccountId: req.query.includeCollaborations
? user.Account.id
: undefined,
...pick(req.query, [ 'id', 'videoId', 'targetUrl', 'start', 'count', 'sort', 'search', 'videoChannelSyncId', 'includeCollaborations' ])
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function getMyInformation (req: express.Request, res: express.Response) {
// We did not load channels in res.locals.user
const user = await UserModel.loadForMeAPI(res.locals.oauth.token.user.id)
const result = await Hooks.wrapObject(
user.toMeFormattedJSON(),
'filter:api.user.me.get.result',
{ user }
)
return res.json(result)
}
async function getMyVideoQuotaUsed (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
const data: UserVideoQuota = {
videoQuotaUsed,
videoQuotaUsedDaily
}
return res.json(data)
}
async function getMyVideoRating (req: express.Request, res: express.Response) {
const videoId = res.locals.videoId.id
const accountId = +res.locals.oauth.token.User.Account.id
const ratingObj = await AccountVideoRateModel.load(accountId, videoId, null)
const rating = ratingObj ? ratingObj.type : 'none'
const json: FormattedUserVideoRate = {
videoId,
rating
}
return res.json(json)
}
async function deleteMe (req: express.Request, res: express.Response) {
const user = await UserModel.loadByIdWithChannels(res.locals.oauth.token.User.id)
auditLogger.delete(getAuditIdFromRes(res), new UserAuditView(user.toFormattedJSON()))
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(t => {
return user.destroy({ transaction: t })
})
})
Hooks.runAction('action:api.user.deleted', { user, req, res })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateMe (req: express.Request, res: express.Response) {
const body: UserUpdateMe = req.body
let sendVerificationEmail = false
const user = res.locals.oauth.token.user
const keysToUpdate: (keyof UserUpdateMe & keyof AttributesOnly<UserModel>)[] = [
'password',
'nsfwPolicy',
'nsfwFlagsDisplayed',
'nsfwFlagsHidden',
'nsfwFlagsWarned',
'nsfwFlagsBlurred',
'p2pEnabled',
'autoPlayVideo',
'autoPlayNextVideo',
'autoPlayNextVideoPlaylist',
'videosHistoryEnabled',
'videoLanguages',
'language',
'theme',
'noInstanceConfigWarningModal',
'noAccountSetupWarningModal',
'noWelcomeModal',
'emailPublic',
'p2pEnabled'
]
for (const key of keysToUpdate) {
if (body[key] !== undefined) user.set(key, body[key])
}
if (body.email !== undefined) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
user.pendingEmail = body.email
sendVerificationEmail = true
} else {
user.email = body.email
}
}
await sequelizeTypescript.transaction(async t => {
await user.save({ transaction: t })
if (body.displayName === undefined && body.description === undefined) return
const userAccount = await AccountModel.load(user.Account.id, t)
if (body.displayName !== undefined) userAccount.name = body.displayName
if (body.description !== undefined) userAccount.description = body.description
await userAccount.save({ transaction: t })
await sendUpdateActor(userAccount, t)
})
if (sendVerificationEmail === true) {
await sendVerifyUserChangeEmail(user)
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateMyAvatar (req: express.Request, res: express.Response) {
const avatarPhysicalFile = req.files['avatarfile'][0]
const user = res.locals.oauth.token.user
const userAccount = await AccountModel.load(user.Account.id)
const avatars = await updateLocalActorImageFiles({
accountOrChannel: userAccount,
imagePhysicalFile: avatarPhysicalFile,
type: ActorImageType.AVATAR,
sendActorUpdate: true
})
return res.json({
avatars: avatars.map(avatar => avatar.toFormattedJSON())
})
}
async function deleteMyAvatar (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
const userAccount = await AccountModel.load(user.Account.id)
await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
return res.json({ avatars: [] })
}
async function usersNewFeatureInfoRead (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
const body: UserNewFeatureInfoRead = req.body
user.newFeaturesInfoRead |= body.feature
await user.save()
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,48 @@
import express from 'express'
import { AbuseModel } from '@server/models/abuse/abuse.js'
import {
abuseListForUserValidator,
abusesSortValidator,
asyncMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../../middlewares/index.js'
const myAbusesRouter = express.Router()
myAbusesRouter.get('/me/abuses',
authenticate,
paginationValidator,
abusesSortValidator,
setDefaultSort,
setDefaultPagination,
abuseListForUserValidator,
asyncMiddleware(listMyAbuses)
)
// ---------------------------------------------------------------------------
export {
myAbusesRouter
}
// ---------------------------------------------------------------------------
async function listMyAbuses (req: express.Request, res: express.Response) {
const resultList = await AbuseModel.listForUserApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
id: req.query.id,
search: req.query.search,
state: req.query.state,
user: res.locals.oauth.token.User
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedUserJSON())
})
}

View File

@@ -0,0 +1,144 @@
import 'multer'
import express from 'express'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
addAccountInBlocklist,
addServerInBlocklist,
removeAccountFromBlocklist,
removeServerFromBlocklist
} from '../../../lib/blocklist.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort,
unblockAccountByAccountValidator
} from '../../../middlewares/index.js'
import {
accountsBlocklistSortValidator,
blockAccountValidator,
blockServerValidator,
serversBlocklistSortValidator,
unblockServerByAccountValidator
} from '../../../middlewares/validators/index.js'
import { AccountBlocklistModel } from '../../../models/account/account-blocklist.js'
import { ServerBlocklistModel } from '../../../models/server/server-blocklist.js'
const myBlocklistRouter = express.Router()
myBlocklistRouter.get('/me/blocklist/accounts',
authenticate,
paginationValidator,
accountsBlocklistSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listBlockedAccounts)
)
myBlocklistRouter.post('/me/blocklist/accounts',
authenticate,
asyncMiddleware(blockAccountValidator),
asyncRetryTransactionMiddleware(blockAccount)
)
myBlocklistRouter.delete('/me/blocklist/accounts/:accountName',
authenticate,
asyncMiddleware(unblockAccountByAccountValidator),
asyncRetryTransactionMiddleware(unblockAccount)
)
myBlocklistRouter.get('/me/blocklist/servers',
authenticate,
paginationValidator,
serversBlocklistSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listBlockedServers)
)
myBlocklistRouter.post('/me/blocklist/servers',
authenticate,
asyncMiddleware(blockServerValidator),
asyncRetryTransactionMiddleware(blockServer)
)
myBlocklistRouter.delete('/me/blocklist/servers/:host',
authenticate,
asyncMiddleware(unblockServerByAccountValidator),
asyncRetryTransactionMiddleware(unblockServer)
)
export {
myBlocklistRouter
}
// ---------------------------------------------------------------------------
async function listBlockedAccounts (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const resultList = await AccountBlocklistModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
accountId: user.Account.id
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function blockAccount (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const accountToBlock = res.locals.account
await addAccountInBlocklist({ byAccountId: user.Account.id, targetAccountId: accountToBlock.id, removeNotificationOfUserId: user.id })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function unblockAccount (req: express.Request, res: express.Response) {
const accountBlock = res.locals.accountBlock
await removeAccountFromBlocklist(accountBlock)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function listBlockedServers (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const resultList = await ServerBlocklistModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
accountId: user.Account.id
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function blockServer (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const serverToBlock = res.locals.server
await addServerInBlocklist({
byAccountId: user.Account.id,
targetServerId: serverToBlock.id,
removeNotificationOfUserId: user.id
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function unblockServer (req: express.Request, res: express.Response) {
const serverBlock = res.locals.serverBlock
await removeServerFromBlocklist(serverBlock)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,75 @@
import express from 'express'
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
userHistoryListValidator,
userHistoryRemoveAllValidator,
userHistoryRemoveElementValidator
} from '../../../middlewares/index.js'
import { UserVideoHistoryModel } from '../../../models/user/user-video-history.js'
const myVideosHistoryRouter = express.Router()
myVideosHistoryRouter.get('/me/history/videos',
authenticate,
paginationValidator,
setDefaultPagination,
userHistoryListValidator,
asyncMiddleware(listMyVideosHistory)
)
myVideosHistoryRouter.delete('/me/history/videos/:videoId',
authenticate,
userHistoryRemoveElementValidator,
asyncMiddleware(removeUserHistoryElement)
)
myVideosHistoryRouter.post('/me/history/videos/remove',
authenticate,
userHistoryRemoveAllValidator,
asyncRetryTransactionMiddleware(removeAllUserHistory)
)
// ---------------------------------------------------------------------------
export {
myVideosHistoryRouter
}
// ---------------------------------------------------------------------------
async function listMyVideosHistory (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const resultList = await UserVideoHistoryModel.listForApi(user, req.query.start, req.query.count, req.query.search)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function removeUserHistoryElement (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
await UserVideoHistoryModel.removeUserHistoryElement(user, forceNumber(req.params.videoId))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function removeAllUserHistory (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const beforeDate = req.body.beforeDate || null
await sequelizeTypescript.transaction(t => {
return UserVideoHistoryModel.removeUserHistoryBefore(user, beforeDate, t)
})
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}

View File

@@ -0,0 +1,119 @@
import { HttpStatusCode, UserNotificationListQuery, UserNotificationSetting } from '@peertube/peertube-models'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { UserNotificationModel } from '@server/models/user/user-notification.js'
import express from 'express'
import 'multer'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort,
userNotificationsSortValidator
} from '../../../middlewares/index.js'
import {
listUserNotificationsValidator,
markAsReadUserNotificationsValidator,
updateNotificationSettingsValidator
} from '../../../middlewares/validators/users/user-notifications.js'
import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting.js'
import { meRouter } from './me.js'
const myNotificationsRouter = express.Router()
meRouter.put(
'/me/notification-settings',
authenticate,
updateNotificationSettingsValidator,
asyncRetryTransactionMiddleware(updateNotificationSettings)
)
myNotificationsRouter.get(
'/me/notifications',
authenticate,
paginationValidator,
userNotificationsSortValidator,
setDefaultSort,
setDefaultPagination,
listUserNotificationsValidator,
asyncMiddleware(listUserNotifications)
)
myNotificationsRouter.post(
'/me/notifications/read',
authenticate,
markAsReadUserNotificationsValidator,
asyncMiddleware(markAsReadUserNotifications)
)
myNotificationsRouter.post('/me/notifications/read-all', authenticate, asyncMiddleware(markAsReadAllUserNotifications))
export {
myNotificationsRouter
}
// ---------------------------------------------------------------------------
async function updateNotificationSettings (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const body = req.body as UserNotificationSetting
const values: UserNotificationSetting = {
newVideoFromSubscription: body.newVideoFromSubscription,
newCommentOnMyVideo: body.newCommentOnMyVideo,
abuseAsModerator: body.abuseAsModerator,
videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator,
blacklistOnMyVideo: body.blacklistOnMyVideo,
myVideoPublished: body.myVideoPublished,
myVideoImportFinished: body.myVideoImportFinished,
newFollow: body.newFollow,
newUserRegistration: body.newUserRegistration,
commentMention: body.commentMention,
newInstanceFollower: body.newInstanceFollower,
autoInstanceFollowing: body.autoInstanceFollowing,
abuseNewMessage: body.abuseNewMessage,
abuseStateChange: body.abuseStateChange,
newPeerTubeVersion: body.newPeerTubeVersion,
newPluginVersion: body.newPluginVersion,
myVideoTranscriptionGenerated: body.myVideoTranscriptionGenerated,
myVideoStudioEditionFinished: body.myVideoStudioEditionFinished
}
await UserNotificationSettingModel.updateUserSettings(values, user.id)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listUserNotifications (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const query = req.query as UserNotificationListQuery
const resultList = await UserNotificationModel.listForApi({
userId: user.id,
start: query.start,
count: query.count,
sort: query.sort,
unread: query.unread,
typeOneOf: query.typeOneOf
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function markAsReadUserNotifications (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
await UserNotificationModel.markAsRead(user.id, req.body.ids)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function markAsReadAllUserNotifications (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
await UserNotificationModel.markAllAsRead(user.id)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,184 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { handlesToNameAndHost } from '@server/helpers/actors.js'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { sendUndoFollow } from '@server/lib/activitypub/send/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import express from 'express'
import 'multer'
import { buildNSFWFilters, getCountVideos } from '../../../helpers/express-utils.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
commonVideosFiltersValidatorFactory,
paginationValidator,
setDefaultPagination,
setDefaultSort,
setDefaultVideosSort,
userSubscriptionAddValidator,
userSubscriptionGetValidator
} from '../../../middlewares/index.js'
import {
areSubscriptionsExistValidator,
userSubscriptionListValidator,
userSubscriptionsSortValidator,
videosSortValidator
} from '../../../middlewares/validators/index.js'
import { ActorFollowModel } from '../../../models/actor/actor-follow.js'
import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter/index.js'
import { VideoModel } from '../../../models/video/video.js'
const mySubscriptionsRouter = express.Router()
mySubscriptionsRouter.get(
'/me/subscriptions/videos',
authenticate,
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
commonVideosFiltersValidatorFactory(),
asyncMiddleware(getUserSubscriptionVideos)
)
mySubscriptionsRouter.get('/me/subscriptions/exist', authenticate, areSubscriptionsExistValidator, asyncMiddleware(areSubscriptionsExist))
mySubscriptionsRouter.get(
'/me/subscriptions',
authenticate,
paginationValidator,
userSubscriptionsSortValidator,
setDefaultSort,
setDefaultPagination,
userSubscriptionListValidator,
asyncMiddleware(listUserSubscriptions)
)
mySubscriptionsRouter.post('/me/subscriptions', authenticate, userSubscriptionAddValidator, addUserSubscription)
mySubscriptionsRouter.get('/me/subscriptions/:uri', authenticate, userSubscriptionGetValidator, asyncMiddleware(getUserSubscription))
mySubscriptionsRouter.delete(
'/me/subscriptions/:uri',
authenticate,
userSubscriptionGetValidator,
asyncRetryTransactionMiddleware(deleteUserSubscription)
)
// ---------------------------------------------------------------------------
export {
mySubscriptionsRouter
}
// ---------------------------------------------------------------------------
async function areSubscriptionsExist (req: express.Request, res: express.Response) {
const uris = req.query.uris as string[]
const user = res.locals.oauth.token.User
const sanitizedHandles = handlesToNameAndHost(uris)
const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, sanitizedHandles)
const existObject: { [id: string]: boolean } = {}
for (const sanitizedHandle of sanitizedHandles) {
const obj = results.find(r => {
const server = r.ActorFollowing.Server
return r.ActorFollowing.preferredUsername.toLowerCase() === sanitizedHandle.name.toLowerCase() &&
(
(!server && !sanitizedHandle.host) ||
(server.host === sanitizedHandle.host)
)
})
existObject[sanitizedHandle.handle] = obj !== undefined
}
return res.json(existObject)
}
function addUserSubscription (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const [ name, host ] = req.body.uri.split('@')
const payload = {
name,
host,
assertIsChannel: true,
followerActorId: user.Account.Actor.id
}
JobQueue.Instance.createJobAsync({ type: 'activitypub-follow', payload })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function getUserSubscription (req: express.Request, res: express.Response) {
const subscription = res.locals.subscription
const videoChannel = await VideoChannelModel.loadAndPopulateAccount(subscription.ActorFollowing.VideoChannel.id)
return res.json(videoChannel.toFormattedJSON())
}
async function deleteUserSubscription (req: express.Request, res: express.Response) {
const subscription = res.locals.subscription
await sequelizeTypescript.transaction(async t => {
if (subscription.state === 'accepted') {
sendUndoFollow(subscription, t)
}
return subscription.destroy({ transaction: t })
})
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}
async function listUserSubscriptions (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const actorId = user.Account.Actor.id
const resultList = await ActorFollowModel.listSubscriptionsForApi({
actorId,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function getUserSubscriptionVideos (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const countVideos = getCountVideos(req)
const query = pickCommonVideoQuery(req.query)
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: user.Account.Actor.id,
orLocalVideos: false
},
user,
countVideos
}, 'filter:api.user.me.subscription-videos.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
apiOptions,
'filter:api.user.me.subscription-videos.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
}

View File

@@ -0,0 +1,51 @@
import express from 'express'
import { forceNumber } from '@peertube/peertube-core-utils'
import { VideosExistInPlaylists } from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { asyncMiddleware, authenticate } from '../../../middlewares/index.js'
import { doVideosInPlaylistExistValidator } from '../../../middlewares/validators/videos/video-playlists.js'
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
const myVideoPlaylistsRouter = express.Router()
myVideoPlaylistsRouter.get('/me/video-playlists/videos-exist',
authenticate,
doVideosInPlaylistExistValidator,
asyncMiddleware(doVideosInPlaylistExist)
)
// ---------------------------------------------------------------------------
export {
myVideoPlaylistsRouter
}
// ---------------------------------------------------------------------------
async function doVideosInPlaylistExist (req: express.Request, res: express.Response) {
const videoIds = req.query.videoIds.map(i => forceNumber(i))
const user = res.locals.oauth.token.User
const results = await VideoPlaylistModel.listPlaylistSummariesOf(user.Account.id, videoIds)
const existObject: VideosExistInPlaylists = {}
for (const videoId of videoIds) {
existObject[videoId] = []
}
for (const result of results) {
for (const element of result.VideoPlaylistElements) {
existObject[element.videoId].push({
playlistElementId: element.id,
playlistId: result.id,
playlistDisplayName: result.name,
playlistShortUUID: uuidToShort(result.uuid),
startTimestamp: element.startTimestamp,
stopTimestamp: element.stopTimestamp
})
}
}
return res.json(existObject)
}

View File

@@ -0,0 +1,264 @@
import { pick } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
UserRegister,
UserRegistrationRequest,
UserRegistrationState,
UserRegistrationUpdateState,
UserRight
} from '@peertube/peertube-models'
import { Emailer } from '@server/lib/emailer.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { UserRegistrationModel } from '@server/models/user/user-registration.js'
import express from 'express'
import { auditLoggerFactory, UserAuditView } from '../../../helpers/audit-logger.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { Notifier } from '../../../lib/notifier/index.js'
import {
buildUser,
createUserAccountAndChannelAndPlaylist,
sendVerifyRegistrationEmail,
sendVerifyRegistrationRequestEmail
} from '../../../lib/user.js'
import {
acceptOrRejectRegistrationValidator,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
buildRateLimiter,
ensureUserHasRight,
ensureUserRegistrationAllowedFactory,
ensureUserRegistrationAllowedForIP,
getRegistrationValidator,
listRegistrationsValidator,
paginationValidator,
setDefaultPagination,
setDefaultSort,
userRegistrationsSortValidator,
usersDirectRegistrationValidator,
usersRequestRegistrationValidator
} from '../../../middlewares/index.js'
const auditLogger = auditLoggerFactory('users')
const registrationRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.SIGNUP.WINDOW_MS,
max: CONFIG.RATES_LIMIT.SIGNUP.MAX,
skipFailedRequests: true
})
const registrationsRouter = express.Router()
registrationsRouter.post(
'/registrations/request',
registrationRateLimiter,
asyncMiddleware(ensureUserRegistrationAllowedFactory('request-registration')),
ensureUserRegistrationAllowedForIP,
asyncMiddleware(usersRequestRegistrationValidator),
asyncRetryTransactionMiddleware(requestRegistration)
)
registrationsRouter.post(
'/registrations/:registrationId/accept',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(acceptOrRejectRegistrationValidator),
asyncRetryTransactionMiddleware(acceptRegistration)
)
registrationsRouter.post(
'/registrations/:registrationId/reject',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(acceptOrRejectRegistrationValidator),
asyncRetryTransactionMiddleware(rejectRegistration)
)
registrationsRouter.delete(
'/registrations/:registrationId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
asyncMiddleware(getRegistrationValidator),
asyncRetryTransactionMiddleware(deleteRegistration)
)
registrationsRouter.get(
'/registrations',
authenticate,
ensureUserHasRight(UserRight.MANAGE_REGISTRATIONS),
paginationValidator,
userRegistrationsSortValidator,
setDefaultSort,
setDefaultPagination,
listRegistrationsValidator,
asyncMiddleware(listRegistrations)
)
registrationsRouter.post(
'/register',
registrationRateLimiter,
asyncMiddleware(ensureUserRegistrationAllowedFactory('direct-registration')),
ensureUserRegistrationAllowedForIP,
asyncMiddleware(usersDirectRegistrationValidator),
asyncRetryTransactionMiddleware(registerUser)
)
// ---------------------------------------------------------------------------
export {
registrationsRouter
}
// ---------------------------------------------------------------------------
async function requestRegistration (req: express.Request, res: express.Response) {
const body: UserRegistrationRequest = req.body
const registration = new UserRegistrationModel({
...pick(body, [ 'username', 'password', 'email', 'registrationReason' ]),
accountDisplayName: body.displayName,
channelDisplayName: body.channel?.displayName,
channelHandle: body.channel?.name,
state: UserRegistrationState.PENDING,
emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
})
await registration.save()
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
await sendVerifyRegistrationRequestEmail(registration)
}
Notifier.Instance.notifyOnNewRegistrationRequest(registration)
Hooks.runAction('action:api.user.requested-registration', { body, registration, req, res })
return res.json(registration.toFormattedJSON())
}
// ---------------------------------------------------------------------------
async function acceptRegistration (req: express.Request, res: express.Response) {
const registration = res.locals.userRegistration
const body: UserRegistrationUpdateState = req.body
const userToCreate = buildUser({
username: registration.username,
password: registration.password,
email: registration.email,
emailVerified: registration.emailVerified
})
// We already encrypted password in registration model
userToCreate.skipPasswordEncryption = true
// TODO: handle conflicts if someone else created a channel handle/user handle/user email between registration and approval
const { user } = await createUserAccountAndChannelAndPlaylist({
userToCreate,
userDisplayName: registration.accountDisplayName,
channelNames: registration.channelHandle && registration.channelDisplayName
? {
name: registration.channelHandle,
displayName: registration.channelDisplayName
}
: undefined
})
registration.userId = user.id
registration.state = UserRegistrationState.ACCEPTED
registration.moderationResponse = body.moderationResponse
if (!registration.processedAt) registration.processedAt = new Date()
await registration.save()
logger.info('Registration of %s accepted', registration.username)
if (body.preventEmailDelivery !== true) {
Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
}
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function rejectRegistration (req: express.Request, res: express.Response) {
const registration = res.locals.userRegistration
const body: UserRegistrationUpdateState = req.body
registration.state = UserRegistrationState.REJECTED
registration.moderationResponse = body.moderationResponse
if (!registration.processedAt) registration.processedAt = new Date()
await registration.save()
if (body.preventEmailDelivery !== true) {
Emailer.Instance.addUserRegistrationRequestProcessedJob(registration)
}
logger.info('Registration of %s rejected', registration.username)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
async function deleteRegistration (req: express.Request, res: express.Response) {
const registration = res.locals.userRegistration
await registration.destroy()
logger.info('Registration of %s deleted', registration.username)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
async function listRegistrations (req: express.Request, res: express.Response) {
const resultList = await UserRegistrationModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search
})
return res.json({
total: resultList.total,
data: resultList.data.map(d => d.toFormattedJSON())
})
}
// ---------------------------------------------------------------------------
async function registerUser (req: express.Request, res: express.Response) {
const body: UserRegister = req.body
const userToCreate = buildUser({
...pick(body, [ 'username', 'password', 'email' ]),
emailVerified: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION ? false : null
})
const { user, account, videoChannel } = await createUserAccountAndChannelAndPlaylist({
userToCreate,
userDisplayName: body.displayName || undefined,
channelNames: body.channel
})
auditLogger.create(body.username, new UserAuditView(user.toFormattedJSON()))
logger.info('User %s with its channel and account registered.', body.username)
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
await sendVerifyRegistrationEmail(user)
}
Notifier.Instance.notifyOnNewDirectRegistration(user)
Hooks.runAction('action:api.user.registered', { body, user, account, videoChannel, req, res })
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,203 @@
import { InvalidGrantError } from '@node-oauth/oauth2-server'
import { ResultList, ScopedToken, TokenSession } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { OTP } from '@server/initializers/constants.js'
import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth.js'
import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model.js'
import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import {
asyncMiddleware,
authenticate,
buildRateLimiter,
openapiOperationDoc,
paginationValidator,
setDefaultPagination,
setDefaultSort,
tokenSessionsSortValidator
} from '@server/middlewares/index.js'
import { manageTokenSessionsValidator, revokeTokenSessionValidator } from '@server/middlewares/validators/token.js'
import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js'
import express from 'express'
const tokensRouter = express.Router()
const loginRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.LOGIN.WINDOW_MS,
max: CONFIG.RATES_LIMIT.LOGIN.MAX
})
tokensRouter.post(
'/token',
loginRateLimiter,
openapiOperationDoc({ operationId: 'getOAuthToken' }),
asyncMiddleware(handleToken)
)
tokensRouter.post(
'/revoke-token',
openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
authenticate,
asyncMiddleware(handleTokenRevocation)
)
// ---------------------------------------------------------------------------
tokensRouter.get(
'/:userId/token-sessions',
authenticate,
asyncMiddleware(manageTokenSessionsValidator),
paginationValidator,
tokenSessionsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listTokenSessions)
)
tokensRouter.post(
'/:userId/token-sessions/:tokenSessionId/revoke',
authenticate,
asyncMiddleware(manageTokenSessionsValidator),
asyncMiddleware(revokeTokenSessionValidator),
asyncMiddleware(revokeTokenSession)
)
// ---------------------------------------------------------------------------
tokensRouter.get(
'/scoped-tokens',
authenticate,
getScopedTokens
)
tokensRouter.post(
'/scoped-tokens',
authenticate,
asyncMiddleware(renewScopedTokens)
)
// ---------------------------------------------------------------------------
export {
tokensRouter
}
// ---------------------------------------------------------------------------
async function handleToken (req: express.Request, res: express.Response, next: express.NextFunction) {
const grantType = req.body.grant_type
try {
const bypassLogin = await buildByPassLogin(req, grantType)
const refreshTokenAuthName = grantType === 'refresh_token'
? await getAuthNameFromRefreshGrant(req.body.refresh_token)
: undefined
const options = {
refreshTokenAuthName,
bypassLogin
}
const token = await handleOAuthToken(req, options)
res.set('Cache-Control', 'no-store')
res.set('Pragma', 'no-cache')
Hooks.runAction('action:api.user.oauth2-got-token', { username: token.user.username, ip: req.ip, req, res })
return res.json({
token_type: 'Bearer',
access_token: token.accessToken,
refresh_token: token.refreshToken,
expires_in: token.accessTokenExpiresIn,
refresh_token_expires_in: token.refreshTokenExpiresIn
})
} catch (err) {
if (err instanceof MissingTwoFactorError) {
res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
logger.debug('Missing two factor error', { err })
} else if (err instanceof InvalidGrantError) {
logger.debug('Invalid grant', { err })
} else {
logger.warn('Login error', { err })
}
return res.fail({
status: err.code,
message: err.message,
type: err.name
})
}
}
async function handleTokenRevocation (req: express.Request, res: express.Response) {
const token = res.locals.oauth.token
const result = await revokeToken(token, { req, explicitLogout: true })
return res.json(result)
}
// ---------------------------------------------------------------------------
async function listTokenSessions (req: express.Request, res: express.Response) {
const currentToken = res.locals.oauth.token
const { total, data } = await OAuthTokenModel.listSessionsOf({
start: req.query.start as number,
count: req.query.count as number,
sort: req.query.sort as string,
userId: res.locals.user.id
})
return res.json(
{
total,
data: data.map(session => session.toSessionFormattedJSON(currentToken.accessToken))
} satisfies ResultList<TokenSession>
)
}
async function revokeTokenSession (req: express.Request, res: express.Response) {
const token = res.locals.tokenSession
const result = await revokeToken(token, { req, explicitLogout: true })
return res.json(result)
}
// ---------------------------------------------------------------------------
function getScopedTokens (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
return res.json({
feedToken: user.feedToken
} as ScopedToken)
}
async function renewScopedTokens (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user
user.feedToken = buildUUID()
await user.save()
return res.json({
feedToken: user.feedToken
} as ScopedToken)
}
async function buildByPassLogin (req: express.Request, grantType: string): Promise<BypassLogin> {
if (grantType !== 'password') return undefined
if (req.body.externalAuthToken) {
// Consistency with the getBypassFromPasswordGrant promise
return getBypassFromExternalAuth(req.body.username, req.body.externalAuthToken)
}
return getBypassFromPasswordGrant(req.body.username, req.body.password)
}

View File

@@ -0,0 +1,95 @@
import express from 'express'
import { generateOTPSecret, isOTPValid } from '@server/helpers/otp.js'
import { encrypt } from '@server/helpers/peertube-crypto.js'
import { CONFIG } from '@server/initializers/config.js'
import { Redis } from '@server/lib/redis.js'
import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares/index.js'
import {
confirmTwoFactorValidator,
disableTwoFactorValidator,
requestOrConfirmTwoFactorValidator
} from '@server/middlewares/validators/two-factor.js'
import { HttpStatusCode, TwoFactorEnableResult } from '@peertube/peertube-models'
const twoFactorRouter = express.Router()
twoFactorRouter.post('/:id/two-factor/request',
authenticate,
asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
asyncMiddleware(requestOrConfirmTwoFactorValidator),
asyncMiddleware(requestTwoFactor)
)
twoFactorRouter.post('/:id/two-factor/confirm-request',
authenticate,
asyncMiddleware(requestOrConfirmTwoFactorValidator),
confirmTwoFactorValidator,
asyncMiddleware(confirmRequestTwoFactor)
)
twoFactorRouter.post('/:id/two-factor/disable',
authenticate,
asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
asyncMiddleware(disableTwoFactorValidator),
asyncMiddleware(disableTwoFactor)
)
// ---------------------------------------------------------------------------
export {
twoFactorRouter
}
// ---------------------------------------------------------------------------
async function requestTwoFactor (req: express.Request, res: express.Response) {
const user = res.locals.user
const { secret, uri } = generateOTPSecret(user.email)
const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
return res.json({
otpRequest: {
requestToken,
secret,
uri
}
} as TwoFactorEnableResult)
}
async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
const requestToken = req.body.requestToken
const otpToken = req.body.otpToken
const user = res.locals.user
const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
if (!encryptedSecret) {
return res.fail({
message: 'Invalid request token',
status: HttpStatusCode.FORBIDDEN_403
})
}
if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
return res.fail({
message: 'Invalid OTP token',
status: HttpStatusCode.FORBIDDEN_403
})
}
user.otpSecret = encryptedSecret
await user.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function disableTwoFactor (req: express.Request, res: express.Response) {
const user = res.locals.user
user.otpSecret = null
await user.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,97 @@
import express from 'express'
import { FileStorage, HttpStatusCode, UserExportRequest, UserExportRequestResult, UserExportState } from '@peertube/peertube-models'
import {
asyncMiddleware,
authenticate,
userExportDeleteValidator,
userExportRequestValidator,
userExportsListValidator
} from '../../../middlewares/index.js'
import { UserExportModel } from '@server/models/user/user-export.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { JobQueue } from '@server/lib/job-queue/job-queue.js'
import { CONFIG } from '@server/initializers/config.js'
const userExportsRouter = express.Router()
userExportsRouter.post('/:userId/exports/request',
authenticate,
asyncMiddleware(userExportRequestValidator),
asyncMiddleware(requestExport)
)
userExportsRouter.get('/:userId/exports',
authenticate,
asyncMiddleware(userExportsListValidator),
asyncMiddleware(listUserExports)
)
userExportsRouter.delete('/:userId/exports/:id',
authenticate,
asyncMiddleware(userExportDeleteValidator),
asyncMiddleware(deleteUserExport)
)
// ---------------------------------------------------------------------------
export {
userExportsRouter
}
// ---------------------------------------------------------------------------
async function requestExport (req: express.Request, res: express.Response) {
const body = req.body as UserExportRequest
const exportModel = new UserExportModel({
state: UserExportState.PENDING,
withVideoFiles: body.withVideoFiles,
storage: CONFIG.OBJECT_STORAGE.ENABLED
? FileStorage.OBJECT_STORAGE
: FileStorage.FILE_SYSTEM,
userId: res.locals.user.id,
createdAt: new Date()
})
exportModel.generateAndSetFilename()
await sequelizeTypescript.transaction(async transaction => {
await exportModel.save({ transaction })
})
await JobQueue.Instance.createJob({ type: 'create-user-export', payload: { userExportId: exportModel.id } })
return res.json({
export: {
id: exportModel.id
}
} as UserExportRequestResult)
}
async function listUserExports (req: express.Request, res: express.Response) {
const resultList = await UserExportModel.listForApi({
start: req.query.start,
count: req.query.count,
user: res.locals.user
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function deleteUserExport (req: express.Request, res: express.Response) {
const userExport = res.locals.userExport
await sequelizeTypescript.transaction(async transaction => {
await userExport.reload({ transaction })
if (!userExport.canBeSafelyRemoved()) {
return res.sendStatus(HttpStatusCode.CONFLICT_409)
}
await userExport.destroy({ transaction })
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,79 @@
import express from 'express'
import {
asyncMiddleware,
authenticate
} from '../../../middlewares/index.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
import {
getLatestImportStatusValidator,
userImportRequestResumableInitValidator,
userImportRequestResumableValidator
} from '@server/middlewares/validators/users/user-import.js'
import { HttpStatusCode, UserImportState, UserImportUploadResult } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { UserImportModel } from '@server/models/user/user-import.js'
import { getFSUserImportFilePath } from '@server/lib/paths.js'
import { move } from 'fs-extra/esm'
import { JobQueue } from '@server/lib/job-queue/job-queue.js'
import { saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
const userImportRouter = express.Router()
userImportRouter.get('/:userId/imports/latest',
authenticate,
asyncMiddleware(getLatestImportStatusValidator),
asyncMiddleware(getLatestImport)
)
setupUploadResumableRoutes({
routePath: '/:userId/imports/import-resumable',
router: userImportRouter,
uploadInitAfterMiddlewares: [ asyncMiddleware(userImportRequestResumableInitValidator) ],
uploadedMiddlewares: [ asyncMiddleware(userImportRequestResumableValidator) ],
uploadedController: asyncMiddleware(addUserImportResumable)
})
// ---------------------------------------------------------------------------
export {
userImportRouter
}
// ---------------------------------------------------------------------------
async function addUserImportResumable (req: express.Request, res: express.Response) {
const file = res.locals.importUserFileResumable
const user = res.locals.user
// Move import
const userImport = new UserImportModel({
state: UserImportState.PENDING,
userId: user.id,
createdAt: new Date()
})
userImport.generateAndSetFilename()
await move(file.path, getFSUserImportFilePath(userImport))
await saveInTransactionWithRetries(userImport)
// Create job
await JobQueue.Instance.createJob({ type: 'import-user-archive', payload: { userImportId: userImport.id } })
logger.info('User import request job created for user ' + user.username)
return res.json({
userImport: {
id: userImport.id
}
} as UserImportUploadResult)
}
async function getLatestImport (req: express.Request, res: express.Response) {
const userImport = await UserImportModel.loadLatestByUserId(res.locals.user.id)
if (!userImport) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
return res.json(userImport.toFormattedJSON())
}

View File

@@ -0,0 +1,100 @@
import { HttpStatusCode, VideoChannelActivityAction, VideoChannelSyncState } from '@peertube/peertube-models'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelSyncAuditView } from '@server/helpers/audit-logger.js'
import { logger } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import {
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
ensureSyncExists,
ensureSyncIsEnabled,
videoChannelSyncValidator
} from '@server/middlewares/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import { MChannelSyncFormattable } from '@server/types/models/index.js'
import express from 'express'
const videoChannelSyncRouter = express.Router()
const auditLogger = auditLoggerFactory('channel-syncs')
videoChannelSyncRouter.use(apiRateLimiter)
videoChannelSyncRouter.post(
'/',
authenticate,
ensureSyncIsEnabled,
asyncMiddleware(videoChannelSyncValidator),
asyncRetryTransactionMiddleware(createVideoChannelSync)
)
videoChannelSyncRouter.delete(
'/:id',
authenticate,
asyncMiddleware(ensureSyncExists),
asyncRetryTransactionMiddleware(removeVideoChannelSync)
)
export { videoChannelSyncRouter }
// ---------------------------------------------------------------------------
async function createVideoChannelSync (req: express.Request, res: express.Response) {
const syncCreated: MChannelSyncFormattable = new VideoChannelSyncModel({
externalChannelUrl: req.body.externalChannelUrl,
videoChannelId: req.body.videoChannelId,
state: VideoChannelSyncState.WAITING_FIRST_RUN
})
await sequelizeTypescript.transaction(async transaction => {
await syncCreated.save({ transaction })
syncCreated.VideoChannel = res.locals.videoChannel
await VideoChannelActivityModel.addChannelSyncActivity({
action: VideoChannelActivityAction.CREATE,
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
sync: syncCreated,
transaction
})
})
auditLogger.create(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncCreated.toFormattedJSON()))
logger.info(
'Video synchronization for channel "%s" with external channel "%s" created.',
syncCreated.VideoChannel.name,
syncCreated.externalChannelUrl
)
return res.json({
videoChannelSync: syncCreated.toFormattedJSON()
})
}
async function removeVideoChannelSync (req: express.Request, res: express.Response) {
const syncInstance = res.locals.videoChannelSync
await sequelizeTypescript.transaction(async transaction => {
await syncInstance.destroy({ transaction })
await VideoChannelActivityModel.addChannelSyncActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
sync: syncInstance,
transaction
})
})
auditLogger.delete(getAuditIdFromRes(res), new VideoChannelSyncAuditView(syncInstance.toFormattedJSON()))
logger.info(
'Video synchronization for channel "%s" with external channel "%s" deleted.',
syncInstance.VideoChannel.name,
syncInstance.externalChannelUrl
)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,466 @@
import {
HttpStatusCode,
VideoChannelActivityAction,
VideoChannelCreate,
VideoChannelUpdate,
VideoPlaylistForAccountListQuery,
VideoPlaylistReorder,
VideosImportInChannelCreate
} from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { reorderPlaylistOrElementsPosition, sendPlaylistPositionUpdateOfChannel } from '@server/lib/video-playlist.js'
import { ActorFollowModel } from '@server/models/actor/actor-follow.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MChannelBannerAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../../helpers/audit-logger.js'
import { resetSequelizeInstance } from '../../../helpers/database-utils.js'
import { buildNSFWFilters, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { sendUpdateActor } from '../../../lib/activitypub/send/index.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import { createLocalVideoChannelWithoutKeys, federateAllVideosOfChannel } from '../../../lib/video-channel.js'
import {
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
commonVideosFiltersValidatorFactory,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort,
setDefaultVideosSort,
videoChannelsAddValidator,
videoChannelsRemoveValidator,
videoChannelsSortValidator,
videoChannelsUpdateValidator,
videoPlaylistsSortValidator
} from '../../../middlewares/index.js'
import {
videoChannelActivitiesSortValidator,
videoChannelFollowersSortValidator,
videoChannelImportVideosValidator,
videoChannelsHandleValidatorFactory,
videoChannelsListValidator,
videosSortValidator
} from '../../../middlewares/validators/index.js'
import {
commonVideoPlaylistFiltersValidator,
videoPlaylistsReorderInChannelValidator
} from '../../../middlewares/validators/videos/video-playlists.js'
import { AccountModel } from '../../../models/account/account.js'
import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter/index.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { VideoPlaylistModel } from '../../../models/video/video-playlist.js'
import { VideoModel } from '../../../models/video/video.js'
import { channelCollaborators } from './video-channel-collaborators.js'
import { videoChannelLogosRouter } from './video-channel-logos.js'
const auditLogger = auditLoggerFactory('channels')
const videoChannelRouter = express.Router()
videoChannelRouter.use(apiRateLimiter)
videoChannelRouter.use(channelCollaborators)
videoChannelRouter.use(videoChannelLogosRouter)
videoChannelRouter.get(
'/',
paginationValidator,
videoChannelsSortValidator,
setDefaultSort,
setDefaultPagination,
videoChannelsListValidator,
asyncMiddleware(listVideoChannels)
)
videoChannelRouter.post(
'/',
authenticate,
asyncMiddleware(videoChannelsAddValidator),
asyncRetryTransactionMiddleware(createVideoChannel)
)
videoChannelRouter.put(
'/:handle',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
videoChannelsUpdateValidator,
asyncRetryTransactionMiddleware(updateVideoChannel)
)
videoChannelRouter.delete(
'/:handle',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: true })),
asyncMiddleware(videoChannelsRemoveValidator),
asyncRetryTransactionMiddleware(removeVideoChannel)
)
videoChannelRouter.get(
'/:handle',
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })),
asyncMiddleware(getVideoChannel)
)
// ---------------------------------------------------------------------------
videoChannelRouter.get(
'/:handle/video-playlists',
optionalAuthenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })),
paginationValidator,
videoPlaylistsSortValidator,
setDefaultSort,
setDefaultPagination,
commonVideoPlaylistFiltersValidator,
asyncMiddleware(listVideoChannelPlaylists)
)
videoChannelRouter.post(
'/:handle/video-playlists/reorder',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(videoPlaylistsReorderInChannelValidator),
asyncRetryTransactionMiddleware(reorderPlaylistsInChannel)
)
// ---------------------------------------------------------------------------
videoChannelRouter.get(
'/:handle/videos',
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })),
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
optionalAuthenticate,
commonVideosFiltersValidatorFactory(),
asyncMiddleware(listVideoChannelVideos)
)
videoChannelRouter.get(
'/:handle/followers',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: true, checkIsOwner: false })),
paginationValidator,
videoChannelFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelFollowers)
)
videoChannelRouter.get(
'/:handle/activities',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
paginationValidator,
videoChannelActivitiesSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelActivities)
)
videoChannelRouter.post(
'/:handle/import-videos',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(videoChannelImportVideosValidator),
asyncMiddleware(importVideosInChannel)
)
// ---------------------------------------------------------------------------
export {
videoChannelRouter
}
// ---------------------------------------------------------------------------
async function listVideoChannels (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const apiOptions = await Hooks.wrapObject({
actorId: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
}, 'filter:api.video-channels.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoChannelModel.listForApi.bind(VideoChannelModel),
apiOptions,
'filter:api.video-channels.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function createVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInfo: VideoChannelCreate = req.body
const videoChannelCreated = await sequelizeTypescript.transaction(async t => {
const account = await AccountModel.load(res.locals.oauth.token.User.Account.id, t)
return createLocalVideoChannelWithoutKeys(videoChannelInfo, account, t)
})
await JobQueue.Instance.createJob({
type: 'actor-keys',
payload: { actorId: videoChannelCreated.Actor.id }
})
auditLogger.create(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelCreated.toFormattedJSON()))
logger.info('Video channel %s created.', videoChannelCreated.Actor.url)
Hooks.runAction('action:api.video-channel.created', { videoChannel: videoChannelCreated, req, res })
return res.json({
videoChannel: {
id: videoChannelCreated.id
}
})
}
async function updateVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInstance = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannelInstance.toFormattedJSON())
const videoChannelInfoToUpdate = req.body as VideoChannelUpdate
let doBulkVideoUpdate = false
try {
await sequelizeTypescript.transaction(async t => {
if (videoChannelInfoToUpdate.displayName !== undefined) videoChannelInstance.name = videoChannelInfoToUpdate.displayName
if (videoChannelInfoToUpdate.description !== undefined) videoChannelInstance.description = videoChannelInfoToUpdate.description
if (videoChannelInfoToUpdate.support !== undefined) {
const oldSupportField = videoChannelInstance.support
videoChannelInstance.support = videoChannelInfoToUpdate.support
if (videoChannelInfoToUpdate.bulkVideosSupportUpdate === true && oldSupportField !== videoChannelInfoToUpdate.support) {
doBulkVideoUpdate = true
await VideoModel.bulkUpdateSupportField(videoChannelInstance, t)
}
}
const videoChannelInstanceUpdated = await videoChannelInstance.save({ transaction: t }) as MChannelBannerAccountDefault
await sendUpdateActor(videoChannelInstanceUpdated, t)
auditLogger.update(
getAuditIdFromRes(res),
new VideoChannelAuditView(videoChannelInstanceUpdated.toFormattedJSON()),
oldVideoChannelAuditKeys
)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannelInstanceUpdated,
transaction: t
})
Hooks.runAction('action:api.video-channel.updated', { videoChannel: videoChannelInstanceUpdated, req, res })
logger.info('Video channel %s updated.', videoChannelInstance.Actor.url)
})
} catch (err) {
logger.debug('Cannot update the video channel.', { err })
// If the transaction is retried, sequelize will think the object has not changed
// So we need to restore the previous fields
await resetSequelizeInstance(videoChannelInstance)
throw err
}
res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
// Don't process in a transaction, and after the response because it could be long
if (doBulkVideoUpdate) {
await federateAllVideosOfChannel(videoChannelInstance)
}
}
async function removeVideoChannel (req: express.Request, res: express.Response) {
const videoChannelInstance = res.locals.videoChannel
await sequelizeTypescript.transaction(async t => {
await VideoPlaylistModel.resetPlaylistsOfChannel(videoChannelInstance.id, t)
await videoChannelInstance.destroy({ transaction: t })
Hooks.runAction('action:api.video-channel.deleted', { videoChannel: videoChannelInstance, req, res })
auditLogger.delete(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannelInstance.toFormattedJSON()))
logger.info('Video channel %s deleted.', videoChannelInstance.Actor.url)
})
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function getVideoChannel (req: express.Request, res: express.Response) {
const id = res.locals.videoChannel.id
const videoChannel = await Hooks.wrapObject(res.locals.videoChannel, 'filter:api.video-channel.get.result', { id })
if (videoChannel.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'actor', url: videoChannel.Actor.url } })
}
return res.json(videoChannel.toFormattedJSON())
}
// ---------------------------------------------------------------------------
// Playlists
// ---------------------------------------------------------------------------
async function listVideoChannelPlaylists (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const { count, playlistType, sort, start, search } = req.query as VideoPlaylistForAccountListQuery
const videoChannel = res.locals.videoChannel
// Allow users to see their private/unlisted video playlists
const listMyPlaylists = !!res.locals.oauth && res.locals.oauth.token.User.Account.id === videoChannel.accountId
const resultList = await VideoPlaylistModel.listForApi({
followerActorId: isUserAbleToSearchRemoteURI(res)
? null
: serverActor.id,
videoChannelId: videoChannel.id,
listMyPlaylists,
start,
count,
sort,
search,
type: playlistType
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function reorderPlaylistsInChannel (req: express.Request, res: express.Response) {
const body: VideoPlaylistReorder = req.body
const videoChannel = res.locals.videoChannel
const start: number = body.startPosition
const insertAfter: number = body.insertAfterPosition
const reorderLength: number = body.reorderLength || 1
if (start === insertAfter) {
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
await sequelizeTypescript.transaction(async t => {
await reorderPlaylistOrElementsPosition({
model: VideoPlaylistModel,
instance: videoChannel,
start,
insertAfter,
reorderLength,
transaction: t
})
videoChannel.changed('updatedAt', true)
await videoChannel.save({ transaction: t })
await sendUpdateActor(videoChannel, t)
await sendPlaylistPositionUpdateOfChannel(videoChannel.id, t)
})
logger.info(
`Reordered playlist of channel ${videoChannel.name} (inserted after position ${insertAfter} elements ${start} - ${
start + reorderLength - 1
}).`
)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
async function listVideoChannelVideos (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const videoChannelInstance = res.locals.videoChannel
const displayOnlyForFollower = isUserAbleToSearchRemoteURI(res)
? null
: {
actorId: serverActor.id,
orLocalVideos: true
}
const countVideos = getCountVideos(req)
const query = pickCommonVideoQuery(req.query)
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower,
videoChannelId: videoChannelInstance.id,
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos
}, 'filter:api.video-channels.videos.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
apiOptions,
'filter:api.video-channels.videos.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
}
async function listVideoChannelFollowers (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const resultList = await ActorFollowModel.listFollowersForApi({
actorIds: [ channel.Actor.id ],
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
state: 'accepted'
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function listVideoChannelActivities (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const resultList = await VideoChannelActivityModel.listForAPI({
channelId: channel.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function importVideosInChannel (req: express.Request, res: express.Response) {
const { externalChannelUrl } = req.body as VideosImportInChannelCreate
// TODO: add channel activity for this endpoint
await JobQueue.Instance.createJob({
type: 'video-channel-import',
payload: {
externalChannelUrl,
videoChannelId: res.locals.videoChannel.id,
partOfChannelSyncId: res.locals.videoChannelSync?.id
}
})
logger.info('Video import job for channel "%s" with url "%s" created.', res.locals.videoChannel.name, externalChannelUrl)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,128 @@
import { HttpStatusCode, VideoChannelCollaboratorState } from '@peertube/peertube-models'
import { deleteInTransactionWithRetries, retryTransactionWrapper, saveInTransactionWithRetries } from '@server/helpers/database-utils.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { Notifier } from '@server/lib/notifier/notifier.js'
import {
channelAcceptOrRejectInviteCollaboratorsValidator,
channelDeleteCollaboratorsValidator,
channelInviteCollaboratorsValidator,
channelListCollaboratorsValidator
} from '@server/middlewares/validators/videos/video-channel-collaborators.js'
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
import { MChannelCollaboratorAccount } from '@server/types/models/index.js'
import express from 'express'
import { asyncMiddleware, authenticate } from '../../../middlewares/index.js'
const channelCollaborators = express.Router()
channelCollaborators.get(
'/:handle/collaborators',
authenticate,
asyncMiddleware(channelListCollaboratorsValidator),
asyncMiddleware(listCollaborators)
)
channelCollaborators.post(
'/:handle/collaborators/invite',
authenticate,
asyncMiddleware(channelInviteCollaboratorsValidator),
asyncMiddleware(inviteCollaborator)
)
channelCollaborators.post(
'/:handle/collaborators/:collaboratorId/accept',
authenticate,
asyncMiddleware(channelAcceptOrRejectInviteCollaboratorsValidator),
asyncMiddleware(acceptCollaboratorInvite)
)
channelCollaborators.post(
'/:handle/collaborators/:collaboratorId/reject',
authenticate,
asyncMiddleware(channelAcceptOrRejectInviteCollaboratorsValidator),
asyncMiddleware(rejectCollaboratorInvite)
)
channelCollaborators.delete(
'/:handle/collaborators/:collaboratorId',
authenticate,
asyncMiddleware(channelDeleteCollaboratorsValidator),
asyncMiddleware(removeCollaborator)
)
// ---------------------------------------------------------------------------
export {
channelCollaborators
}
// ---------------------------------------------------------------------------
async function listCollaborators (req: express.Request, res: express.Response) {
const resultList = await VideoChannelCollaboratorModel.listForApi({
channelId: res.locals.videoChannel.id,
start: 0,
// Prefer to use limit of 100 to avoid issues with collaborators list if the admin lowered the config
count: Math.max(CONFIG.VIDEO_CHANNELS.MAX_COLLABORATORS_PER_CHANNEL, 100),
sort: '-createdAt'
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function inviteCollaborator (req: express.Request, res: express.Response) {
const collaborator = new VideoChannelCollaboratorModel({
state: VideoChannelCollaboratorState.PENDING,
accountId: res.locals.account.id,
channelId: res.locals.videoChannel.id
}) as MChannelCollaboratorAccount
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async transaction => {
// Existing rejected collaborator
if (res.locals.channelCollaborator) {
await res.locals.channelCollaborator.destroy({ transaction })
}
await collaborator.save({ transaction })
})
})
collaborator.Account = res.locals.account
Notifier.Instance.notifyOfChannelCollaboratorInvitation(collaborator, res.locals.videoChannel)
return res.json({ collaborator: collaborator.toFormattedJSON() })
}
async function acceptCollaboratorInvite (req: express.Request, res: express.Response) {
const collaborator = res.locals.channelCollaborator
collaborator.state = VideoChannelCollaboratorState.ACCEPTED
await saveInTransactionWithRetries(collaborator)
Notifier.Instance.notifyOfAcceptedChannelCollaborator(collaborator, res.locals.videoChannel)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function rejectCollaboratorInvite (req: express.Request, res: express.Response) {
const collaborator = res.locals.channelCollaborator
collaborator.state = VideoChannelCollaboratorState.REJECTED
await saveInTransactionWithRetries(collaborator)
Notifier.Instance.notifyOfRefusedChannelCollaborator(collaborator, res.locals.videoChannel)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function removeCollaborator (req: express.Request, res: express.Response) {
const collaborator = res.locals.channelCollaborator
await deleteInTransactionWithRetries(collaborator)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,138 @@
import { ActorImageType, HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js'
import { asyncMiddleware, authenticate } from '../../../middlewares/index.js'
import { updateAvatarValidator, updateBannerValidator } from '../../../middlewares/validators/actor-image.js'
import { videoChannelsHandleValidatorFactory } from '../../../middlewares/validators/index.js'
const auditLogger = auditLoggerFactory('channels')
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const videoChannelLogosRouter = express.Router()
videoChannelLogosRouter.post(
'/:handle/avatar/pick',
authenticate,
reqAvatarFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateAvatarValidator,
asyncMiddleware(updateVideoChannelAvatar)
)
videoChannelLogosRouter.post(
'/:handle/banner/pick',
authenticate,
reqBannerFile,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
updateBannerValidator,
asyncMiddleware(updateVideoChannelBanner)
)
videoChannelLogosRouter.delete(
'/:handle/avatar',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelLogosRouter.delete(
'/:handle/banner',
authenticate,
asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })),
asyncMiddleware(deleteVideoChannelBanner)
)
// ---------------------------------------------------------------------------
export {
videoChannelLogosRouter
}
// ---------------------------------------------------------------------------
async function updateVideoChannelBanner (req: express.Request, res: express.Response) {
const bannerPhysicalFile = req.files['bannerfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const banners = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: bannerPhysicalFile,
type: ActorImageType.BANNER,
sendActorUpdate: true
})
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
banners: banners.map(b => b.toFormattedJSON())
})
}
async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
const avatarPhysicalFile = req.files['avatarfile'][0]
const videoChannel = res.locals.videoChannel
const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON())
const avatars = await updateLocalActorImageFiles({
accountOrChannel: videoChannel,
imagePhysicalFile: avatarPhysicalFile,
type: ActorImageType.AVATAR,
sendActorUpdate: true
})
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys)
return res.json({
avatars: avatars.map(a => a.toFormattedJSON())
})
}
async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
await VideoChannelActivityModel.addChannelActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoChannel,
transaction: undefined
})
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,619 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
VideoChannelActivityAction,
VideoPlaylistCreate,
VideoPlaylistCreateResult,
VideoPlaylistElementCreate,
VideoPlaylistElementCreateResult,
VideoPlaylistElementUpdate,
VideoPlaylistPrivacy,
VideoPlaylistPrivacyType,
VideoPlaylistReorder,
VideoPlaylistUpdate
} from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import {
generateThumbnailForPlaylist,
reorderPlaylistOrElementsPosition,
sendPlaylistPositionUpdateOfChannel
} from '@server/lib/video-playlist.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { MVideoPlaylistFull, MVideoPlaylistThumbnail } from '@server/types/models/index.js'
import express from 'express'
import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils.js'
import { createReqFiles } from '../../helpers/express-utils.js'
import { logger } from '../../helpers/logger.js'
import { getFormattedObjects } from '../../helpers/utils.js'
import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants.js'
import { sequelizeTypescript } from '../../initializers/database.js'
import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send/index.js'
import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url.js'
import { updateLocalPlaylistMiniatureFromExisting } from '../../lib/thumbnail.js'
import {
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../middlewares/index.js'
import { videoPlaylistsSortValidator } from '../../middlewares/validators/index.js'
import {
commonVideoPlaylistFiltersValidator,
videoPlaylistsAddValidator,
videoPlaylistsAddVideoValidator,
videoPlaylistsDeleteValidator,
videoPlaylistsGetValidator,
videoPlaylistsReorderVideosValidator,
videoPlaylistsUpdateOrRemoveVideoValidator,
videoPlaylistsUpdateValidator
} from '../../middlewares/validators/videos/video-playlists.js'
import { AccountModel } from '../../models/account/account.js'
import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element.js'
import { VideoPlaylistModel } from '../../models/video/video-playlist.js'
const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
const videoPlaylistRouter = express.Router()
videoPlaylistRouter.use(apiRateLimiter)
videoPlaylistRouter.get('/privacies', listVideoPlaylistPrivacies)
videoPlaylistRouter.get(
'/',
paginationValidator,
videoPlaylistsSortValidator,
setDefaultSort,
setDefaultPagination,
commonVideoPlaylistFiltersValidator,
asyncMiddleware(listVideoPlaylists)
)
videoPlaylistRouter.get('/:playlistId', asyncMiddleware(videoPlaylistsGetValidator('summary')), getVideoPlaylist)
videoPlaylistRouter.post(
'/',
authenticate,
reqThumbnailFile,
asyncMiddleware(videoPlaylistsAddValidator),
asyncMiddleware(createVideoPlaylist)
)
videoPlaylistRouter.put(
'/:playlistId',
authenticate,
reqThumbnailFile,
asyncMiddleware(videoPlaylistsUpdateValidator),
asyncRetryTransactionMiddleware(updateVideoPlaylist)
)
videoPlaylistRouter.delete(
'/:playlistId',
authenticate,
asyncMiddleware(videoPlaylistsDeleteValidator),
asyncRetryTransactionMiddleware(removeVideoPlaylist)
)
// ---------------------------------------------------------------------------
// Playlist elements
// ---------------------------------------------------------------------------
videoPlaylistRouter.get(
'/:playlistId/videos',
asyncMiddleware(videoPlaylistsGetValidator('summary')),
paginationValidator,
setDefaultPagination,
optionalAuthenticate,
asyncMiddleware(listVideosOfPlaylist)
)
videoPlaylistRouter.post(
'/:playlistId/videos',
authenticate,
asyncMiddleware(videoPlaylistsAddVideoValidator),
asyncRetryTransactionMiddleware(addVideoInPlaylist)
)
videoPlaylistRouter.post(
'/:playlistId/videos/reorder',
authenticate,
asyncMiddleware(videoPlaylistsReorderVideosValidator),
asyncRetryTransactionMiddleware(reorderVideosOfPlaylist)
)
videoPlaylistRouter.put(
'/:playlistId/videos/:playlistElementId',
authenticate,
asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
)
videoPlaylistRouter.delete(
'/:playlistId/videos/:playlistElementId',
authenticate,
asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
)
// ---------------------------------------------------------------------------
export {
videoPlaylistRouter
}
// ---------------------------------------------------------------------------
function listVideoPlaylistPrivacies (req: express.Request, res: express.Response) {
res.json(VIDEO_PLAYLIST_PRIVACIES)
}
async function listVideoPlaylists (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const resultList = await VideoPlaylistModel.listForApi({
followerActorId: serverActor.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
type: req.query.playlistType
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
function getVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylist = res.locals.videoPlaylistSummary
scheduleRefreshIfNeeded(videoPlaylist)
return res.json(videoPlaylist.toFormattedJSON())
}
async function createVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylistInfo: VideoPlaylistCreate = req.body
const user = res.locals.oauth.token.User
const videoPlaylist = new VideoPlaylistModel({
name: videoPlaylistInfo.displayName,
description: videoPlaylistInfo.description,
privacy: videoPlaylistInfo.privacy || VideoPlaylistPrivacy.PRIVATE,
ownerAccountId: res.locals.videoChannel?.Account.id ?? user.Account.id
}) as MVideoPlaylistFull
videoPlaylist.url = getLocalVideoPlaylistActivityPubUrl(videoPlaylist) // We use the UUID, so set the URL after building the object
const videoChannel = res.locals.videoChannel
if (videoChannel && videoPlaylistInfo.videoChannelId) {
videoPlaylist.videoChannelId = videoChannel.id
videoPlaylist.VideoChannel = videoChannel
}
const thumbnailField = req.files?.['thumbnailfile']
const thumbnailModel = thumbnailField
? await updateLocalPlaylistMiniatureFromExisting({
inputPath: thumbnailField[0].path,
playlist: videoPlaylist,
automaticallyGenerated: false
})
: undefined
const videoPlaylistCreated = await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
if (videoPlaylist.videoChannelId) {
videoPlaylist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
videoChannelId: videoPlaylist.videoChannelId,
transaction: t
})
}
const videoPlaylistCreated = await videoPlaylist.save({ transaction: t }) as MVideoPlaylistFull
if (thumbnailModel) {
await videoPlaylistCreated.setAndSaveThumbnail(thumbnailModel, t)
}
// We need more attributes for the federation
videoPlaylistCreated.OwnerAccount = await AccountModel.load(user.Account.id, t)
await sendCreateVideoPlaylist(videoPlaylistCreated, t)
if (videoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.CREATE,
user,
channel: videoChannel,
playlist: videoPlaylistCreated,
transaction: t
})
}
return videoPlaylistCreated
})
})
logger.info('Video playlist with uuid %s created.', videoPlaylist.uuid)
return res.json({
videoPlaylist: {
id: videoPlaylistCreated.id,
shortUUID: uuidToShort(videoPlaylistCreated.uuid),
uuid: videoPlaylistCreated.uuid
} as VideoPlaylistCreateResult
})
}
async function updateVideoPlaylist (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistFull
const body = req.body as VideoPlaylistUpdate
const wasPrivatePlaylist = playlist.privacy === VideoPlaylistPrivacy.PRIVATE
const wasNotPrivatePlaylist = playlist.privacy !== VideoPlaylistPrivacy.PRIVATE
let removedFromChannel: { id: number, position: number }
const thumbnailField = req.files?.['thumbnailfile']
const thumbnailModel = thumbnailField
? await updateLocalPlaylistMiniatureFromExisting({
inputPath: thumbnailField[0].path,
playlist,
automaticallyGenerated: false
})
: undefined
try {
await sequelizeTypescript.transaction(async t => {
const newChannel = res.locals.videoChannel
const user = res.locals.oauth.token.User
// Had a channel, but the user changed it (to null or another channel)
if (playlist.videoChannelId && body.videoChannelId !== undefined && body.videoChannelId !== playlist.videoChannelId) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP,
user,
channel: playlist.VideoChannel,
playlist,
transaction: t
})
removedFromChannel = {
id: playlist.videoChannelId,
position: playlist.videoChannelPosition
}
playlist.videoChannelId = null
playlist.VideoChannel = null
}
if (newChannel && newChannel.id !== playlist.videoChannelId) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP,
user,
channel: newChannel,
playlist,
transaction: t
})
playlist.videoChannelPosition = await VideoPlaylistModel.getNextPositionOf({
videoChannelId: newChannel.id,
transaction: t
})
playlist.videoChannelId = newChannel.id
playlist.VideoChannel = newChannel
} else if (newChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: newChannel,
playlist,
transaction: t
})
}
if (body.displayName !== undefined) playlist.name = body.displayName
if (body.description !== undefined) playlist.description = body.description
if (body.privacy !== undefined) {
playlist.privacy = forceNumber(body.privacy) as VideoPlaylistPrivacyType
if (wasNotPrivatePlaylist === true && playlist.privacy === VideoPlaylistPrivacy.PRIVATE) {
await sendDeleteVideoPlaylist(playlist, t)
}
}
const playlistUpdated = await playlist.save({ transaction: t })
if (thumbnailModel) {
thumbnailModel.automaticallyGenerated = false
await playlistUpdated.setAndSaveThumbnail(thumbnailModel, t)
}
const isNewPlaylist = wasPrivatePlaylist && playlistUpdated.privacy !== VideoPlaylistPrivacy.PRIVATE
if (isNewPlaylist) {
await sendCreateVideoPlaylist(playlistUpdated, t)
} else {
await sendUpdateVideoPlaylist(playlistUpdated, t)
}
if (removedFromChannel) {
await VideoPlaylistModel.increasePositionOf({
videoChannelId: removedFromChannel.id,
fromPosition: removedFromChannel.position,
by: -1,
transaction: t
})
await sendPlaylistPositionUpdateOfChannel(removedFromChannel.id, t)
}
logger.info('Video playlist %s updated.', playlist.uuid)
return playlistUpdated
})
} catch (err) {
logger.debug('Cannot update the video playlist.', { err })
// If the transaction is retried, sequelize will think the object has not changed
// So we need to restore the previous fields
await resetSequelizeInstance(playlist)
throw err
}
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function removeVideoPlaylist (req: express.Request, res: express.Response) {
const videoPlaylistInstance = res.locals.videoPlaylistSummary
const positionToDelete = videoPlaylistInstance.videoChannelPosition
await sequelizeTypescript.transaction(async t => {
await videoPlaylistInstance.destroy({ transaction: t })
if (videoPlaylistInstance.privacy !== VideoPlaylistPrivacy.PRIVATE) {
await sendDeleteVideoPlaylist(videoPlaylistInstance, t)
}
if (videoPlaylistInstance.videoChannelId) {
await VideoPlaylistModel.increasePositionOf({
videoChannelId: videoPlaylistInstance.videoChannelId,
fromPosition: positionToDelete,
by: -1,
transaction: t
})
}
if (videoPlaylistInstance.videoChannelId) {
await sendPlaylistPositionUpdateOfChannel(videoPlaylistInstance.videoChannelId, t)
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: videoPlaylistInstance.VideoChannel,
playlist: videoPlaylistInstance,
transaction: t
})
}
logger.info('Video playlist %s deleted.', videoPlaylistInstance.uuid)
})
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
// ---------------------------------------------------------------------------
// Videos in playlist
// ---------------------------------------------------------------------------
async function addVideoInPlaylist (req: express.Request, res: express.Response) {
const body: VideoPlaylistElementCreate = req.body
const videoPlaylist = res.locals.videoPlaylistFull
const video = res.locals.onlyVideo
const playlistElement = await sequelizeTypescript.transaction(async t => {
const position = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id, t)
const playlistElement = await VideoPlaylistElementModel.create({
position,
startTimestamp: body.startTimestamp || null,
stopTimestamp: body.stopTimestamp || null,
videoPlaylistId: videoPlaylist.id,
videoId: video.id
}, { transaction: t })
playlistElement.url = getLocalVideoPlaylistElementActivityPubUrl(videoPlaylist, playlistElement)
await playlistElement.save({ transaction: t })
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
return playlistElement
})
// If the user did not set a thumbnail, automatically take the video thumbnail
if (videoPlaylist.shouldGenerateThumbnailWithNewElement(playlistElement)) {
await generateThumbnailForPlaylist(videoPlaylist, video)
}
sendUpdateVideoPlaylist(videoPlaylist, undefined)
.catch(err => logger.error('Cannot send video playlist update.', { err }))
logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
Hooks.runAction('action:api.video-playlist-element.created', { playlistElement, req, res })
return res.json({
videoPlaylistElement: {
id: playlistElement.id
} as VideoPlaylistElementCreateResult
})
}
async function updateVideoPlaylistElement (req: express.Request, res: express.Response) {
const body: VideoPlaylistElementUpdate = req.body
const videoPlaylist = res.locals.videoPlaylistFull
const videoPlaylistElement = res.locals.videoPlaylistElement
const playlistElement: VideoPlaylistElementModel = await sequelizeTypescript.transaction(async t => {
if (body.startTimestamp !== undefined) videoPlaylistElement.startTimestamp = body.startTimestamp
if (body.stopTimestamp !== undefined) videoPlaylistElement.stopTimestamp = body.stopTimestamp
const element = await videoPlaylistElement.save({ transaction: t })
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
await sendUpdateVideoPlaylist(videoPlaylist, t)
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
return element
})
logger.info('Element of position %d of playlist %s updated.', playlistElement.position, videoPlaylist.uuid)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function removeVideoFromPlaylist (req: express.Request, res: express.Response) {
const videoPlaylistElement = res.locals.videoPlaylistElement
const videoPlaylist = res.locals.videoPlaylistFull
const positionToDelete = videoPlaylistElement.position
await sequelizeTypescript.transaction(async t => {
await videoPlaylistElement.destroy({ transaction: t })
// Decrease position of the next elements
await VideoPlaylistElementModel.increasePositionOf({
videoPlaylistId: videoPlaylist.id,
fromPosition: positionToDelete,
by: -1,
transaction: t
})
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
if (videoPlaylist.VideoChannel) {
await VideoChannelActivityModel.addPlaylistActivity({
action: VideoChannelActivityAction.UPDATE_ELEMENTS,
user: res.locals.oauth.token.User,
channel: videoPlaylist.VideoChannel,
playlist: videoPlaylist,
transaction: t
})
}
logger.info('Video playlist element %d of playlist %s deleted.', videoPlaylistElement.position, videoPlaylist.uuid)
})
// Do we need to regenerate the default thumbnail?
if (positionToDelete === 1 && videoPlaylist.hasGeneratedThumbnail()) {
await regeneratePlaylistThumbnail(videoPlaylist)
}
sendUpdateVideoPlaylist(videoPlaylist, undefined)
.catch(err => logger.error('Cannot send video playlist update.', { err }))
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function reorderVideosOfPlaylist (req: express.Request, res: express.Response) {
const videoPlaylist = res.locals.videoPlaylistFull
const body: VideoPlaylistReorder = req.body
const start: number = body.startPosition
const insertAfter: number = body.insertAfterPosition
const reorderLength: number = body.reorderLength || 1
if (start === insertAfter) {
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
await sequelizeTypescript.transaction(async t => {
await reorderPlaylistOrElementsPosition({
model: VideoPlaylistElementModel,
instance: videoPlaylist,
start,
insertAfter,
reorderLength,
transaction: t
})
videoPlaylist.changed('updatedAt', true)
await videoPlaylist.save({ transaction: t })
await sendUpdateVideoPlaylist(videoPlaylist, t)
})
// The first element changed
if ((start === 1 || insertAfter === 0) && videoPlaylist.hasGeneratedThumbnail()) {
await regeneratePlaylistThumbnail(videoPlaylist)
}
logger.info(
'Reordered playlist %s (inserted after position %d elements %d - %d).',
videoPlaylist.uuid,
insertAfter,
start,
start + reorderLength - 1
)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function listVideosOfPlaylist (req: express.Request, res: express.Response) {
const videoPlaylistInstance = res.locals.videoPlaylistSummary
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
const server = await getServerActor()
const apiOptions = await Hooks.wrapObject({
start: req.query.start,
count: req.query.count,
videoPlaylistId: videoPlaylistInstance.id,
serverAccount: server.Account,
user
}, 'filter:api.video-playlist.videos.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoPlaylistElementModel.listForApi.bind(VideoPlaylistElementModel),
apiOptions,
'filter:api.video-playlist.videos.list.result'
)
const options = { accountId: user?.Account?.id }
return res.json(getFormattedObjects(resultList.data, resultList.total, options))
}
async function regeneratePlaylistThumbnail (videoPlaylist: MVideoPlaylistThumbnail) {
await videoPlaylist.Thumbnail.destroy()
videoPlaylist.Thumbnail = null
const firstElement = await VideoPlaylistElementModel.loadFirstElementWithVideoThumbnail(videoPlaylist.id)
if (firstElement) await generateThumbnailForPlaylist(videoPlaylist, firstElement.Video)
}

View File

@@ -0,0 +1,112 @@
import express from 'express'
import { blacklistVideo, unblacklistVideo } from '@server/lib/video-blacklist.js'
import { HttpStatusCode, UserRight, VideoBlacklistCreate } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import {
asyncMiddleware,
authenticate,
blacklistSortValidator,
ensureUserHasRight,
openapiOperationDoc,
paginationValidator,
setBlacklistSort,
setDefaultPagination,
videosBlacklistAddValidator,
videosBlacklistFiltersValidator,
videosBlacklistRemoveValidator,
videosBlacklistUpdateValidator
} from '../../../middlewares/index.js'
import { VideoBlacklistModel } from '../../../models/video/video-blacklist.js'
const blacklistRouter = express.Router()
blacklistRouter.post('/:videoId/blacklist',
openapiOperationDoc({ operationId: 'addVideoBlock' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
asyncMiddleware(videosBlacklistAddValidator),
asyncMiddleware(addVideoToBlacklistController)
)
blacklistRouter.get('/blacklist',
openapiOperationDoc({ operationId: 'getVideoBlocks' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
paginationValidator,
blacklistSortValidator,
setBlacklistSort,
setDefaultPagination,
videosBlacklistFiltersValidator,
asyncMiddleware(listBlacklist)
)
blacklistRouter.put('/:videoId/blacklist',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
asyncMiddleware(videosBlacklistUpdateValidator),
asyncMiddleware(updateVideoBlacklistController)
)
blacklistRouter.delete('/:videoId/blacklist',
openapiOperationDoc({ operationId: 'delVideoBlock' }),
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
asyncMiddleware(videosBlacklistRemoveValidator),
asyncMiddleware(removeVideoFromBlacklistController)
)
// ---------------------------------------------------------------------------
export {
blacklistRouter
}
// ---------------------------------------------------------------------------
async function addVideoToBlacklistController (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll
const body: VideoBlacklistCreate = req.body
await blacklistVideo(videoInstance, body)
logger.info('Video %s blacklisted.', videoInstance.uuid)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
const videoBlacklist = res.locals.videoBlacklist
if (req.body.reason !== undefined) videoBlacklist.reason = req.body.reason
await sequelizeTypescript.transaction(t => {
return videoBlacklist.save({ transaction: t })
})
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}
async function listBlacklist (req: express.Request, res: express.Response) {
const resultList = await VideoBlacklistModel.listForApi({
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
type: req.query.type
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function removeVideoFromBlacklistController (req: express.Request, res: express.Response) {
const videoBlacklist = res.locals.videoBlacklist
const video = res.locals.videoAll
await unblacklistVideo(videoBlacklist, video)
logger.info('Video %s removed from blacklist.', video.uuid)
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,148 @@
import { HttpStatusCode, VideoCaptionGenerate, VideoChannelActivityAction } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { createLocalCaption, createTranscriptionTaskIfNeeded, updateHLSMasterOnCaptionChangeIfNeeded } from '@server/lib/video-captions.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { federateVideoIfNeeded } from '../../../lib/activitypub/videos/index.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
import {
addVideoCaptionValidator,
deleteVideoCaptionValidator,
generateVideoCaptionValidator,
listVideoCaptionsValidator
} from '../../../middlewares/validators/index.js'
import { VideoCaptionModel } from '../../../models/video/video-caption.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const lTags = loggerTagsFactory('api', 'video-caption')
const reqVideoCaptionAdd = createReqFiles([ 'captionfile' ], MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
const videoCaptionsRouter = express.Router()
videoCaptionsRouter.post(
'/:videoId/captions/generate',
authenticate,
asyncMiddleware(generateVideoCaptionValidator),
asyncMiddleware(createGenerateVideoCaption)
)
videoCaptionsRouter.get('/:videoId/captions', asyncMiddleware(listVideoCaptionsValidator), asyncMiddleware(listVideoCaptions))
videoCaptionsRouter.put(
'/:videoId/captions/:captionLanguage',
authenticate,
reqVideoCaptionAdd,
asyncMiddleware(addVideoCaptionValidator),
asyncMiddleware(createVideoCaption)
)
videoCaptionsRouter.delete(
'/:videoId/captions/:captionLanguage',
authenticate,
asyncMiddleware(deleteVideoCaptionValidator),
asyncRetryTransactionMiddleware(deleteVideoCaption)
)
// ---------------------------------------------------------------------------
export {
videoCaptionsRouter
}
// ---------------------------------------------------------------------------
async function createGenerateVideoCaption (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const body = req.body as VideoCaptionGenerate
if (body.forceTranscription === true) {
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscription')
}
await createTranscriptionTaskIfNeeded(video)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function listVideoCaptions (req: express.Request, res: express.Response) {
const data = await VideoCaptionModel.listVideoCaptions(res.locals.onlyVideo.id)
return res.json(getFormattedObjects(data, data.length))
}
async function createVideoCaption (req: express.Request, res: express.Response) {
const videoCaptionPhysicalFile: Express.Multer.File = req.files['captionfile'][0]
const video = res.locals.videoAll
const captionLanguage = req.params.captionLanguage
const videoCaption = await createLocalCaption({
video,
language: captionLanguage,
path: videoCaptionPhysicalFile.path,
automaticallyGenerated: false
})
if (videoCaption.m3u8Filename) {
await updateHLSMasterOnCaptionChangeIfNeeded(video)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CAPTIONS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
return federateVideoIfNeeded(video, false, t)
})
})
Hooks.runAction('action:api.video-caption.created', { caption: videoCaption, req, res })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function deleteVideoCaption (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoCaption = res.locals.videoCaption
const hasM3U8 = !!videoCaption.m3u8Filename
await sequelizeTypescript.transaction(async t => {
await videoCaption.destroy({ transaction: t })
})
if (hasM3U8) {
await updateHLSMasterOnCaptionChangeIfNeeded(video)
}
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CAPTIONS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
return federateVideoIfNeeded(video, false, t)
})
})
logger.info('Video caption %s of video %s deleted.', videoCaption.language, video.uuid, lTags(video.uuid))
Hooks.runAction('action:api.video-caption.deleted', { caption: videoCaption, req, res })
return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
}

View File

@@ -0,0 +1,62 @@
import { HttpStatusCode, VideoChannelActivityAction, VideoChapterUpdate } from '@peertube/peertube-models'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/federate.js'
import { replaceChapters } from '@server/lib/video-chapters.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoChapterModel } from '@server/models/video/video-chapter.js'
import express from 'express'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares/index.js'
import { updateVideoChaptersValidator, videosCustomGetValidator } from '../../../middlewares/validators/index.js'
const videoChaptersRouter = express.Router()
videoChaptersRouter.get(
'/:id/chapters',
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
asyncMiddleware(listVideoChapters)
)
videoChaptersRouter.put(
'/:videoId/chapters',
authenticate,
asyncMiddleware(updateVideoChaptersValidator),
asyncRetryTransactionMiddleware(replaceVideoChapters)
)
// ---------------------------------------------------------------------------
export {
videoChaptersRouter
}
// ---------------------------------------------------------------------------
async function listVideoChapters (req: express.Request, res: express.Response) {
const chapters = await VideoChapterModel.listChaptersOfVideo(res.locals.onlyVideo.id)
return res.json({ chapters: chapters.map(c => c.toFormattedJSON()) })
}
async function replaceVideoChapters (req: express.Request, res: express.Response) {
const body = req.body as VideoChapterUpdate
const video = res.locals.videoAll
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
await replaceChapters({ video, chapters: body.chapters, transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_CHAPTERS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
await federateVideoIfNeeded(video, false, t)
})
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,255 @@
import { pick } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
ResultList,
ThreadsResultList,
UserRight,
VideoCommentCreate,
VideoCommentPolicy,
VideoCommentThreads
} from '@peertube/peertube-models'
import { getServerActor } from '@server/models/application/application.js'
import { MCommentFormattable } from '@server/types/models/index.js'
import express from 'express'
import { CommentAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { Notifier } from '../../../lib/notifier/index.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import { approveComment, buildFormattedCommentTree, createLocalVideoComment, removeComment } from '../../../lib/video-comment.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
ensureUserHasRight,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../../middlewares/index.js'
import {
addVideoCommentReplyValidator,
addVideoCommentThreadValidator,
approveVideoCommentValidator,
listAllVideoCommentsForAdminValidator,
listVideoCommentThreadsValidator,
listVideoThreadCommentsValidator,
removeVideoCommentValidator,
videoCommentThreadsSortValidator,
videoCommentsValidator
} from '../../../middlewares/validators/index.js'
import { VideoCommentModel } from '../../../models/video/video-comment.js'
const auditLogger = auditLoggerFactory('comments')
const videoCommentRouter = express.Router()
videoCommentRouter.get(
'/:videoId/comment-threads',
paginationValidator,
videoCommentThreadsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoCommentThreadsValidator),
optionalAuthenticate,
asyncMiddleware(listVideoThreads)
)
videoCommentRouter.get(
'/:videoId/comment-threads/:threadId',
asyncMiddleware(listVideoThreadCommentsValidator),
optionalAuthenticate,
asyncMiddleware(listVideoThreadComments)
)
videoCommentRouter.post(
'/:videoId/comment-threads',
authenticate,
asyncMiddleware(addVideoCommentThreadValidator),
asyncRetryTransactionMiddleware(addVideoCommentThread)
)
videoCommentRouter.post(
'/:videoId/comments/:commentId',
authenticate,
asyncMiddleware(addVideoCommentReplyValidator),
asyncRetryTransactionMiddleware(addVideoCommentReply)
)
videoCommentRouter.delete(
'/:videoId/comments/:commentId',
authenticate,
asyncMiddleware(removeVideoCommentValidator),
asyncRetryTransactionMiddleware(removeVideoComment)
)
videoCommentRouter.post(
'/:videoId/comments/:commentId/approve',
authenticate,
asyncMiddleware(approveVideoCommentValidator),
asyncMiddleware(approveVideoComment)
)
videoCommentRouter.get(
'/comments',
authenticate,
ensureUserHasRight(UserRight.SEE_ALL_COMMENTS),
paginationValidator,
videoCommentsValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAllVideoCommentsForAdminValidator),
asyncMiddleware(listComments)
)
// ---------------------------------------------------------------------------
export {
videoCommentRouter
}
// ---------------------------------------------------------------------------
async function listComments (req: express.Request, res: express.Response) {
const options = {
...pick(req.query, [
'start',
'count',
'sort',
'isLocal',
'onLocalVideo',
'search',
'searchAccount',
'searchVideo',
'autoTagOneOf'
]),
videoId: res.locals.onlyImmutableVideo?.id,
videoChannelOwnerId: res.locals.videoChannel?.id,
autoTagOfAccountId: (await getServerActor()).Account.id,
heldForReview: undefined
}
const resultList = await VideoCommentModel.listForApi(options)
return res.json({
total: resultList.total,
data: resultList.data.map(c => c.toFormattedForAdminOrUserJSON())
})
}
async function listVideoThreads (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
let resultList: ThreadsResultList<MCommentFormattable>
if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
const apiOptions = await Hooks.wrapObject({
video,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
user
}, 'filter:api.video-threads.list.params')
resultList = await Hooks.wrapPromiseFun(
VideoCommentModel.listThreadsForApi.bind(VideoCommentModel),
apiOptions,
'filter:api.video-threads.list.result'
)
} else {
resultList = {
total: 0,
totalNotDeletedComments: 0,
data: []
}
}
return res.json({
...getFormattedObjects(resultList.data, resultList.total),
totalNotDeletedComments: resultList.totalNotDeletedComments
} as VideoCommentThreads)
}
async function listVideoThreadComments (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
let resultList: ResultList<MCommentFormattable>
if (video.commentsPolicy !== VideoCommentPolicy.DISABLED) {
const apiOptions = await Hooks.wrapObject({
video,
threadId: res.locals.videoCommentThread.id,
user
}, 'filter:api.video-thread-comments.list.params')
resultList = await Hooks.wrapPromiseFun(
VideoCommentModel.listThreadCommentsForApi.bind(VideoCommentModel),
apiOptions,
'filter:api.video-thread-comments.list.result'
)
} else {
resultList = {
total: 0,
data: []
}
}
if (resultList.data.length === 0) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No comments were found'
})
}
return res.json(buildFormattedCommentTree(resultList))
}
async function addVideoCommentThread (req: express.Request, res: express.Response) {
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await createLocalVideoComment({
text: videoCommentInfo.text,
inReplyToComment: null,
video: res.locals.videoAll,
user: res.locals.oauth.token.User
})
Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
Hooks.runAction('action:api.video-thread.created', { comment, req, res })
return res.json({ comment: comment.toFormattedJSON() })
}
async function addVideoCommentReply (req: express.Request, res: express.Response) {
const videoCommentInfo: VideoCommentCreate = req.body
const comment = await createLocalVideoComment({
text: videoCommentInfo.text,
inReplyToComment: res.locals.videoCommentFull,
video: res.locals.videoAll,
user: res.locals.oauth.token.User
})
Notifier.Instance.notifyOnNewComment(comment)
auditLogger.create(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
Hooks.runAction('action:api.video-comment-reply.created', { comment, req, res })
return res.json({ comment: comment.toFormattedJSON() })
}
async function removeVideoComment (req: express.Request, res: express.Response) {
const comment = res.locals.videoCommentFull
await removeComment(comment, req, res)
auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(comment.toFormattedJSON()))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function approveVideoComment (req: express.Request, res: express.Response) {
await approveComment(res.locals.videoCommentFull)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,120 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { updateM3U8AndShaPlaylist } from '@server/lib/hls.js'
import { removeAllWebVideoFiles, removeHLSFile, removeHLSPlaylist, removeWebVideoFile } from '@server/lib/video-file.js'
import { VideoFileModel } from '@server/models/video/video-file.js'
import express from 'express'
import validator from 'validator'
import {
asyncMiddleware,
authenticate,
ensureUserHasRight,
videoFileMetadataGetValidator,
videoFilesDeleteHLSFileValidator,
videoFilesDeleteHLSValidator,
videoFilesDeleteWebVideoFileValidator,
videoFilesDeleteWebVideoValidator,
videosGetValidator
} from '../../../middlewares/index.js'
const lTags = loggerTagsFactory('api', 'video')
const filesRouter = express.Router()
filesRouter.get('/:id/metadata/:videoFileId',
asyncMiddleware(videosGetValidator),
asyncMiddleware(videoFileMetadataGetValidator),
asyncMiddleware(getVideoFileMetadata)
)
filesRouter.delete('/:id/hls',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoFilesDeleteHLSValidator),
asyncMiddleware(removeHLSPlaylistController)
)
filesRouter.delete('/:id/hls/:videoFileId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoFilesDeleteHLSFileValidator),
asyncMiddleware(removeHLSFileController)
)
filesRouter.delete('/:id/web-videos',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoFilesDeleteWebVideoValidator),
asyncMiddleware(removeAllWebVideoFilesController)
)
filesRouter.delete('/:id/web-videos/:videoFileId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_FILES),
asyncMiddleware(videoFilesDeleteWebVideoFileValidator),
asyncMiddleware(removeWebVideoFileController)
)
// ---------------------------------------------------------------------------
export {
filesRouter
}
// ---------------------------------------------------------------------------
async function getVideoFileMetadata (req: express.Request, res: express.Response) {
const videoFile = await VideoFileModel.loadWithMetadata(validator.default.toInt(req.params.videoFileId))
return res.json(videoFile.metadata)
}
// ---------------------------------------------------------------------------
async function removeHLSPlaylistController (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
logger.info('Deleting HLS playlist of %s.', video.url, lTags(video.uuid))
await removeHLSPlaylist(video)
await federateVideoIfNeeded(video, false, undefined)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function removeHLSFileController (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoFileId = +req.params.videoFileId
logger.info('Deleting HLS file %d of %s.', videoFileId, video.url, lTags(video.uuid))
const playlist = await removeHLSFile(video, videoFileId)
if (playlist) await updateM3U8AndShaPlaylist(video, playlist)
await federateVideoIfNeeded(video, false, undefined)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// ---------------------------------------------------------------------------
async function removeAllWebVideoFilesController (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
logger.info('Deleting Web Video files of %s.', video.url, lTags(video.uuid))
await removeAllWebVideoFiles(video)
await federateVideoIfNeeded(video, false, undefined)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function removeWebVideoFileController (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoFileId = +req.params.videoFileId
logger.info('Deleting Web Video file %d of %s.', videoFileId, video.url, lTags(video.uuid))
await removeWebVideoFile(video, videoFileId)
await federateVideoIfNeeded(video, false, undefined)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,299 @@
import {
HttpStatusCode,
HttpStatusCodeType,
ServerErrorCode,
ThumbnailType,
VideoImportCreate,
VideoImportPayload,
VideoImportState
} from '@peertube/peertube-models'
import { YoutubeDlImportError, YoutubeDlImportErrorCode } from '@server/helpers/youtube-dl/youtube-dl-wrapper.js'
import { buildRetryImportJob } from '@server/lib/video-post-import.js'
import { buildVideoFromImport, buildYoutubeDLImport, insertFromImportIntoDB } from '@server/lib/video-pre-import.js'
import { MThumbnail, MVideoThumbnail } from '@server/types/models/index.js'
import express from 'express'
import { move } from 'fs-extra/esm'
import { readFile } from 'fs/promises'
import { decode } from 'magnet-uri'
import parseTorrent, { Instance } from 'parse-torrent'
import { join } from 'path'
import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger.js'
import { isArray } from '../../../helpers/custom-validators/misc.js'
import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getSecureTorrentName } from '../../../helpers/utils.js'
import { CONFIG } from '../../../initializers/config.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { JobQueue } from '../../../lib/job-queue/job-queue.js'
import { updateLocalVideoMiniatureFromExisting } from '../../../lib/thumbnail.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
videoImportAddValidator,
videoImportCancelValidator,
videoImportDeleteValidator,
videoImportRetryValidator
} from '../../../middlewares/index.js'
const auditLogger = auditLoggerFactory('video-imports')
const videoImportsRouter = express.Router()
const reqVideoFileImport = createReqFiles(
[ 'thumbnailfile', 'previewfile', 'torrentfile' ],
{ ...MIMETYPES.TORRENT.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
)
videoImportsRouter.post(
'/imports',
authenticate,
reqVideoFileImport,
asyncMiddleware(videoImportAddValidator),
asyncRetryTransactionMiddleware(handleVideoImport)
)
videoImportsRouter.post(
'/imports/:id/cancel',
authenticate,
asyncMiddleware(videoImportCancelValidator),
asyncRetryTransactionMiddleware(cancelVideoImport)
)
videoImportsRouter.post(
'/imports/:id/retry',
authenticate,
asyncMiddleware(videoImportRetryValidator),
asyncRetryTransactionMiddleware(retryVideoImport)
)
videoImportsRouter.delete(
'/imports/:id',
authenticate,
asyncMiddleware(videoImportDeleteValidator),
asyncRetryTransactionMiddleware(deleteVideoImport)
)
// ---------------------------------------------------------------------------
export {
videoImportsRouter
}
// ---------------------------------------------------------------------------
async function deleteVideoImport (req: express.Request, res: express.Response) {
const videoImport = res.locals.videoImport
await videoImport.destroy()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function cancelVideoImport (req: express.Request, res: express.Response) {
const videoImport = res.locals.videoImport
videoImport.state = VideoImportState.CANCELLED
await videoImport.save()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function retryVideoImport (req: express.Request, res: express.Response) {
const videoImport = res.locals.videoImport
await JobQueue.Instance.createJob(await buildRetryImportJob(videoImport))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
function handleVideoImport (req: express.Request, res: express.Response) {
if (req.body.targetUrl) return handleYoutubeDlImport(req, res)
const file = req.files?.['torrentfile']?.[0]
if (req.body.magnetUri || file) return handleTorrentImport(req, res, file)
}
async function handleTorrentImport (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
const body: VideoImportCreate = req.body
const user = res.locals.oauth.token.User
let videoName: string
let torrentName: string
let magnetUri: string
if (torrentfile) {
const result = await processTorrentOrAbortRequest(req, res, torrentfile)
if (!result) return
videoName = result.name
torrentName = result.torrentName
} else {
const result = processMagnetURI(body)
magnetUri = result.magnetUri
videoName = result.name
}
const video = await buildVideoFromImport({
channelId: res.locals.videoChannel.id,
importData: { name: videoName },
importDataOverride: body,
importType: 'torrent'
})
const thumbnailModel = await processThumbnail(req, video)
const previewModel = await processPreview(req, video)
const videoImport = await insertFromImportIntoDB({
video,
thumbnailModel,
previewModel,
videoChannel: res.locals.videoChannel,
tags: body.tags || undefined,
user,
videoPasswords: body.videoPasswords,
videoImportAttributes: {
magnetUri,
torrentName,
state: VideoImportState.PENDING,
userId: user.id
}
})
const payload: VideoImportPayload = {
type: torrentfile
? 'torrent-file'
: 'magnet-uri',
videoImportId: videoImport.id,
preventException: false,
generateTranscription: body.generateTranscription
}
videoImport.payload = payload
await videoImport.save()
await JobQueue.Instance.createJob({ type: 'video-import', payload })
auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
return res.json(videoImport.toFormattedJSON()).end()
}
function statusFromYtDlImportError (err: YoutubeDlImportError): HttpStatusCodeType {
switch (err.code) {
case YoutubeDlImportErrorCode.NOT_ONLY_UNICAST_URL:
return HttpStatusCode.FORBIDDEN_403
case YoutubeDlImportErrorCode.FETCH_ERROR:
case YoutubeDlImportErrorCode.IS_LIVE:
return HttpStatusCode.BAD_REQUEST_400
default:
return HttpStatusCode.INTERNAL_SERVER_ERROR_500
}
}
async function handleYoutubeDlImport (req: express.Request, res: express.Response) {
const body: VideoImportCreate = req.body
const targetUrl = body.targetUrl
const user = res.locals.oauth.token.User
try {
const { job, videoImport } = await buildYoutubeDLImport({
targetUrl,
channel: res.locals.videoChannel,
importDataOverride: body,
thumbnailFilePath: req.files?.['thumbnailfile']?.[0].path,
previewFilePath: req.files?.['previewfile']?.[0].path,
user
})
await JobQueue.Instance.createJob(job)
auditLogger.create(getAuditIdFromRes(res), new VideoImportAuditView(videoImport.toFormattedJSON()))
return res.json(videoImport.toFormattedJSON()).end()
} catch (err) {
logger.error('An error occurred while importing the video %s. ', targetUrl, { err })
return res.fail({
message: err.message,
status: statusFromYtDlImportError(err),
data: {
targetUrl
}
})
}
}
async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
const thumbnailField = req.files ? req.files['thumbnailfile'] : undefined
if (thumbnailField) {
const thumbnailPhysicalFile = thumbnailField[0]
return updateLocalVideoMiniatureFromExisting({
inputPath: thumbnailPhysicalFile.path,
video,
type: ThumbnailType.MINIATURE,
automaticallyGenerated: false
})
}
return undefined
}
async function processPreview (req: express.Request, video: MVideoThumbnail): Promise<MThumbnail> {
const previewField = req.files ? req.files['previewfile'] : undefined
if (previewField) {
const previewPhysicalFile = previewField[0]
return updateLocalVideoMiniatureFromExisting({
inputPath: previewPhysicalFile.path,
video,
type: ThumbnailType.PREVIEW,
automaticallyGenerated: false
})
}
return undefined
}
async function processTorrentOrAbortRequest (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
const torrentName = torrentfile.originalname
// Rename the torrent to a secured name
const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
await move(torrentfile.path, newTorrentPath, { overwrite: true })
torrentfile.path = newTorrentPath
const buf = await readFile(torrentfile.path)
// FIXME: typings: parseTorrent now returns an async result
const parsedTorrent = await (parseTorrent(buf) as unknown as Promise<Instance>)
if (parsedTorrent.files.length !== 1) {
cleanUpReqFiles(req)
res.fail({
type: ServerErrorCode.INCORRECT_FILES_IN_TORRENT,
message: 'Torrents with only 1 file are supported.'
})
return undefined
}
return {
name: extractNameFromArray(parsedTorrent.name),
torrentName
}
}
function processMagnetURI (body: VideoImportCreate) {
const magnetUri = body.magnetUri
const parsed = decode(magnetUri)
return {
name: extractNameFromArray(parsed.name),
magnetUri
}
}
function extractNameFromArray (name: string | string[]) {
return isArray(name) ? name[0] : name
}

View File

@@ -0,0 +1,200 @@
import { HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { pickCommonVideoQuery } from '@server/helpers/query.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { getServerActor } from '@server/models/application/application.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { buildNSFWFilters, getCountVideos } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { JobQueue } from '../../../lib/job-queue/index.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import {
apiRateLimiter,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
checkVideoFollowConstraints,
commonVideosFiltersValidatorFactory,
optionalAuthenticate,
paginationValidator,
setDefaultPagination,
setDefaultVideosSort,
videosCustomGetValidator,
videosRemoveValidator,
videosSortValidator
} from '../../../middlewares/index.js'
import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter/index.js'
import { VideoModel } from '../../../models/video/video.js'
import { blacklistRouter } from './blacklist.js'
import { videoCaptionsRouter } from './captions.js'
import { videoChaptersRouter } from './chapters.js'
import { videoCommentRouter } from './comment.js'
import { filesRouter } from './files.js'
import { videoImportsRouter } from './import.js'
import { liveRouter } from './live.js'
import { ownershipVideoRouter } from './ownership.js'
import { videoPasswordRouter } from './passwords.js'
import { rateVideoRouter } from './rate.js'
import { videoSourceRouter } from './source.js'
import { statsRouter } from './stats.js'
import { storyboardRouter } from './storyboard.js'
import { studioRouter } from './studio.js'
import { tokenRouter } from './token.js'
import { transcodingRouter } from './transcoding.js'
import { updateRouter } from './update.js'
import { uploadRouter } from './upload.js'
import { viewRouter } from './view.js'
const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router()
videosRouter.use(apiRateLimiter)
videosRouter.use('/', blacklistRouter)
videosRouter.use('/', statsRouter)
videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter)
videosRouter.use('/', studioRouter)
videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', viewRouter)
videosRouter.use('/', liveRouter)
videosRouter.use('/', uploadRouter)
videosRouter.use('/', updateRouter)
videosRouter.use('/', filesRouter)
videosRouter.use('/', transcodingRouter)
videosRouter.use('/', tokenRouter)
videosRouter.use('/', videoPasswordRouter)
videosRouter.use('/', storyboardRouter)
videosRouter.use('/', videoSourceRouter)
videosRouter.use('/', videoChaptersRouter)
videosRouter.get('/categories', openapiOperationDoc({ operationId: 'getCategories' }), listVideoCategories)
videosRouter.get('/licences', openapiOperationDoc({ operationId: 'getLicences' }), listVideoLicences)
videosRouter.get('/languages', openapiOperationDoc({ operationId: 'getLanguages' }), listVideoLanguages)
videosRouter.get('/privacies', openapiOperationDoc({ operationId: 'getPrivacies' }), listVideoPrivacies)
videosRouter.get(
'/',
openapiOperationDoc({ operationId: 'getVideos' }),
paginationValidator,
videosSortValidator,
setDefaultVideosSort,
setDefaultPagination,
optionalAuthenticate,
commonVideosFiltersValidatorFactory(),
asyncMiddleware(listVideos)
)
videosRouter.get(
'/:id',
openapiOperationDoc({ operationId: 'getVideo' }),
optionalAuthenticate,
asyncMiddleware(videosCustomGetValidator('for-api')),
asyncMiddleware(checkVideoFollowConstraints),
asyncMiddleware(getVideo)
)
videosRouter.delete(
'/:id',
openapiOperationDoc({ operationId: 'delVideo' }),
authenticate,
asyncMiddleware(videosRemoveValidator),
asyncRetryTransactionMiddleware(removeVideo)
)
// ---------------------------------------------------------------------------
export {
videosRouter
}
// ---------------------------------------------------------------------------
function listVideoCategories (_req: express.Request, res: express.Response) {
res.json(VIDEO_CATEGORIES)
}
function listVideoLicences (_req: express.Request, res: express.Response) {
res.json(VIDEO_LICENCES)
}
function listVideoLanguages (_req: express.Request, res: express.Response) {
res.json(VIDEO_LANGUAGES)
}
function listVideoPrivacies (_req: express.Request, res: express.Response) {
res.json(VIDEO_PRIVACIES)
}
async function getVideo (req: express.Request, res: express.Response) {
const videoId = res.locals.videoAPI.id
const userId = res.locals.oauth?.token.User.id
const video = await Hooks.wrapObject(res.locals.videoAPI, 'filter:api.video.get.result', { req, id: videoId, userId })
// Filter may return null/undefined value to forbid video access
if (!video) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (video.isOutdated()) {
JobQueue.Instance.createJobAsync({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
}
return res.json(video.toFormattedDetailsJSON())
}
async function listVideos (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const query = pickCommonVideoQuery(req.query)
const countVideos = getCountVideos(req)
const apiOptions = await Hooks.wrapObject({
...query,
...buildNSFWFilters({ req, res }),
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
user: res.locals.oauth ? res.locals.oauth.token.User : undefined,
countVideos
}, 'filter:api.videos.list.params')
const resultList = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
apiOptions,
'filter:api.videos.list.result'
)
return res.json(getFormattedObjects(resultList.data, resultList.total, guessAdditionalAttributesFromQuery(query)))
}
async function removeVideo (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll
await sequelizeTypescript.transaction(async t => {
await videoInstance.destroy({ transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.DELETE,
user: res.locals.oauth.token.User,
channel: videoInstance.VideoChannel,
video: videoInstance,
transaction: t
})
})
auditLogger.delete(getAuditIdFromRes(res), new VideoAuditView(videoInstance.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s deleted.', videoInstance.name, videoInstance.uuid)
Hooks.runAction('action:api.video.deleted', { video: videoInstance, req, res })
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}

View File

@@ -0,0 +1,256 @@
import { pick } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
LiveVideoCreate,
LiveVideoUpdate,
ThumbnailType,
UserRight,
VideoChannelActivityAction,
VideoState
} from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { exists, isArray } from '@server/helpers/custom-validators/misc.js'
import { retryTransactionWrapper } from '@server/helpers/database-utils.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js'
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { checkCanManageVideo } from '@server/middlewares/validators/shared/videos.js'
import {
videoLiveAddValidator,
videoLiveFindReplaySessionValidator,
videoLiveGetValidator,
videoLiveListSessionsValidator,
videoLiveUpdateValidator
} from '@server/middlewares/validators/videos/video-live.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoLiveReplaySettingModel } from '@server/models/video/video-live-replay-setting.js'
import { VideoLiveScheduleModel } from '@server/models/video/video-live-schedule.js'
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
import { MVideoLive } from '@server/types/models/index.js'
import express from 'express'
import { Transaction } from 'sequelize'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
liveSessionsSortValidator,
optionalAuthenticate,
setLiveSessionsSort
} from '../../../middlewares/index.js'
const lTags = loggerTagsFactory('api', 'live')
const liveRouter = express.Router()
const reqVideoFileLive = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
liveRouter.post(
'/live',
authenticate,
reqVideoFileLive,
asyncMiddleware(videoLiveAddValidator),
asyncRetryTransactionMiddleware(addLiveVideo)
)
liveRouter.get(
'/live/:videoId/sessions',
authenticate,
liveSessionsSortValidator,
setLiveSessionsSort,
asyncMiddleware(videoLiveGetValidator),
asyncMiddleware(videoLiveListSessionsValidator),
asyncMiddleware(listLiveVideoSessions)
)
liveRouter.get(
'/live/:videoId',
optionalAuthenticate,
asyncMiddleware(videoLiveGetValidator),
asyncMiddleware(getLiveVideo)
)
liveRouter.put(
'/live/:videoId',
authenticate,
asyncMiddleware(videoLiveGetValidator),
asyncMiddleware(videoLiveUpdateValidator),
asyncRetryTransactionMiddleware(updateLiveVideo)
)
liveRouter.get(
'/:videoId/live-session',
asyncMiddleware(videoLiveFindReplaySessionValidator),
getLiveReplaySession
)
// ---------------------------------------------------------------------------
export {
liveRouter
}
// ---------------------------------------------------------------------------
async function getLiveVideo (req: express.Request, res: express.Response) {
const videoLive = res.locals.videoLive
return res.json(videoLive.toFormattedJSON(await canSeePrivateLiveInformation(req, res)))
}
function getLiveReplaySession (req: express.Request, res: express.Response) {
const session = res.locals.videoLiveSession
return res.json(session.toFormattedJSON())
}
async function listLiveVideoSessions (req: express.Request, res: express.Response) {
const videoLive = res.locals.videoLive
const data = await VideoLiveSessionModel.listSessionsOfLiveForAPI({
videoId: videoLive.videoId,
count: 100,
sort: req.query.sort
})
return res.json(getFormattedObjects(data, data.length))
}
function canSeePrivateLiveInformation (req: express.Request, res: express.Response) {
return checkCanManageVideo({
user: res.locals.oauth?.token.User,
video: res.locals.videoAll,
right: UserRight.GET_ANY_LIVE,
req,
res: null,
checkIsLocal: true,
checkIsOwner: false
})
}
async function updateLiveVideo (req: express.Request, res: express.Response) {
const body: LiveVideoUpdate = req.body
const video = res.locals.videoAll
const videoLive = res.locals.videoLive
await retryTransactionWrapper(() => {
return sequelizeTypescript.transaction(async t => {
const newReplaySettingModel = await updateReplaySettings(videoLive, body, t)
if (newReplaySettingModel) videoLive.replaySettingId = newReplaySettingModel.id
else videoLive.replaySettingId = null
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
if (body.schedules !== undefined) {
await VideoLiveScheduleModel.deleteAllOfLiveId(videoLive.id, t)
videoLive.LiveSchedules = []
if (isArray(body.schedules)) {
videoLive.LiveSchedules = await VideoLiveScheduleModel.addToLiveId(videoLive.id, body.schedules.map(s => s.startAt), t)
}
}
video.VideoLive = await videoLive.save({ transaction: t })
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
})
})
await federateVideoIfNeeded(video, false)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateReplaySettings (videoLive: MVideoLive, body: LiveVideoUpdate, t: Transaction) {
if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
// The live replay is not saved anymore, destroy the old model if it existed
if (!videoLive.saveReplay) {
if (videoLive.replaySettingId) {
await VideoLiveReplaySettingModel.removeSettings(videoLive.replaySettingId, t)
}
return undefined
}
const settingModel = videoLive.replaySettingId
? await VideoLiveReplaySettingModel.load(videoLive.replaySettingId, t)
: new VideoLiveReplaySettingModel()
if (exists(body.replaySettings.privacy)) settingModel.privacy = body.replaySettings.privacy
return settingModel.save({ transaction: t })
}
async function addLiveVideo (req: express.Request, res: express.Response) {
const videoInfo: LiveVideoCreate = req.body
const thumbnails = [ { type: ThumbnailType.MINIATURE, field: 'thumbnailfile' }, { type: ThumbnailType.PREVIEW, field: 'previewfile' } ]
.map(({ type, field }) => {
if (req.files?.[field]?.[0]) {
return {
path: req.files[field][0].path,
type,
automaticallyGenerated: false,
keepOriginal: false
}
}
return {
path: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
type,
automaticallyGenerated: true,
keepOriginal: true
}
})
const localVideoCreator = new LocalVideoCreator({
channel: res.locals.videoChannel,
chapters: undefined,
fallbackChapters: {
fromDescription: false,
finalFallback: undefined
},
liveAttributes: pick(videoInfo, [ 'saveReplay', 'permanentLive', 'latencyMode', 'replaySettings', 'schedules' ]),
videoAttributeResultHook: 'filter:api.video.live.video-attribute.result',
lTags,
videoAttributes: {
...videoInfo,
duration: 0,
state: VideoState.WAITING_FOR_LIVE,
isLive: true,
inputFilename: null
},
videoFile: undefined,
user: res.locals.oauth.token.User,
thumbnails
})
const { video } = await localVideoCreator.create()
logger.info('Video live %s with uuid %s created.', videoInfo.name, video.uuid, lTags())
Hooks.runAction('action:api.live-video.created', { video, req, res })
return res.json({
video: {
id: video.id,
shortUUID: uuidToShort(video.uuid),
uuid: video.uuid
}
})
}

View File

@@ -0,0 +1,142 @@
import { HttpStatusCode, VideoChangeOwnershipStatus } from '@peertube/peertube-models'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
import { MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { logger } from '../../../helpers/logger.js'
import { getFormattedObjects } from '../../../helpers/utils.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { sendUpdateVideo } from '../../../lib/activitypub/send/index.js'
import { changeVideoChannelShare } from '../../../lib/activitypub/share.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
paginationValidator,
setDefaultPagination,
videosAcceptChangeOwnershipValidator,
videosChangeOwnershipValidator,
videosTerminateChangeOwnershipValidator
} from '../../../middlewares/index.js'
import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { VideoModel } from '../../../models/video/video.js'
const ownershipVideoRouter = express.Router()
ownershipVideoRouter.post(
'/:videoId/give-ownership',
authenticate,
asyncMiddleware(videosChangeOwnershipValidator),
asyncRetryTransactionMiddleware(giveVideoOwnership)
)
ownershipVideoRouter.get(
'/ownership',
authenticate,
paginationValidator,
setDefaultPagination,
asyncRetryTransactionMiddleware(listVideoOwnership)
)
ownershipVideoRouter.post(
'/ownership/:id/accept',
authenticate,
asyncMiddleware(videosTerminateChangeOwnershipValidator),
asyncMiddleware(videosAcceptChangeOwnershipValidator),
asyncRetryTransactionMiddleware(acceptOwnership)
)
ownershipVideoRouter.post(
'/ownership/:id/refuse',
authenticate,
asyncMiddleware(videosTerminateChangeOwnershipValidator),
asyncRetryTransactionMiddleware(refuseOwnership)
)
// ---------------------------------------------------------------------------
export {
ownershipVideoRouter
}
// ---------------------------------------------------------------------------
async function giveVideoOwnership (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll
const initiatorAccountId = res.locals.oauth.token.User.Account.id
const nextOwner = res.locals.nextOwner
await sequelizeTypescript.transaction(t => {
return VideoChangeOwnershipModel.findOrCreate({
where: {
initiatorAccountId,
nextOwnerAccountId: nextOwner.id,
videoId: videoInstance.id,
status: VideoChangeOwnershipStatus.WAITING
},
defaults: {
initiatorAccountId,
nextOwnerAccountId: nextOwner.id,
videoId: videoInstance.id,
status: VideoChangeOwnershipStatus.WAITING
},
transaction: t
})
})
logger.info('Ownership change for video %s created.', videoInstance.name)
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}
async function listVideoOwnership (req: express.Request, res: express.Response) {
const currentAccountId = res.locals.oauth.token.User.Account.id
const resultList = await VideoChangeOwnershipModel.listForApi(
currentAccountId,
req.query.start,
req.query.count,
req.query.sort || 'createdAt'
)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
function acceptOwnership (req: express.Request, res: express.Response) {
return sequelizeTypescript.transaction(async t => {
const videoChangeOwnership = res.locals.videoChangeOwnership
const channel = res.locals.videoChannel
// We need more attributes for federation
const targetVideo = await VideoModel.loadFull(videoChangeOwnership.Video.id, t)
const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId, t)
targetVideo.channelId = channel.id
const targetVideoUpdated = await targetVideo.save({ transaction: t }) as MVideoFullLight
targetVideoUpdated.VideoChannel = channel
if (canVideoBeFederated(targetVideoUpdated)) {
await changeVideoChannelShare(targetVideoUpdated, oldVideoChannel, t)
await sendUpdateVideo(targetVideoUpdated, t, oldVideoChannel.Account.Actor)
}
videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED
await videoChangeOwnership.save({ transaction: t })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
})
}
function refuseOwnership (req: express.Request, res: express.Response) {
return sequelizeTypescript.transaction(async t => {
const videoChangeOwnership = res.locals.videoChangeOwnership
videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED
await videoChangeOwnership.save({ transaction: t })
return res.status(HttpStatusCode.NO_CONTENT_204).end()
})
}

View File

@@ -0,0 +1,138 @@
import { HttpStatusCode, VideoChannelActivityAction } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import express from 'express'
import { Transaction } from 'sequelize'
import { getFormattedObjects } from '../../../helpers/utils.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
setDefaultPagination,
setDefaultSort
} from '../../../middlewares/index.js'
import {
addVideoPasswordValidator,
listVideoPasswordValidator,
paginationValidator,
removeVideoPasswordValidator,
updateVideoPasswordListValidator,
videoPasswordsSortValidator
} from '../../../middlewares/validators/index.js'
const lTags = loggerTagsFactory('api', 'video')
const videoPasswordRouter = express.Router()
videoPasswordRouter.get(
'/:videoId/passwords',
authenticate,
paginationValidator,
videoPasswordsSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoPasswordValidator),
asyncMiddleware(listVideoPasswords)
)
videoPasswordRouter.put(
'/:videoId/passwords',
authenticate,
asyncMiddleware(updateVideoPasswordListValidator),
asyncMiddleware(updateVideoPasswordList)
)
videoPasswordRouter.post('/:videoId/passwords', authenticate, asyncMiddleware(addVideoPasswordValidator), asyncMiddleware(addVideoPassword))
videoPasswordRouter.delete(
'/:videoId/passwords/:passwordId',
authenticate,
asyncMiddleware(removeVideoPasswordValidator),
asyncRetryTransactionMiddleware(removeVideoPassword)
)
// ---------------------------------------------------------------------------
export {
videoPasswordRouter
}
// ---------------------------------------------------------------------------
async function listVideoPasswords (req: express.Request, res: express.Response) {
const options = {
videoId: res.locals.videoAll.id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
}
const resultList = await VideoPasswordModel.listPasswords(options)
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
async function updateVideoPasswordList (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoId = video.id
const passwordArray = req.body.passwords as string[]
await VideoPasswordModel.sequelize.transaction(async (t: Transaction) => {
await VideoPasswordModel.deleteAllPasswords(videoId, t)
await VideoPasswordModel.addPasswords(passwordArray, videoId, t)
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_PASSWORDS,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: t
})
})
logger.info(`Video passwords for video with name ${video.name} and uuid ${video.uuid} have been updated`, lTags(video.uuid))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function addVideoPassword (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoId = video.id
const newPassword = req.body.password as string
await sequelizeTypescript.transaction(t => {
return VideoPasswordModel.addPassword(newPassword, videoId, t)
})
logger.info(`Video password for video with name ${video.name} and uuid ${video.uuid} have been added`, lTags(video.uuid))
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function removeVideoPassword (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll
const password = res.locals.videoPassword
await VideoPasswordModel.deletePassword(password.id)
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_PASSWORDS,
user: res.locals.oauth.token.User,
channel: videoInstance.VideoChannel,
video: videoInstance,
transaction: null
})
logger.info(
'Password with id %d of video named %s and uuid %s has been deleted.',
password.id,
videoInstance.name,
videoInstance.uuid,
lTags(videoInstance.uuid)
)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,36 @@
import express from 'express'
import { HttpStatusCode, UserVideoRateUpdate } from '@peertube/peertube-models'
import { logger } from '../../../helpers/logger.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoUpdateRateValidator } from '../../../middlewares/index.js'
import { userRateVideo } from '@server/lib/rate.js'
const rateVideoRouter = express.Router()
rateVideoRouter.put('/:id/rate',
authenticate,
asyncMiddleware(videoUpdateRateValidator),
asyncRetryTransactionMiddleware(rateVideo)
)
// ---------------------------------------------------------------------------
export {
rateVideoRouter
}
// ---------------------------------------------------------------------------
async function rateVideo (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const video = res.locals.videoAll
await userRateVideo({
account: user.Account,
rateType: (req.body as UserVideoRateUpdate).rating,
video
})
logger.info('Account video rate for video %s of account %s updated.', video.name, user.username)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,247 @@
import { buildAspectRatio } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoChannelActivityAction, VideoState } from '@peertube/peertube-models'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js'
import { setupUploadResumableRoutes } from '@server/lib/uploadx.js'
import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js'
import { regenerateTranscriptionTaskIfNeeded } from '@server/lib/video-captions.js'
import { buildNewFile, createVideoSource } from '@server/lib/video-file.js'
import { addRemoteStoryboardJobIfNeeded, buildLocalStoryboardJobIfNeeded, buildMoveVideoJob } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoModel } from '@server/models/video/video.js'
import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { move } from 'fs-extra/esm'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import {
asyncMiddleware,
authenticate,
replaceVideoSourceResumableInitValidator,
replaceVideoSourceResumableValidator,
videoSourceGetLatestValidator
} from '../../../middlewares/index.js'
const lTags = loggerTagsFactory('api', 'video')
const videoSourceRouter = express.Router()
videoSourceRouter.get(
'/:id/source',
openapiOperationDoc({ operationId: 'getVideoSource' }),
authenticate,
asyncMiddleware(videoSourceGetLatestValidator),
getVideoLatestSource
)
videoSourceRouter.delete(
'/:id/source/file',
openapiOperationDoc({ operationId: 'deleteVideoSourceFile' }),
authenticate,
asyncMiddleware(videoSourceGetLatestValidator),
asyncMiddleware(deleteVideoLatestSourceFile)
)
setupUploadResumableRoutes({
routePath: '/:id/source/replace-resumable',
router: videoSourceRouter,
uploadInitAfterMiddlewares: [ asyncMiddleware(replaceVideoSourceResumableInitValidator) ],
uploadedMiddlewares: [ asyncMiddleware(replaceVideoSourceResumableValidator) ],
uploadedController: asyncMiddleware(replaceVideoSourceResumable)
})
// ---------------------------------------------------------------------------
export {
videoSourceRouter
}
// ---------------------------------------------------------------------------
async function deleteVideoLatestSourceFile (req: express.Request, res: express.Response) {
const videoSource = res.locals.videoSource
const video = res.locals.videoAll
await video.removeOriginalFile(videoSource)
videoSource.keptOriginalFilename = null
videoSource.storage = null
await videoSource.save()
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_SOURCE_FILE,
user: res.locals.oauth.token.User,
channel: video.VideoChannel,
video,
transaction: null
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
function getVideoLatestSource (req: express.Request, res: express.Response) {
return res.json(res.locals.videoSource.toFormattedJSON())
}
async function replaceVideoSourceResumable (req: express.Request, res: express.Response) {
const videoPhysicalFile = res.locals.updateVideoFileResumable
const user = res.locals.oauth.token.User
const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video', ffprobe: res.locals.ffprobe })
const originalFilename = videoPhysicalFile.originalname
const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid)
try {
const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile)
await move(videoPhysicalFile.path, destination)
let oldWebVideoFiles: MVideoFile[] = []
let oldStreamingPlaylists: MStreamingPlaylistFiles[] = []
const inputFileUpdatedAt = new Date()
const video = await sequelizeTypescript.transaction(async transaction => {
const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction)
oldWebVideoFiles = video.VideoFiles
oldStreamingPlaylists = video.VideoStreamingPlaylists
for (const file of video.VideoFiles) {
await file.destroy({ transaction })
}
for (const playlist of oldStreamingPlaylists) {
await playlist.destroy({ transaction })
}
videoFile.videoId = video.id
await videoFile.save({ transaction })
video.VideoFiles = [ videoFile ]
video.VideoStreamingPlaylists = []
video.state = buildNextVideoState()
video.duration = videoPhysicalFile.duration
video.inputFileUpdatedAt = inputFileUpdatedAt
video.aspectRatio = buildAspectRatio({ width: videoFile.width, height: videoFile.height })
await video.save({ transaction })
await autoBlacklistVideoIfNeeded({
video,
user,
isRemote: false,
isNew: false,
isNewFile: true,
transaction
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE_SOURCE_FILE,
user,
channel: video.VideoChannel,
video,
transaction
})
return video
})
await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists })
const source = await createVideoSource({
inputFilename: originalFilename,
inputProbe: res.locals.ffprobe,
inputPath: destination,
video,
createdAt: inputFileUpdatedAt
})
await regenerateMiniaturesIfNeeded(video, res.locals.ffprobe)
await video.VideoChannel.setAsUpdated()
await addVideoJobsAfterUpload(video, videoFile.withVideoOrPlaylist(video))
logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid))
Hooks.runAction('action:api.video.file-updated', { video, req, res })
return res.json(source.toFormattedJSON())
} finally {
videoFileMutexReleaser()
}
}
async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) {
const jobs: (CreateJobArgument & CreateJobOptions)[] = [
{
type: 'manage-video-torrent' as const,
payload: {
videoId: video.id,
videoFileId: videoFile.id,
action: 'create'
}
},
await buildLocalStoryboardJobIfNeeded({ video, federate: false }),
{
type: 'federate-video' as const,
payload: {
videoUUID: video.uuid,
isNewVideoForFederation: false
}
}
]
if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
jobs.push(
await buildMoveVideoJob({
type: 'move-to-object-storage',
video,
moveVideoState: {
isNewVideo: false,
previousVideoState: undefined
}
})
)
}
if (video.state === VideoState.TO_TRANSCODE) {
jobs.push({
type: 'transcoding-job-builder' as const,
payload: {
videoUUID: video.uuid,
optimizeJob: {
isNewVideo: false
}
}
})
}
await JobQueue.Instance.createSequentialJobFlow(...jobs)
await addRemoteStoryboardJobIfNeeded(video)
await regenerateTranscriptionTaskIfNeeded(video)
}
async function removeOldFiles (options: {
video: MVideo
files: MVideoFile[]
playlists: MStreamingPlaylistFiles[]
}) {
const { video, files, playlists } = options
for (const file of files) {
await video.removeWebVideoFile(file)
}
for (const playlist of playlists) {
await video.removeAllStreamingPlaylistFiles({ playlist })
}
}

View File

@@ -0,0 +1,103 @@
import {
VideoStatsOverallQuery,
VideoStatsTimeserieMetric,
VideoStatsTimeserieQuery,
VideoStatsUserAgentQuery
} from '@peertube/peertube-models'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
import express from 'express'
import {
asyncMiddleware,
authenticate,
videoOverallOrUserAgentStatsValidator,
videoRetentionStatsValidator,
videoTimeseriesStatsValidator
} from '../../../middlewares/index.js'
const statsRouter = express.Router()
statsRouter.get(
'/:videoId/stats/overall',
authenticate,
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
asyncMiddleware(getOverallStats)
)
statsRouter.get(
'/:videoId/stats/timeseries/:metric',
authenticate,
asyncMiddleware(videoTimeseriesStatsValidator),
asyncMiddleware(getTimeseriesStats)
)
statsRouter.get(
'/:videoId/stats/retention',
authenticate,
asyncMiddleware(videoRetentionStatsValidator),
asyncMiddleware(getRetentionStats)
)
statsRouter.get(
'/:videoId/stats/user-agent',
authenticate,
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
asyncMiddleware(getUserAgentStats)
)
// ---------------------------------------------------------------------------
export {
statsRouter
}
// ---------------------------------------------------------------------------
async function getOverallStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const query = req.query as VideoStatsOverallQuery
const stats = await LocalVideoViewerModel.getOverallStats({
video,
startDate: query.startDate,
endDate: query.endDate
})
return res.json(stats)
}
async function getUserAgentStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const query = req.query as VideoStatsUserAgentQuery
const stats = await LocalVideoViewerModel.getUserAgentStats({
video,
startDate: query.startDate,
endDate: query.endDate
})
return res.json(stats)
}
async function getRetentionStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const stats = await LocalVideoViewerModel.getRetentionStats(video)
return res.json(stats)
}
async function getTimeseriesStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const metric = req.params.metric as VideoStatsTimeserieMetric
const query = req.query as VideoStatsTimeserieQuery
const stats = await LocalVideoViewerModel.getTimeserieStats({
video,
metric,
startDate: query.startDate ?? video.createdAt.toISOString(),
endDate: query.endDate ?? new Date().toISOString()
})
return res.json(stats)
}

View File

@@ -0,0 +1,29 @@
import express from 'express'
import { getVideoWithAttributes } from '@server/helpers/video.js'
import { StoryboardModel } from '@server/models/video/storyboard.js'
import { asyncMiddleware, videosGetValidator } from '../../../middlewares/index.js'
const storyboardRouter = express.Router()
storyboardRouter.get('/:id/storyboards',
asyncMiddleware(videosGetValidator),
asyncMiddleware(listStoryboards)
)
// ---------------------------------------------------------------------------
export {
storyboardRouter
}
// ---------------------------------------------------------------------------
async function listStoryboards (req: express.Request, res: express.Response) {
const video = getVideoWithAttributes(res)
const storyboards = await StoryboardModel.listStoryboardsOf(video)
return res.json({
storyboards: storyboards.map(s => s.toFormattedJSON())
})
}

View File

@@ -0,0 +1,156 @@
import Bluebird from 'bluebird'
import express from 'express'
import { move } from 'fs-extra/esm'
import { basename } from 'path'
import { createAnyReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES, VIDEO_FILTERS } from '@server/initializers/constants.js'
import { buildTaskFileFieldname, createVideoStudioJob, getStudioTaskFilePath, getTaskFileFromReq } from '@server/lib/video-studio.js'
import {
HttpStatusCode,
VideoChannelActivityAction,
VideoState,
VideoStudioCreateEdition,
VideoStudioTask,
VideoStudioTaskCut,
VideoStudioTaskIntro,
VideoStudioTaskOutro,
VideoStudioTaskPayload,
VideoStudioTaskWatermark
} from '@peertube/peertube-models'
import { asyncMiddleware, authenticate, videoStudioAddEditionValidator } from '../../../middlewares/index.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
const studioRouter = express.Router()
const tasksFiles = createAnyReqFiles(
MIMETYPES.VIDEO.MIMETYPE_EXT,
(req: express.Request, file: Express.Multer.File, cb: (err: Error, result?: boolean) => void) => {
const body = req.body as VideoStudioCreateEdition
// Fetch array element
const matches = file.fieldname.match(/tasks\[(\d+)\]/)
if (!matches) return cb(new Error('Cannot find array element indice for ' + file.fieldname))
const indice = parseInt(matches[1])
const task = body.tasks[indice]
if (!task) return cb(new Error('Cannot find array element of indice ' + indice + ' for ' + file.fieldname))
if (
[ 'add-intro', 'add-outro', 'add-watermark' ].includes(task.name) &&
file.fieldname === buildTaskFileFieldname(indice)
) {
return cb(null, true)
}
return cb(null, false)
}
)
studioRouter.post(
'/:videoId/studio/edit',
authenticate,
tasksFiles,
asyncMiddleware(videoStudioAddEditionValidator),
asyncMiddleware(createEditionTasks)
)
// ---------------------------------------------------------------------------
export {
studioRouter
}
// ---------------------------------------------------------------------------
async function createEditionTasks (req: express.Request, res: express.Response) {
const files = req.files as Express.Multer.File[]
const body = req.body as VideoStudioCreateEdition
const video = res.locals.videoAll
video.state = VideoState.TO_EDIT
await video.save()
const payload = {
videoUUID: video.uuid,
tasks: await Bluebird.mapSeries(body.tasks, (t, i) => buildTaskPayload(t, i, files))
}
const user = res.locals.oauth.token.User
await createVideoStudioJob({
user,
payload,
video
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.CREATE_STUDIO_TASKS,
user,
channel: video.VideoChannel,
video,
transaction: null
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
const taskPayloadBuilders: {
[id in VideoStudioTask['name']]: (
task: VideoStudioTask,
indice?: number,
files?: Express.Multer.File[]
) => Promise<VideoStudioTaskPayload>
} = {
'add-intro': buildIntroOutroTask,
'add-outro': buildIntroOutroTask,
'cut': buildCutTask,
'add-watermark': buildWatermarkTask
}
function buildTaskPayload (task: VideoStudioTask, indice: number, files: Express.Multer.File[]): Promise<VideoStudioTaskPayload> {
return taskPayloadBuilders[task.name](task, indice, files)
}
async function buildIntroOutroTask (task: VideoStudioTaskIntro | VideoStudioTaskOutro, indice: number, files: Express.Multer.File[]) {
const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path)
return {
name: task.name,
options: {
file: destination
}
}
}
function buildCutTask (task: VideoStudioTaskCut) {
return Promise.resolve({
name: task.name,
options: {
start: task.options.start,
end: task.options.end
}
})
}
async function buildWatermarkTask (task: VideoStudioTaskWatermark, indice: number, files: Express.Multer.File[]) {
const destination = await moveStudioFileToPersistentTMP(getTaskFileFromReq(files, indice).path)
return {
name: task.name,
options: {
file: destination,
watermarkSizeRatio: VIDEO_FILTERS.WATERMARK.SIZE_RATIO,
horitonzalMarginRatio: VIDEO_FILTERS.WATERMARK.HORIZONTAL_MARGIN_RATIO,
verticalMarginRatio: VIDEO_FILTERS.WATERMARK.VERTICAL_MARGIN_RATIO
}
}
}
async function moveStudioFileToPersistentTMP (file: string) {
const destination = getStudioTaskFilePath(basename(file))
await move(file, destination)
return destination
}

View File

@@ -0,0 +1,33 @@
import express from 'express'
import { VideoTokensManager } from '@server/lib/video-tokens-manager.js'
import { VideoPrivacy, VideoToken } from '@peertube/peertube-models'
import { asyncMiddleware, optionalAuthenticate, videoFileTokenValidator, videosCustomGetValidator } from '../../../middlewares/index.js'
const tokenRouter = express.Router()
tokenRouter.post('/:id/token',
optionalAuthenticate,
asyncMiddleware(videosCustomGetValidator('only-video-and-blacklist')),
videoFileTokenValidator,
generateToken
)
// ---------------------------------------------------------------------------
export {
tokenRouter
}
// ---------------------------------------------------------------------------
function generateToken (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const files = video.privacy === VideoPrivacy.PASSWORD_PROTECTED
? VideoTokensManager.Instance.createForPasswordProtectedVideo({ videoUUID: video.uuid })
: VideoTokensManager.Instance.createForAuthUser({ videoUUID: video.uuid, user: res.locals.oauth.token.User })
return res.json({
files
} as VideoToken)
}

View File

@@ -0,0 +1,61 @@
import { HttpStatusCode, UserRight, VideoState, VideoTranscodingCreate } from '@peertube/peertube-models'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { createTranscodingJobs } from '@server/lib/transcoding/create-transcoding-job.js'
import { computeResolutionsToTranscode } from '@server/lib/transcoding/transcoding-resolutions.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { asyncMiddleware, authenticate, createTranscodingValidator, ensureUserHasRight } from '../../../middlewares/index.js'
const lTags = loggerTagsFactory('api', 'video')
const transcodingRouter = express.Router()
transcodingRouter.post('/:videoId/transcoding',
authenticate,
ensureUserHasRight(UserRight.RUN_VIDEO_TRANSCODING),
asyncMiddleware(createTranscodingValidator),
asyncMiddleware(createTranscoding)
)
// ---------------------------------------------------------------------------
export {
transcodingRouter
}
// ---------------------------------------------------------------------------
async function createTranscoding (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
logger.info('Creating %s transcoding job for %s.', req.body.transcodingType, video.url, lTags())
const body: VideoTranscodingCreate = req.body
await VideoJobInfoModel.abortAllTasks(video.uuid, 'pendingTranscode')
const maxResolution = video.getMaxResolution()
const hasAudio = video.hasAudio()
const resolutions = await Hooks.wrapObject(
computeResolutionsToTranscode({ input: maxResolution, type: 'vod', includeInput: true, strictLower: false, hasAudio }),
'filter:transcoding.manual.resolutions-to-transcode.result',
body
)
if (resolutions.length === 0) {
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
video.state = VideoState.TO_TRANSCODE
await video.save()
await createTranscodingJobs({
video,
resolutions,
transcodingType: body.transcodingType,
isNewVideo: false,
user: null // Don't specify priority since these transcoding jobs are fired by the admin
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,326 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
NSFWFlag,
ThumbnailType,
VideoChannelActivityAction,
VideoPrivacy,
VideoPrivacyType,
VideoUpdate
} from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
import { sendDeleteVideo } from '@server/lib/activitypub/send/send-delete.js'
import { changeVideoChannelShare } from '@server/lib/activitypub/share.js'
import { isNewVideoPrivacyForFederation, isPrivacyForFederation } from '@server/lib/activitypub/videos/federate.js'
import { AutomaticTagger } from '@server/lib/automatic-tags/automatic-tagger.js'
import { setAndSaveVideoAutomaticTags } from '@server/lib/automatic-tags/automatic-tags.js'
import { updateLocalVideoMiniatureFromExisting } from '@server/lib/thumbnail.js'
import { replaceChaptersFromDescriptionIfNeeded } from '@server/lib/video-chapters.js'
import { addVideoJobsAfterUpdate } from '@server/lib/video-jobs.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import { setVideoPrivacy } from '@server/lib/video-privacy.js'
import { setVideoTags } from '@server/lib/video.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import { VideoChannelActivityModel } from '@server/models/video/video-channel-activity.js'
import { VideoPasswordModel } from '@server/models/video/video-password.js'
import { FilteredModelAttributes } from '@server/types/index.js'
import { MVideoFullLight, MVideoThumbnail } from '@server/types/models/index.js'
import express, { UploadFiles } from 'express'
import { Transaction } from 'sequelize'
import { VideoAuditView, auditLoggerFactory, getAuditIdFromRes } from '../../../helpers/audit-logger.js'
import { resetSequelizeInstance } from '../../../helpers/database-utils.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { MIMETYPES } from '../../../initializers/constants.js'
import { sequelizeTypescript } from '../../../initializers/database.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist.js'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares/index.js'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update.js'
import { VideoModel } from '../../../models/video/video.js'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
const updateRouter = express.Router()
const reqVideoFileUpdate = createReqFiles([ 'thumbnailfile', 'previewfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT)
updateRouter.put(
'/:id',
openapiOperationDoc({ operationId: 'putVideo' }),
authenticate,
reqVideoFileUpdate,
asyncMiddleware(videosUpdateValidator),
asyncRetryTransactionMiddleware(updateVideo)
)
// ---------------------------------------------------------------------------
export {
updateRouter
}
// ---------------------------------------------------------------------------
async function updateVideo (req: express.Request, res: express.Response) {
const videoFromReq = res.locals.videoAll
const oldVideoAuditView = new VideoAuditView(videoFromReq.toFormattedDetailsJSON())
const body: VideoUpdate = req.body
const user = res.locals.oauth.token.User
const hadPrivacyForFederation = isPrivacyForFederation(videoFromReq.privacy)
const oldPrivacy = videoFromReq.privacy
const thumbnails = await buildVideoThumbnailsFromReq(videoFromReq, req.files)
const videoFileLockReleaser = await VideoPathManager.Instance.lockFiles(videoFromReq.uuid)
try {
const { videoInstanceUpdated, isNewVideoForFederation } = await sequelizeTypescript.transaction(async t => {
// Refresh video since thumbnails to prevent concurrent updates
const video = await VideoModel.loadFull(videoFromReq.id, t)
const oldName = video.name
const oldDescription = video.description
const oldVideoChannel = video.VideoChannel
const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
'name',
'category',
'licence',
'language',
'nsfw',
'nsfwFlags',
'nsfwSummary',
'waitTranscoding',
'support',
'description',
'downloadEnabled'
]
for (const key of keysToUpdate) {
if (body[key] !== undefined) video.set(key, body[key])
}
if (!video.nsfw) {
video.nsfwFlags = NSFWFlag.NONE
video.nsfwSummary = null
}
if (body.commentsPolicy !== undefined) {
video.commentsPolicy = body.commentsPolicy
}
if (body.originallyPublishedAt !== undefined) {
video.originallyPublishedAt = body.originallyPublishedAt
? new Date(body.originallyPublishedAt)
: null
}
// Privacy update?
let isNewVideoForFederation = false
if (body.privacy !== undefined) {
isNewVideoForFederation = await updateVideoPrivacy({
videoInstance: video,
videoInfoToUpdate: body,
hadPrivacyForFederation,
transaction: t
})
}
// Force updatedAt attribute change
if (!video.changed()) {
await video.setAsRefreshed(t)
}
const videoInstanceUpdated = await video.save({ transaction: t }) as MVideoFullLight
// Thumbnail & preview updates?
for (const thumbnail of thumbnails) {
await videoInstanceUpdated.addAndSaveThumbnail(thumbnail, t)
}
// Video tags update?
if (body.tags !== undefined) {
await setVideoTags({ video: videoInstanceUpdated, tags: body.tags, transaction: t })
}
// Video channel update?
const newChannel = res.locals.videoChannel
if (newChannel && videoInstanceUpdated.channelId !== newChannel.id) {
const oldChannel = videoInstanceUpdated.VideoChannel
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.REMOVE_CHANNEL_OWNERSHIP,
user,
channel: oldChannel,
video: videoInstanceUpdated,
transaction: t
})
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.CREATE_CHANNEL_OWNERSHIP,
user,
channel: newChannel,
video: videoInstanceUpdated,
transaction: t
})
await videoInstanceUpdated.$set('VideoChannel', newChannel, { transaction: t })
videoInstanceUpdated.VideoChannel = newChannel
if (hadPrivacyForFederation === true) {
await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
}
} else {
await VideoChannelActivityModel.addVideoActivity({
action: VideoChannelActivityAction.UPDATE,
user: res.locals.oauth.token.User,
channel: videoInstanceUpdated.VideoChannel,
video: videoInstanceUpdated,
transaction: t
})
}
// Schedule an update in the future?
await updateSchedule(videoInstanceUpdated, body, t)
if (oldDescription !== video.description) {
await replaceChaptersFromDescriptionIfNeeded({
newDescription: videoInstanceUpdated.description,
transaction: t,
video,
oldDescription
})
}
if (oldName !== video.name || oldDescription !== video.description) {
const automaticTags = await new AutomaticTagger().buildVideoAutomaticTags({ video, transaction: t })
await setAndSaveVideoAutomaticTags({ video, automaticTags, transaction: t })
}
await autoBlacklistVideoIfNeeded({
video: videoInstanceUpdated,
user,
isRemote: false,
isNew: false,
isNewFile: false,
transaction: t
})
auditLogger.update(
getAuditIdFromRes(res),
new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
oldVideoAuditView
)
logger.info('Video with name %s and uuid %s updated.', video.name, video.uuid, lTags(video.uuid))
return { videoInstanceUpdated, isNewVideoForFederation }
})
Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body, req, res })
await addVideoJobsAfterUpdate({
video: videoInstanceUpdated,
nameChanged: !!body.name,
oldPrivacy,
isNewVideoForFederation
})
} catch (err) {
// If the transaction is retried, sequelize will think the object has not changed
// So we need to restore the previous fields
await resetSequelizeInstance(videoFromReq)
throw err
} finally {
videoFileLockReleaser()
}
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}
// Return a boolean indicating if the video is considered as "new" for remote instances in the federation
async function updateVideoPrivacy (options: {
videoInstance: MVideoFullLight
videoInfoToUpdate: VideoUpdate
hadPrivacyForFederation: boolean
transaction: Transaction
}) {
const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options
const isNewVideoForFederation = isNewVideoPrivacyForFederation(videoInstance.privacy, videoInfoToUpdate.privacy)
const newPrivacy = forceNumber(videoInfoToUpdate.privacy) as VideoPrivacyType
setVideoPrivacy(videoInstance, newPrivacy)
// Delete passwords if video is not anymore password protected
if (videoInstance.privacy === VideoPrivacy.PASSWORD_PROTECTED && newPrivacy !== VideoPrivacy.PASSWORD_PROTECTED) {
await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
}
if (newPrivacy === VideoPrivacy.PASSWORD_PROTECTED && exists(videoInfoToUpdate.videoPasswords)) {
await VideoPasswordModel.deleteAllPasswords(videoInstance.id, transaction)
await VideoPasswordModel.addPasswords(videoInfoToUpdate.videoPasswords, videoInstance.id, transaction)
}
// Unfederate the video if the new privacy is not compatible with federation
if (hadPrivacyForFederation && !isPrivacyForFederation(videoInstance.privacy)) {
await sendDeleteVideo({ video: videoInstance, deleteForPrivacyChange: true, transaction })
}
return isNewVideoForFederation
}
async function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: VideoUpdate, transaction: Transaction) {
if (videoInfoToUpdate.scheduleUpdate) {
const updateAt = new Date(videoInfoToUpdate.scheduleUpdate.updateAt)
videoInstance.publishedAt = updateAt
await videoInstance.save({ transaction })
await ScheduleVideoUpdateModel.upsert({
videoId: videoInstance.id,
updateAt,
privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
}, { transaction })
return
}
if (videoInfoToUpdate.scheduleUpdate === null) {
const deleted = await ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction)
if (deleted) {
videoInstance.publishedAt = new Date()
await videoInstance.save({ transaction })
}
}
}
async function buildVideoThumbnailsFromReq (video: MVideoThumbnail, files: UploadFiles) {
const promises = [
{
type: ThumbnailType.MINIATURE,
fieldName: 'thumbnailfile'
},
{
type: ThumbnailType.PREVIEW,
fieldName: 'previewfile'
}
].map(p => {
const fields = files?.[p.fieldName]
if (!fields) return undefined
return updateLocalVideoMiniatureFromExisting({
inputPath: fields[0].path,
video,
type: p.type,
automaticallyGenerated: false
})
})
const thumbnailsOrUndefined = await Promise.all(promises)
return thumbnailsOrUndefined.filter(t => !!t)
}

View File

@@ -0,0 +1,194 @@
import { ffprobePromise, getChaptersFromContainer } from '@peertube/peertube-ffmpeg'
import { isPeerTubeError, ThumbnailType, VideoCreate } from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { getResumableUploadPath } from '@server/helpers/upload.js'
import { LocalVideoCreator } from '@server/lib/local-video-creator.js'
import { Redis } from '@server/lib/redis.js'
import { setupUploadResumableRoutes, uploadx } from '@server/lib/uploadx.js'
import { buildNextVideoState } from '@server/lib/video-state.js'
import { openapiOperationDoc } from '@server/middlewares/doc.js'
import express from 'express'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger.js'
import { createReqFiles } from '../../../helpers/express-utils.js'
import { logger, loggerTagsFactory } from '../../../helpers/logger.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
videosAddLegacyValidator,
videosAddResumableInitValidator,
videosAddResumableValidator
} from '../../../middlewares/index.js'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
const uploadRouter = express.Router()
const reqVideoFileAdd = createReqFiles(
[ 'videofile', 'thumbnailfile', 'previewfile' ],
{ ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.IMAGE.MIMETYPE_EXT }
)
const reqVideoFileAddResumable = createReqFiles(
[ 'thumbnailfile', 'previewfile' ],
MIMETYPES.IMAGE.MIMETYPE_EXT,
getResumableUploadPath()
)
uploadRouter.post(
'/upload',
openapiOperationDoc({ operationId: 'uploadLegacy' }),
authenticate,
reqVideoFileAdd,
asyncMiddleware(videosAddLegacyValidator),
asyncRetryTransactionMiddleware(addVideoLegacy)
)
setupUploadResumableRoutes({
routePath: '/upload-resumable',
router: uploadRouter,
uploadInitBeforeMiddlewares: [
openapiOperationDoc({ operationId: 'uploadResumableInit' }),
reqVideoFileAddResumable
],
uploadInitAfterMiddlewares: [ asyncMiddleware(videosAddResumableInitValidator) ],
uploadDeleteMiddlewares: [ asyncMiddleware(deleteUploadResumableCache) ],
uploadedMiddlewares: [
openapiOperationDoc({ operationId: 'uploadResumable' }),
asyncMiddleware(videosAddResumableValidator)
],
uploadedController: asyncMiddleware(addVideoResumable)
})
// ---------------------------------------------------------------------------
export {
uploadRouter
}
// ---------------------------------------------------------------------------
async function addVideoLegacy (req: express.Request, res: express.Response) {
const videoPhysicalFile = req.files['videofile'][0]
const videoInfo: VideoCreate = req.body
const files = req.files
const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files })
return res.json(response)
}
async function addVideoResumable (req: express.Request, res: express.Response) {
const videoPhysicalFile = res.locals.uploadVideoFileResumable
const videoInfo = videoPhysicalFile.metadata
const files = { previewfile: videoInfo.previewfile, thumbnailfile: videoInfo.thumbnailfile }
try {
const response = await addVideo({ req, res, videoPhysicalFile, videoInfo, files })
return res.json(response)
} finally {
await Redis.Instance.deleteUploadSession(req.query.upload_id)
await uploadx.storage.delete(res.locals.uploadVideoFileResumable)
}
}
async function addVideo (options: {
req: express.Request
res: express.Response
videoPhysicalFile: express.VideoLegacyUploadFile
videoInfo: VideoCreate
files: express.UploadFiles
}) {
const { req, res, videoPhysicalFile, videoInfo, files } = options
const ffprobe = await ffprobePromise(videoPhysicalFile.path)
const containerChapters = await getChaptersFromContainer({
path: videoPhysicalFile.path,
maxTitleLength: CONSTRAINTS_FIELDS.VIDEO_CHAPTERS.TITLE.max,
ffprobe
})
logger.debug(`Got ${containerChapters.length} chapters from video "${videoInfo.name}" container`, { containerChapters, ...lTags() })
const thumbnails = [ { type: ThumbnailType.MINIATURE, field: 'thumbnailfile' }, { type: ThumbnailType.PREVIEW, field: 'previewfile' } ]
.filter(({ field }) => !!files?.[field]?.[0])
.map(({ type, field }) => ({
path: files[field][0].path,
type,
automaticallyGenerated: false,
keepOriginal: false
}))
const localVideoCreator = new LocalVideoCreator({
lTags,
videoFile: {
path: videoPhysicalFile.path,
probe: res.locals.ffprobe
},
user: res.locals.oauth.token.User,
channel: res.locals.videoChannel,
chapters: undefined,
fallbackChapters: {
fromDescription: true,
finalFallback: containerChapters
},
videoAttributes: {
...videoInfo,
duration: videoPhysicalFile.duration,
inputFilename: videoPhysicalFile.originalname,
state: buildNextVideoState(),
isLive: false
},
liveAttributes: undefined,
videoAttributeResultHook: 'filter:api.video.upload.video-attribute.result',
thumbnails
})
try {
const { video } = await localVideoCreator.create()
auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(video.toFormattedDetailsJSON()))
logger.info('Video with name %s and uuid %s created.', videoInfo.name, video.uuid, lTags(video.uuid))
Hooks.runAction('action:api.video.uploaded', { video, req, res })
return {
video: {
id: video.id,
shortUUID: uuidToShort(video.uuid),
uuid: video.uuid
}
}
} catch (err) {
if (isPeerTubeError(err) && err.code === 'INVALID_IMAGE_FILE') {
logger.warn('Invalid thumbnail file provided for video upload.', { err, ...lTags() })
return res.fail({
message: req.t('The provided thumbnail file is invalid.')
})
}
throw err
}
}
async function deleteUploadResumableCache (req: express.Request, res: express.Response, next: express.NextFunction) {
await Redis.Instance.deleteUploadSession(req.query.upload_id)
return next()
}

View File

@@ -0,0 +1,70 @@
import { HttpStatusCode, VideoView } from '@peertube/peertube-models'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { MVideoId } from '@server/types/models/index.js'
import express from 'express'
import {
asyncMiddleware,
methodsValidator,
openapiOperationDoc,
optionalAuthenticate,
videoViewValidator
} from '../../../middlewares/index.js'
import { UserVideoHistoryModel } from '../../../models/user/user-video-history.js'
const viewRouter = express.Router()
viewRouter.all(
[ '/:videoId/views', '/:videoId/watching' ],
openapiOperationDoc({ operationId: 'addView' }),
methodsValidator([ 'PUT', 'POST' ]),
optionalAuthenticate,
asyncMiddleware(videoViewValidator),
asyncMiddleware(viewVideo)
)
// ---------------------------------------------------------------------------
export {
viewRouter
}
// ---------------------------------------------------------------------------
async function viewVideo (req: express.Request, res: express.Response) {
const video = res.locals.onlyImmutableVideo
const body = req.body as VideoView
const ip = req.ip
const { successView } = await VideoViewsManager.Instance.processLocalView({
video,
ip,
currentTime: body.currentTime,
viewEvent: body.viewEvent,
sessionId: body.sessionId,
client: body.client,
operatingSystem: body.operatingSystem,
device: body.device
})
if (successView) {
Hooks.runAction('action:api.video.viewed', { video, ip, req, res })
}
await updateUserHistoryIfNeeded(body, video, res)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateUserHistoryIfNeeded (body: VideoView, video: MVideoId, res: express.Response) {
const user = res.locals.oauth?.token.User
if (!user) return
if (user.videosHistoryEnabled !== true) return
await UserVideoHistoryModel.upsert({
videoId: video.id,
userId: user.id,
currentTime: body.currentTime
})
}

View File

@@ -0,0 +1,162 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { Awaitable } from '@peertube/peertube-typescript-utils'
import {
addWatchedWordsListValidatorFactory,
getWatchedWordsListValidatorFactory,
manageAccountWatchedWordsListValidator,
updateWatchedWordsListValidatorFactory
} from '@server/middlewares/validators/watched-words.js'
import { getServerActor } from '@server/models/application/application.js'
import { WatchedWordsListModel } from '@server/models/watched-words/watched-words-list.js'
import { MAccountId } from '@server/types/models/index.js'
import express from 'express'
import { getFormattedObjects } from '../../helpers/utils.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate, ensureUserHasRight, paginationValidator,
setDefaultPagination,
setDefaultSort,
watchedWordsListsSortValidator
} from '../../middlewares/index.js'
const watchedWordsRouter = express.Router()
watchedWordsRouter.use(apiRateLimiter)
{
const common = [
authenticate,
paginationValidator,
watchedWordsListsSortValidator,
setDefaultSort,
setDefaultPagination
]
watchedWordsRouter.get('/accounts/:accountName/lists',
...common,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(listWatchedWordsListsFactory(res => res.locals.account))
)
watchedWordsRouter.get('/server/lists',
...common,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(listWatchedWordsListsFactory(() => getServerActor().then(a => a.Account)))
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.post('/accounts/:accountName/lists',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(addWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(addWatchedWordsListFactory(res => res.locals.account))
)
watchedWordsRouter.post('/server/lists',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(addWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(addWatchedWordsListFactory(() => getServerActor().then(a => a.Account)))
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.put('/accounts/:accountName/lists/:listId',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(updateWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(updateWatchedWordsList)
)
watchedWordsRouter.put('/server/lists/:listId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(updateWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(updateWatchedWordsList)
)
}
// ---------------------------------------------------------------------------
{
watchedWordsRouter.delete('/accounts/:accountName/lists/:listId',
authenticate,
asyncMiddleware(manageAccountWatchedWordsListValidator),
asyncMiddleware(getWatchedWordsListValidatorFactory(res => res.locals.account)),
asyncMiddleware(deleteWatchedWordsList)
)
watchedWordsRouter.delete('/server/lists/:listId',
authenticate,
ensureUserHasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS),
asyncMiddleware(getWatchedWordsListValidatorFactory(() => getServerActor().then(a => a.Account))),
asyncMiddleware(deleteWatchedWordsList)
)
}
// ---------------------------------------------------------------------------
export {
watchedWordsRouter
}
// ---------------------------------------------------------------------------
function listWatchedWordsListsFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return async (req: express.Request, res: express.Response) => {
const resultList = await WatchedWordsListModel.listForAPI({
accountId: (await accountGetter(res)).id,
start: req.query.start,
count: req.query.count,
sort: req.query.sort
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}
}
function addWatchedWordsListFactory (accountGetter: (res: express.Response) => Awaitable<MAccountId>) {
return async (req: express.Request, res: express.Response) => {
const list = await WatchedWordsListModel.createList({
accountId: (await accountGetter(res)).id,
listName: req.body.listName,
words: req.body.words
})
return res.json({
watchedWordsList: {
id: list.id
}
})
}
}
async function updateWatchedWordsList (req: express.Request, res: express.Response) {
const list = res.locals.watchedWordsList
await list.updateList({
listName: req.body.listName,
words: req.body.words
})
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteWatchedWordsList (req: express.Request, res: express.Response) {
const list = res.locals.watchedWordsList
await list.destroy()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}

View File

@@ -0,0 +1,257 @@
import { buildFileLocale, getCompleteLocale, is18nLocale, LOCALE_FILES } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { currentDir, root } from '@peertube/peertube-node-utils'
import { toCompleteUUID } from '@server/helpers/custom-validators/misc.js'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import { constants, promises as fs } from 'fs'
import { join } from 'path'
import { STATIC_MAX_AGE } from '../initializers/constants.js'
import { ClientHtml, sendHTML, serveIndexHTML } from '../lib/html/client-html.js'
import { asyncMiddleware, buildRateLimiter, embedCSP } from '../middlewares/index.js'
const clientsRouter = express.Router()
const clientsRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.CLIENT.WINDOW_MS,
max: CONFIG.RATES_LIMIT.CLIENT.MAX
})
const distPath = join(root(), 'client', 'dist')
// Special route that add OpenGraph and oEmbed tags
// Do not use a template engine for a so little thing
clientsRouter.use([ '/w/p/:id', '/videos/watch/playlist/:id' ], clientsRateLimiter, asyncMiddleware(generateWatchPlaylistHtmlPage))
clientsRouter.use([ '/w/:id', '/videos/watch/:id' ], clientsRateLimiter, asyncMiddleware(generateWatchHtmlPage))
clientsRouter.use([ '/accounts/:handle', '/a/:handle' ], clientsRateLimiter, asyncMiddleware(generateAccountHtmlPage))
clientsRouter.use([ '/video-channels/:handle', '/c/:handle' ], clientsRateLimiter, asyncMiddleware(generateVideoChannelHtmlPage))
clientsRouter.use('/@:handle', clientsRateLimiter, asyncMiddleware(generateActorHtmlPage))
// ---------------------------------------------------------------------------
const embedMiddlewares = [
clientsRateLimiter,
CONFIG.CSP.ENABLED
? embedCSP
: (req: express.Request, res: express.Response, next: express.NextFunction) => next(),
// Set headers
(req: express.Request, res: express.Response, next: express.NextFunction) => {
res.removeHeader('X-Frame-Options')
// Don't cache HTML file since it's an index to the immutable JS/CSS files
res.setHeader('Cache-Control', 'public, max-age=0')
next()
}
]
clientsRouter.use('/videos/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoEmbedHtmlPage))
clientsRouter.use('/video-playlists/embed/:id', ...embedMiddlewares, asyncMiddleware(generateVideoPlaylistEmbedHtmlPage))
// ---------------------------------------------------------------------------
const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
const testEmbedController = (req: express.Request, res: express.Response) => res.sendFile(testEmbedPath)
clientsRouter.use('/videos/test-embed', clientsRateLimiter, testEmbedController)
clientsRouter.use('/video-playlists/test-embed', clientsRateLimiter, testEmbedController)
// ---------------------------------------------------------------------------
// Dynamic PWA manifest
clientsRouter.get('/manifest.webmanifest', clientsRateLimiter, asyncMiddleware(generateManifest))
// Static client overrides
// Must be consistent with static client overrides redirections in /support/nginx/peertube
const staticClientOverrides = [
'assets/images/default-playlist.jpg',
'assets/images/default-avatar-account.png',
'assets/images/default-avatar-account-48x48.png',
'assets/images/default-avatar-video-channel.png',
'assets/images/default-avatar-video-channel-48x48.png'
]
for (const staticClientOverride of staticClientOverrides) {
const overridePhysicalPath = join(CONFIG.STORAGE.CLIENT_OVERRIDES_DIR, staticClientOverride)
clientsRouter.use(`/client/${staticClientOverride}`, asyncMiddleware(serveClientOverride(overridePhysicalPath)))
}
clientsRouter.use('/client/locales/:locale/:file.json', serveServerTranslations)
clientsRouter.use('/client', express.static(distPath, { maxAge: STATIC_MAX_AGE.CLIENT }))
// 404 for static files not found
clientsRouter.use('/client/*', (req: express.Request, res: express.Response) => {
res.status(HttpStatusCode.NOT_FOUND_404).end()
})
// Always serve index client page (the client is a single page application, let it handle routing)
// Try to provide the right language index.html
clientsRouter.use('/(:language)?', clientsRateLimiter, asyncMiddleware(serveIndexHTML))
// ---------------------------------------------------------------------------
export {
clientsRouter
}
// ---------------------------------------------------------------------------
function serveServerTranslations (req: express.Request, res: express.Response) {
const locale = req.params.locale
const file = req.params.file
if (is18nLocale(locale) && LOCALE_FILES.includes(file)) {
const completeLocale = getCompleteLocale(locale)
const completeFileLocale = buildFileLocale(completeLocale)
const path = join(currentDir(import.meta.url), `../../../client/dist/locale/${file}.${completeFileLocale}.json`)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
}
return res.status(HttpStatusCode.NOT_FOUND_404).end()
}
async function generateVideoEmbedHtmlPage (req: express.Request, res: express.Response) {
req.params.id = toCompleteUUID(req.params.id)
const allowParameters = { req }
const allowedResult = await Hooks.wrapFun(
isEmbedAllowed,
allowParameters,
'filter:html.embed.video.allowed.result'
)
if (allowedResult?.allowed !== true) {
logger.info('Embed is not allowed.', { allowedResult })
return sendHTML(allowedResult?.html || '', res)
}
const html = await ClientHtml.getVideoEmbedHTML(req.params.id)
return sendHTML(html, res)
}
async function generateVideoPlaylistEmbedHtmlPage (req: express.Request, res: express.Response) {
req.params.id = toCompleteUUID(req.params.id)
const allowParameters = { req }
const allowedResult = await Hooks.wrapFun(
isEmbedAllowed,
allowParameters,
'filter:html.embed.video-playlist.allowed.result'
)
if (allowedResult?.allowed !== true) {
logger.info('Embed is not allowed.', { allowedResult })
return sendHTML(allowedResult?.html || '', res)
}
const html = await ClientHtml.getVideoPlaylistEmbedHTML(req.params.id)
return sendHTML(html, res)
}
async function generateWatchHtmlPage (req: express.Request, res: express.Response) {
// Thread link is '/w/:videoId;threadId=:threadId'
// So to get the videoId we need to remove the last part
let videoId = req.params.id + ''
const threadIdIndex = videoId.indexOf(';threadId')
if (threadIdIndex !== -1) videoId = videoId.substring(0, threadIdIndex)
videoId = toCompleteUUID(videoId)
const html = await ClientHtml.getWatchHTMLPage(videoId, req, res)
return sendHTML(html, res, true)
}
async function generateWatchPlaylistHtmlPage (req: express.Request, res: express.Response) {
req.params.id = toCompleteUUID(req.params.id)
const html = await ClientHtml.getWatchPlaylistHTMLPage(req.params.id + '', req, res)
return sendHTML(html, res, true)
}
async function generateAccountHtmlPage (req: express.Request, res: express.Response) {
const html = await ClientHtml.getAccountHTMLPage(req.params.handle, req, res)
return sendHTML(html, res, true)
}
async function generateVideoChannelHtmlPage (req: express.Request, res: express.Response) {
const html = await ClientHtml.getVideoChannelHTMLPage(req.params.handle, req, res)
return sendHTML(html, res, true)
}
async function generateActorHtmlPage (req: express.Request, res: express.Response) {
const html = await ClientHtml.getActorHTMLPage(req.params.handle, req, res)
return sendHTML(html, res, true)
}
async function generateManifest (req: express.Request, res: express.Response) {
const serverActor = await getServerActor()
const defaultIcons = [ 192, 512 ].map(size => {
return {
src: `/client/assets/images/icons/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
}
})
const icons = Array.isArray(serverActor.Avatars) && serverActor.Avatars.length > 0
? serverActor.Avatars.map(avatar => ({
src: avatar.getStaticPath(),
sizes: `${avatar.width}x${avatar.height}`,
type: avatar.getMimeType()
}))
: defaultIcons
return res.json({
name: CONFIG.INSTANCE.NAME,
short_name: CONFIG.INSTANCE.NAME,
start_url: '/',
background_color: '#fff',
theme_color: '#fff',
description: CONFIG.INSTANCE.SHORT_DESCRIPTION,
display: 'standalone',
icons
})
}
function serveClientOverride (path: string) {
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
await fs.access(path, constants.F_OK)
// Serve override client
res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
} catch {
// Serve dist client
next()
}
}
}
type AllowedResult = { allowed: boolean, html?: string }
function isEmbedAllowed (_object: {
req: express.Request
}): AllowedResult {
return { allowed: true }
}

View File

@@ -0,0 +1,435 @@
import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
import { FileStorage, HttpStatusCode, VideoResolution, VideoStreamingPlaylistType } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoTorrentsSimpleFileCache } from '@server/lib/files-cache/index.js'
import {
generateHLSFilePresignedUrl,
generateOriginalFilePresignedUrl,
generateUserExportPresignedUrl,
generateWebVideoPresignedUrl
} from '@server/lib/object-storage/index.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoDownload } from '@server/lib/video-download.js'
import { VideoPathManager } from '@server/lib/video-path-manager.js'
import {
MStreamingPlaylist,
MStreamingPlaylistVideo,
MUserExport,
MVideo,
MVideoFile,
MVideoFullLight
} from '@server/types/models/index.js'
import { MVideoSource } from '@server/types/models/video/video-source.js'
import contentDisposition from 'content-disposition'
import cors from 'cors'
import express from 'express'
import { DOWNLOAD_PATHS, WEBSERVER } from '../initializers/constants.js'
import {
asyncMiddleware,
buildRateLimiter,
optionalAuthenticate,
originalVideoFileDownloadValidator,
userExportDownloadValidator,
videosDownloadValidator,
videosGenerateDownloadValidator
} from '../middlewares/index.js'
const lTags = loggerTagsFactory('download')
const downloadRouter = express.Router()
downloadRouter.use(cors())
downloadRouter.use(
DOWNLOAD_PATHS.TORRENTS + ':filename',
asyncMiddleware(downloadTorrent)
)
// ---------------------------------------------------------------------------
downloadRouter.use(
DOWNLOAD_PATHS.WEB_VIDEOS + ':id-:resolution([0-9]+).:extension',
optionalAuthenticate,
asyncMiddleware(videosDownloadValidator),
asyncMiddleware(downloadWebVideoFile)
)
downloadRouter.use(
DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
optionalAuthenticate,
asyncMiddleware(videosDownloadValidator),
asyncMiddleware(downloadHLSVideoFile)
)
const downloadGenerateRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.WINDOW_MS,
max: CONFIG.RATES_LIMIT.DOWNLOAD_GENERATE_VIDEO.MAX,
skipFailedRequests: true
})
downloadRouter.use(
[ DOWNLOAD_PATHS.GENERATE_VIDEO + ':id.m4a', DOWNLOAD_PATHS.GENERATE_VIDEO + ':id.mp4', DOWNLOAD_PATHS.GENERATE_VIDEO + ':id' ],
downloadGenerateRateLimiter,
optionalAuthenticate,
asyncMiddleware(videosDownloadValidator),
videosGenerateDownloadValidator,
asyncMiddleware(downloadGeneratedVideoFile)
)
// ---------------------------------------------------------------------------
downloadRouter.use(
DOWNLOAD_PATHS.USER_EXPORTS + ':filename',
asyncMiddleware(userExportDownloadValidator), // Include JWT token authentication
asyncMiddleware(downloadUserExport)
)
downloadRouter.use(
DOWNLOAD_PATHS.ORIGINAL_VIDEO_FILE + ':filename',
optionalAuthenticate,
asyncMiddleware(originalVideoFileDownloadValidator),
asyncMiddleware(downloadOriginalFile)
)
// ---------------------------------------------------------------------------
export {
downloadRouter
}
// ---------------------------------------------------------------------------
async function downloadTorrent (req: express.Request, res: express.Response) {
const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Torrent file not found'
})
}
const allowParameters = {
req,
res,
torrentPath: result.path,
downloadName: result.downloadName
}
const allowedResult = await Hooks.wrapFun(
isTorrentDownloadAllowed,
allowParameters,
'filter:api.download.torrent.allowed.result'
)
if (!checkAllowResult(res, allowParameters, allowedResult)) return
return res.download(result.path, result.downloadName)
}
// ---------------------------------------------------------------------------
async function downloadWebVideoFile (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const videoFile = getVideoFileFromReq(req, video.VideoFiles)
if (!videoFile) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video file not found'
})
}
const allowParameters = {
req,
res,
video,
videoFile
}
const allowedResult = await Hooks.wrapFun(
isVideoDownloadAllowed,
allowParameters,
'filter:api.download.video.allowed.result'
)
if (!checkAllowResult(res, allowParameters, allowedResult)) return
const downloadFilename = buildDownloadFilename({ video, resolution: videoFile.resolution, extname: videoFile.extname })
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
return redirectVideoDownloadToObjectStorage({ res, video, file: videoFile, downloadFilename })
}
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(video), path => {
return res.download(path, downloadFilename)
})
}
async function downloadHLSVideoFile (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const streamingPlaylist = getHLSPlaylist(video)
if (!streamingPlaylist) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const videoFile = getVideoFileFromReq(req, streamingPlaylist.VideoFiles)
if (!videoFile) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video file not found'
})
}
const allowParameters = {
req,
res,
video,
streamingPlaylist,
videoFile
}
const allowedResult = await Hooks.wrapFun(
isVideoDownloadAllowed,
allowParameters,
'filter:api.download.video.allowed.result'
)
if (!checkAllowResult(res, allowParameters, allowedResult)) return
const downloadFilename = buildDownloadFilename({ video, streamingPlaylist, resolution: videoFile.resolution, extname: videoFile.extname })
if (videoFile.storage === FileStorage.OBJECT_STORAGE) {
return redirectVideoDownloadToObjectStorage({ res, video, streamingPlaylist, file: videoFile, downloadFilename })
}
await VideoPathManager.Instance.makeAvailableVideoFile(videoFile.withVideoOrPlaylist(streamingPlaylist), path => {
return res.download(path, downloadFilename)
})
}
// ---------------------------------------------------------------------------
async function downloadGeneratedVideoFile (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const filesToSelect = req.query.videoFileIds
const videoFiles = video.getAllFiles()
.filter(f => filesToSelect.includes(f.id))
if (videoFiles.length === 0) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `No files found (${filesToSelect.join(', ')}) to download video ${video.url}`
})
}
if (videoFiles.filter(f => f.hasVideo()).length > 1 || videoFiles.filter(f => f.hasAudio()).length > 1) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
// In theory we could, but ffmpeg-fluent doesn't support multiple input streams so prefer to reject this specific use case
message: `Cannot generate a container with multiple video/audio files. PeerTube supports a maximum of 1 audio and 1 video file`
})
}
const allowParameters = {
req,
res,
video,
videoFiles
}
const allowedResult = await Hooks.wrapFun(
isGeneratedVideoDownloadAllowed,
allowParameters,
'filter:api.download.generated-video.allowed.result'
)
if (!checkAllowResult(res, allowParameters, allowedResult)) return
if (VideoDownload.totalDownloads > CONFIG.DOWNLOAD_GENERATE_VIDEO.MAX_PARALLEL_DOWNLOADS) {
return res.fail({
status: HttpStatusCode.TOO_MANY_REQUESTS_429,
message: req.t(`Too many parallel downloads on this server. Please try again later.`)
})
}
const maxResolutionFile = maxBy(videoFiles, 'resolution')
// Prefer m4a extension for the user if this is a mp4 audio file only
const extname = maxResolutionFile.resolution === VideoResolution.H_NOVIDEO && maxResolutionFile.extname === '.mp4'
? '.m4a'
: maxResolutionFile.extname
// If there is the extension, we want to simulate a "raw file" and so not send the content disposition header
const urlPath = new URL(req.originalUrl, WEBSERVER.URL).pathname
if (!urlPath.endsWith('.mp4') && !urlPath.endsWith('.m4a')) {
const downloadFilename = buildDownloadFilename({ video, extname })
res.setHeader('Content-disposition', contentDisposition(encodeURI(downloadFilename)))
}
res.type(extname)
try {
await new VideoDownload({ video, videoFiles }).muxToMergeVideoFiles(res)
} catch (err) {
// muxToMergeVideoFiles has already logged the error
res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: req.t('Cannot process video download at the moment. Please try again later.'),
data: err.message
})
}
}
// ---------------------------------------------------------------------------
function downloadUserExport (req: express.Request, res: express.Response) {
const userExport = res.locals.userExport
const downloadFilename = userExport.filename
if (userExport.storage === FileStorage.OBJECT_STORAGE) {
return redirectUserExportToObjectStorage({ res, userExport, downloadFilename })
}
res.download(getFSUserExportFilePath(userExport), downloadFilename)
return Promise.resolve()
}
function downloadOriginalFile (req: express.Request, res: express.Response) {
const videoSource = res.locals.videoSource
const downloadFilename = videoSource.inputFilename
if (videoSource.storage === FileStorage.OBJECT_STORAGE) {
return redirectOriginalFileToObjectStorage({ res, videoSource, downloadFilename })
}
res.download(VideoPathManager.Instance.getFSOriginalVideoFilePath(videoSource.keptOriginalFilename), downloadFilename)
return Promise.resolve()
}
// ---------------------------------------------------------------------------
function getVideoFileFromReq (req: express.Request, files: MVideoFile[]) {
const resolution = forceNumber(req.params.resolution)
return files.find(f => f.resolution === resolution)
}
function getHLSPlaylist (video: MVideoFullLight) {
const playlist = video.VideoStreamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
if (!playlist) return undefined
return Object.assign(playlist, { Video: video })
}
type AllowedResult = {
allowed: boolean
errorMessage?: string
}
function isTorrentDownloadAllowed (_object: {
torrentPath: string
}): AllowedResult {
return { allowed: true }
}
function isVideoDownloadAllowed (_object: {
video: MVideo
videoFile: MVideoFile
streamingPlaylist?: MStreamingPlaylist
}): AllowedResult {
return { allowed: true }
}
function isGeneratedVideoDownloadAllowed (_object: {
video: MVideo
videoFiles: MVideoFile[]
}): AllowedResult {
return { allowed: true }
}
// ---------------------------------------------------------------------------
function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
if (result?.allowed !== true) {
logger.info('Download is not allowed.', { result, allowParameters, ...lTags() })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: result?.errorMessage || 'Refused download'
})
return false
}
return true
}
async function redirectVideoDownloadToObjectStorage (options: {
res: express.Response
video: MVideo
file: MVideoFile
streamingPlaylist?: MStreamingPlaylistVideo
downloadFilename: string
}) {
const { res, video, streamingPlaylist, file, downloadFilename } = options
const url = streamingPlaylist
? await generateHLSFilePresignedUrl({ streamingPlaylist, file, downloadFilename })
: await generateWebVideoPresignedUrl({ file, downloadFilename })
logger.debug('Generating pre-signed URL %s for video %s', url, video.uuid, lTags())
return res.redirect(url)
}
async function redirectUserExportToObjectStorage (options: {
res: express.Response
downloadFilename: string
userExport: MUserExport
}) {
const { res, downloadFilename, userExport } = options
const url = await generateUserExportPresignedUrl({ userExport, downloadFilename })
logger.debug('Generating pre-signed URL %s for user export %s', url, userExport.filename, lTags())
return res.redirect(url)
}
async function redirectOriginalFileToObjectStorage (options: {
res: express.Response
downloadFilename: string
videoSource: MVideoSource
}) {
const { res, downloadFilename, videoSource } = options
const url = await generateOriginalFilePresignedUrl({ videoSource, downloadFilename })
logger.debug('Generating pre-signed URL %s for original video file %s', url, videoSource.keptOriginalFilename, lTags())
return res.redirect(url)
}
function buildDownloadFilename (options: {
video: MVideo
streamingPlaylist?: MStreamingPlaylist
resolution?: number
extname: string
}) {
const { video, resolution, extname, streamingPlaylist } = options
// Express uses basename on filename parameter
const videoName = video.name.replace(/[/\\]/g, '_')
const suffixStr = streamingPlaylist
? `-${streamingPlaylist.getStringType()}`
: ''
const resolutionStr = exists(resolution)
? `-${resolution}p`
: ''
return videoName + resolutionStr + suffixStr + extname
}

View File

@@ -0,0 +1,97 @@
import { toSafeHtml } from '@server/helpers/markdown.js'
import { cacheRouteFactory } from '@server/middlewares/index.js'
import express from 'express'
import { CONFIG } from '../../initializers/config.js'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import {
asyncMiddleware,
feedsAccountOrChannelFiltersValidator,
feedsFormatValidator,
setFeedFormatContentType,
videoCommentsFeedsValidator
} from '../../middlewares/index.js'
import { VideoCommentModel } from '../../models/video/video-comment.js'
import { buildFeedMetadata, initFeed, sendFeed } from './shared/index.js'
const commentFeedsRouter = express.Router()
// ---------------------------------------------------------------------------
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
// ---------------------------------------------------------------------------
commentFeedsRouter.get(
'/video-comments.:format',
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(feedsAccountOrChannelFiltersValidator),
asyncMiddleware(videoCommentsFeedsValidator),
asyncMiddleware(generateVideoCommentsFeed)
)
// ---------------------------------------------------------------------------
export {
commentFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoCommentsFeed (req: express.Request, res: express.Response) {
const start = 0
const video = res.locals.videoAll
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const comments = await VideoCommentModel.listForFeed({
start,
count: CONFIG.FEEDS.COMMENTS.COUNT,
videoId: video?.id,
videoAccountOwnerId: account?.id,
videoChannelOwnerId: videoChannel?.id
})
const { name, description, imageUrl, link } = await buildFeedMetadata({ video, account, videoChannel })
const feed = await initFeed({
name,
description,
imageUrl,
isPodcast: false,
link,
resourceType: 'video-comments',
queryString: new URL(WEBSERVER.URL + req.originalUrl).search
})
// Adding video items to the feed, one at a time
for (const comment of comments) {
const localLink = WEBSERVER.URL + comment.getCommentStaticPath()
let title = comment.Video.name
const author: { name: string, link: string }[] = []
if (comment.Account) {
title += ` - ${comment.Account.getDisplayName()}`
author.push({
name: comment.Account.getDisplayName(),
link: comment.Account.Actor.url
})
}
feed.addItem({
title,
id: localLink,
link: localLink,
content: toSafeHtml(comment.text),
author,
date: comment.createdAt
})
}
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}

View File

@@ -0,0 +1,25 @@
import express from 'express'
import { CONFIG } from '@server/initializers/config.js'
import { buildRateLimiter } from '@server/middlewares/index.js'
import { commentFeedsRouter } from './comment-feeds.js'
import { videoFeedsRouter } from './video-feeds.js'
import { videoPodcastFeedsRouter } from './video-podcast-feeds.js'
const feedsRouter = express.Router()
const feedsRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.FEEDS.WINDOW_MS,
max: CONFIG.RATES_LIMIT.FEEDS.MAX
})
feedsRouter.use('/feeds', feedsRateLimiter)
feedsRouter.use('/feeds', commentFeedsRouter)
feedsRouter.use('/feeds', videoFeedsRouter)
feedsRouter.use('/feeds', videoPodcastFeedsRouter)
// ---------------------------------------------------------------------------
export {
feedsRouter
}

View File

@@ -0,0 +1,170 @@
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, Person } from '@peertube/feed/lib/typings/index.js'
import { pick } from '@peertube/peertube-core-utils'
import { ActorImageType } from '@peertube/peertube-models'
import { mdToPlainText } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { getServerActor } from '@server/models/application/application.js'
import { UserModel } from '@server/models/user/user.js'
import { MAccountDefault, MChannelBannerAccountDefault, MUser, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
export async function initFeed (parameters: {
name: string
description: string
imageUrl: string
isPodcast: boolean
nsfw?: boolean
guid?: string
link?: string
locked?: { isLocked: boolean, email: string }
author?: {
name: string
link: string
}
category?: string
language?: string
person?: Person[]
resourceType?: 'videos' | 'video-comments'
queryString?: string
medium?: string
stunServers?: string[]
trackers?: string[]
customXMLNS?: CustomXMLNS[]
customTags?: CustomTag[]
}) {
const webserverUrl = WEBSERVER.URL
const { name, description, link, imageUrl, category, isPodcast, resourceType, queryString, medium, nsfw } = parameters
const feed = new Feed({
title: name,
description: mdToPlainText(description),
// updated: TODO: somehowGetLatestUpdate, // optional, default = today
id: link || webserverUrl,
link: link || webserverUrl,
image: imageUrl,
favicon: ServerConfigManager.Instance.getFavicon(await getServerActor()).fileUrl,
copyright: `All rights reserved, unless otherwise specified in the terms specified at ${webserverUrl}/about` +
` and potential licenses granted by each content's rightholder.`,
generator: `PeerTube - ${webserverUrl}`,
medium: medium || 'video',
nsfw: nsfw ?? false,
feedLinks: {
json: `${webserverUrl}/feeds/${resourceType}.json${queryString}`,
atom: `${webserverUrl}/feeds/${resourceType}.atom${queryString}`,
rss: isPodcast
? `${webserverUrl}/feeds/podcast/videos.xml${queryString}`
: `${webserverUrl}/feeds/${resourceType}.xml${queryString}`
},
...pick(parameters, [
'guid',
'language',
'stunServers',
'trackers',
'customXMLNS',
'customTags',
'author',
'person',
'locked'
])
})
if (category) {
feed.addCategory(category)
}
return feed
}
export function sendFeed (feed: Feed, req: express.Request, res: express.Response) {
const format = req.params.format
if (format === 'atom' || format === 'atom1') {
return res.send(feed.atom1()).end()
}
if (format === 'json' || format === 'json1') {
return res.send(feed.json1()).end()
}
if (format === 'rss' || format === 'rss2') {
return res.send(feed.rss2()).end()
}
// We're in the ambiguous '.xml' case and we look at the format query parameter
if (req.query.format === 'atom' || req.query.format === 'atom1') {
return res.send(feed.atom1()).end()
}
return res.send(feed.rss2()).end()
}
export async function buildFeedMetadata (options: {
videoChannel?: MChannelBannerAccountDefault
account?: MAccountDefault
video?: MVideoFullLight
}) {
const { video, videoChannel, account } = options
let imageUrl = ServerConfigManager.Instance.getLogoUrl(await getServerActor(), 512)
let ownerImageUrl: string
let name: string
let description: string
let email: string
let link: string
let ownerLink: string
let user: MUser
if (videoChannel) {
name = videoChannel.getDisplayName()
description = videoChannel.description
ownerLink = videoChannel.getClientUrl()
link = ownerLink
if (videoChannel.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + videoChannel.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
ownerImageUrl = imageUrl
}
user = await UserModel.loadById(videoChannel.Account.userId)
} else if (account) {
name = account.getDisplayName()
description = account.description
ownerLink = account.getClientUrl()
link = ownerLink
if (account.Actor.hasImage(ActorImageType.AVATAR)) {
imageUrl = WEBSERVER.URL + account.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
ownerImageUrl = imageUrl
}
user = await UserModel.loadById(account.userId)
} else if (video) {
name = video.name
description = video.description
link = video.url
} else {
name = CONFIG.INSTANCE.NAME
description = CONFIG.INSTANCE.DESCRIPTION
link = WEBSERVER.URL
}
// If the user is local, has a verified email address, and allows it to be publicly displayed
// Return it so the owner can prove ownership of their feed
if (user && !user.pluginAuth && user.emailVerified && user.emailPublic) {
email = user.email
}
return { name, description, imageUrl, ownerImageUrl, email, link, ownerLink }
}

View File

@@ -0,0 +1,2 @@
export * from './video-feed-utils.js'
export * from './common-feed-utils.js'

View File

@@ -0,0 +1,83 @@
import { getChannelPodcastFeed } from '@peertube/peertube-core-utils'
import { VideoIncludeType } from '@peertube/peertube-models'
import { mdToPlainText, toSafeHtml } from '@server/helpers/markdown.js'
import { CONFIG } from '@server/initializers/config.js'
import { WEBSERVER } from '@server/initializers/constants.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getServerActor } from '@server/models/application/application.js'
import { getCategoryLabel } from '@server/models/video/formatter/index.js'
import { DisplayOnlyForFollowerOptions } from '@server/models/video/sql/video/index.js'
import { VideoModel } from '@server/models/video/video.js'
import { MChannelHostOnly, MThumbnail, MUserDefault } from '@server/types/models/index.js'
export async function getVideosForFeeds (options: {
sort: string
nsfw: boolean
isLocal: boolean
include: VideoIncludeType
accountId?: number
videoChannelId?: number
displayOnlyForFollower?: DisplayOnlyForFollowerOptions
user?: MUserDefault
}) {
const server = await getServerActor()
const { data } = await Hooks.wrapPromiseFun(
VideoModel.listForApi.bind(VideoModel),
{
start: 0,
count: CONFIG.FEEDS.VIDEOS.COUNT,
displayOnlyForFollower: {
actorId: server.id,
orLocalVideos: true
},
hasFiles: true,
countVideos: false,
...options
},
'filter:feed.videos.list.result'
)
return data
}
export function getCommonVideoFeedAttributes (video: VideoModel) {
const localLink = WEBSERVER.URL + video.getWatchStaticPath()
const thumbnailModels: MThumbnail[] = []
if (video.hasPreview()) thumbnailModels.push(video.getPreview())
if (video.hasMiniature()) thumbnailModels.push(video.getMiniature())
return {
title: video.name,
link: localLink,
description: mdToPlainText(video.getTruncatedDescription()),
content: toSafeHtml(video.description),
date: video.publishedAt,
nsfw: video.nsfw,
category: video.category
? [ { name: getCategoryLabel(video.category) } ]
: undefined,
thumbnails: thumbnailModels.map(t => ({
url: WEBSERVER.URL + t.getLocalStaticPath(),
width: t.width,
height: t.height
}))
}
}
export function getPodcastFeedUrlCustomTag (videoChannel: MChannelHostOnly) {
return {
name: 'podcast:txt',
attributes: {
purpose: 'p20url'
},
// TODO: use remote channel podcast feed URL
value: getChannelPodcastFeed(WEBSERVER.URL, videoChannel)
}
}

View File

@@ -0,0 +1,215 @@
import { Feed } from '@peertube/feed'
import { buildDownloadFilesUrl } from '@peertube/peertube-core-utils'
import { VideoInclude, VideoResolution } from '@peertube/peertube-models'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { cacheRouteFactory } from '@server/middlewares/index.js'
import { VideoModel } from '@server/models/video/video.js'
import express from 'express'
import { extname } from 'path'
import { buildNSFWFilters } from '../../helpers/express-utils.js'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../../initializers/constants.js'
import {
asyncMiddleware,
commonVideosFiltersValidatorFactory,
feedsAccountOrChannelFiltersValidator,
feedsFormatValidator,
setDefaultVideosSort,
setFeedFormatContentType,
videosSortValidator,
videoSubscriptionFeedsValidator
} from '../../middlewares/index.js'
import {
buildFeedMetadata,
getCommonVideoFeedAttributes,
getPodcastFeedUrlCustomTag,
getVideosForFeeds,
initFeed,
sendFeed
} from './shared/index.js'
const videoFeedsRouter = express.Router()
const { middleware: cacheRouteMiddleware } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
// ---------------------------------------------------------------------------
videoFeedsRouter.get(
'/videos.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidatorFactory(),
asyncMiddleware(feedsAccountOrChannelFiltersValidator),
asyncMiddleware(generateVideoFeed)
)
videoFeedsRouter.get(
'/subscriptions.:format',
videosSortValidator,
setDefaultVideosSort,
feedsFormatValidator,
setFeedFormatContentType,
cacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
commonVideosFiltersValidatorFactory(),
asyncMiddleware(videoSubscriptionFeedsValidator),
asyncMiddleware(generateVideoFeedForSubscriptions)
)
// ---------------------------------------------------------------------------
export {
videoFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoFeed (req: express.Request, res: express.Response) {
const account = res.locals.account
const videoChannel = res.locals.videoChannel
const { name, description, imageUrl, ownerImageUrl, link, ownerLink } = await buildFeedMetadata({ videoChannel, account })
const feed = await initFeed({
name,
description,
link,
isPodcast: false,
imageUrl: ownerImageUrl || imageUrl,
author: { name, link: ownerLink },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
customXMLNS: [
{
name: 'podcast',
value: 'https://podcastindex.org/namespace/1.0'
}
],
customTags: videoChannel
? [ getPodcastFeedUrlCustomTag(videoChannel) ]
: []
})
const data = await getVideosForFeeds({
...buildNSFWFilters({ req, res }),
sort: req.query.sort,
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
accountId: account?.id,
videoChannelId: videoChannel?.id
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
async function generateVideoFeedForSubscriptions (req: express.Request, res: express.Response) {
const account = res.locals.account
const { name, description, imageUrl, link } = await buildFeedMetadata({ account })
const feed = await initFeed({
name,
description,
link,
isPodcast: false,
imageUrl,
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search
})
const data = await getVideosForFeeds({
...buildNSFWFilters({ req, res }),
sort: req.query.sort,
isLocal: req.query.isLocal,
include: req.query.include | VideoInclude.FILES,
displayOnlyForFollower: {
actorId: res.locals.user.Account.Actor.id,
orLocalVideos: false
},
user: res.locals.user
})
addVideosToFeed(feed, data)
// Now the feed generation is done, let's send it!
return sendFeed(feed, req, res)
}
// ---------------------------------------------------------------------------
function addVideosToFeed (feed: Feed, videos: VideoModel[]) {
/**
* Adding video items to the feed object, one at a time
*/
for (const video of videos) {
const formattedVideoFiles = video.getFormattedAllVideoFilesJSON(false)
const torrents = formattedVideoFiles.map(videoFile => ({
title: video.name,
url: videoFile.torrentUrl,
size_in_bytes: videoFile.size
}))
const videoFiles = formattedVideoFiles.map(videoFile => {
return {
type: getVideoFileMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
medium: 'video',
height: videoFile.resolution.id,
fileSize: videoFile.size,
url: videoFile.fileUrl,
framerate: videoFile.fps,
duration: video.duration,
lang: video.language
}
})
const { videoFile: bestFile, separatedAudioFile: bestAudioFile } = video.getMaxQualityAudioAndVideoFiles()
const bestFiles = [ bestFile, bestAudioFile ].filter(f => !!f)
feed.addItem({
...getCommonVideoFeedAttributes(video),
id: WEBSERVER.URL + video.getWatchStaticPath(),
author: [
{
name: video.VideoChannel.getDisplayName(),
link: video.VideoChannel.getClientUrl()
}
],
torrents,
// Enclosure
video: bestFiles.length !== 0
? {
url: buildDownloadFilesUrl({ baseUrl: WEBSERVER.URL, videoFiles: bestFiles.map(f => f.id), videoUUID: video.uuid }),
length: bestFiles.reduce((p, f) => p + f.size, 0),
type: getVideoFileMimeType('.mp4', bestFile.resolution === VideoResolution.H_NOVIDEO)
}
: undefined,
// Media RSS
videos: videoFiles,
embed: {
url: WEBSERVER.URL + video.getEmbedStaticPath(),
allowFullscreen: true
},
player: {
url: WEBSERVER.URL + video.getWatchStaticPath()
},
community: {
statistics: {
views: video.views
}
}
})
}
}

View File

@@ -0,0 +1,384 @@
import { Feed } from '@peertube/feed'
import { CustomTag, CustomXMLNS, LiveItemStatus } from '@peertube/feed/lib/typings/index.js'
import { buildDownloadFilesUrl, getResolutionLabel, sortObjectComparator } from '@peertube/peertube-core-utils'
import { ActorImageType, VideoFile, VideoInclude, VideoResolution, VideoState } from '@peertube/peertube-models'
import { buildUUIDv5FromURL } from '@peertube/peertube-node-utils'
import { buildNSFWFilters } from '@server/helpers/express-utils.js'
import { CONFIG } from '@server/initializers/config.js'
import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { getVideoFileMimeType } from '@server/lib/video-file.js'
import { buildPodcastGroupsCache, cacheRouteFactory, videoFeedsPodcastSetCacheKey } from '@server/middlewares/index.js'
import { MVideo, MVideoCaptionVideo, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { extname } from 'path'
import { MIMETYPES, ROUTE_CACHE_LIFETIME, VIDEO_CATEGORIES, WEBSERVER } from '../../initializers/constants.js'
import { asyncMiddleware, setFeedPodcastContentType, videoFeedsPodcastValidator } from '../../middlewares/index.js'
import { VideoCaptionModel } from '../../models/video/video-caption.js'
import { VideoModel } from '../../models/video/video.js'
import { buildFeedMetadata, getCommonVideoFeedAttributes, getPodcastFeedUrlCustomTag, getVideosForFeeds, initFeed } from './shared/index.js'
const videoPodcastFeedsRouter = express.Router()
// ---------------------------------------------------------------------------
const { middleware: podcastCacheRouteMiddleware, instance: podcastApiCache } = cacheRouteFactory({
headerBlacklist: [ 'Content-Type' ]
})
for (const event of ([ 'video-created', 'video-updated', 'video-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ video }) => {
if (video.remote) return
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: video.channelId }))
})
}
for (const event of ([ 'channel-updated', 'channel-deleted' ] as const)) {
InternalEventEmitter.Instance.on(event, ({ channel }) => {
podcastApiCache.clearGroupSafe(buildPodcastGroupsCache({ channelId: channel.id }))
})
}
// ---------------------------------------------------------------------------
videoPodcastFeedsRouter.get(
'/podcast/videos.xml',
setFeedPodcastContentType,
videoFeedsPodcastSetCacheKey,
podcastCacheRouteMiddleware(ROUTE_CACHE_LIFETIME.FEEDS),
asyncMiddleware(videoFeedsPodcastValidator),
asyncMiddleware(generateVideoPodcastFeed)
)
// ---------------------------------------------------------------------------
export {
videoPodcastFeedsRouter
}
// ---------------------------------------------------------------------------
async function generateVideoPodcastFeed (req: express.Request, res: express.Response) {
const videoChannel = res.locals.videoChannel
const { name, description, imageUrl, ownerImageUrl, email, link, ownerLink } = await buildFeedMetadata({ videoChannel })
const nsfwOptions = buildNSFWFilters()
const data = await getVideosForFeeds({
...nsfwOptions,
sort: '-publishedAt',
// Prevent podcast feeds from listing videos in other instances
// helps prevent duplicates when they are indexed -- only the author should control them
isLocal: true,
include: VideoInclude.FILES,
videoChannelId: videoChannel?.id
})
const language = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'language')
const category = await VideoModel.guessLanguageOrCategoryOfChannel(videoChannel.id, 'category')
const hasNSFW = nsfwOptions.nsfw !== false
? await VideoModel.channelHasNSFWContent(videoChannel.id)
: false
const customTags: CustomTag[] = await Hooks.wrapObject(
[ getPodcastFeedUrlCustomTag(videoChannel) ],
'filter:feed.podcast.channel.create-custom-tags.result',
{ videoChannel }
)
const customXMLNS: CustomXMLNS[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.rss.create-custom-xmlns.result'
)
const feed = await initFeed({
name,
description,
link,
isPodcast: true,
imageUrl,
language: language || 'en',
category: categoryToItunes(category),
nsfw: hasNSFW,
guid: buildUUIDv5FromURL(videoChannel.Actor.url),
locked: email
? { isLocked: true, email } // Default to true because we have no way of offering a redirect yet
: undefined,
person: [ { name, href: ownerLink, img: ownerImageUrl } ],
author: { name: CONFIG.INSTANCE.NAME, link: WEBSERVER.URL },
resourceType: 'videos',
queryString: new URL(WEBSERVER.URL + req.url).search,
medium: 'video',
customXMLNS,
customTags
})
await addVideosToPodcastFeed(feed, data)
// Now the feed generation is done, let's send it!
return res.send(feed.podcast()).end()
}
type PodcastMedia =
| {
type: string
length: number
bitrate: number
sources: { uri: string, contentType?: string }[]
title: string
language?: string
}
| {
sources: { uri: string }[]
type: string
title: string
}
async function generatePodcastItem (options: {
video: VideoModel
liveItem: boolean
media: PodcastMedia[]
}) {
const { video, liveItem, media } = options
const customTags: CustomTag[] = await Hooks.wrapObject(
[],
'filter:feed.podcast.video.create-custom-tags.result',
{ video, liveItem }
)
const commonAttributes = getCommonVideoFeedAttributes(video)
const guid = liveItem
? `${video.url}?publishedAt=${video.publishedAt.toISOString()}`
: video.url
const account = video.VideoChannel.Account
const person = {
name: account.getDisplayName(),
href: account.getClientUrl(),
img: account.Actor.hasImage(ActorImageType.AVATAR)
? WEBSERVER.URL + account.Actor.getMaxQualityImage(ActorImageType.AVATAR).getStaticPath()
: undefined
}
return {
guid,
...commonAttributes,
trackers: video.getTrackerUrls(),
person: [ person ],
media,
socialInteract: [
{
uri: video.url,
protocol: 'activitypub',
accountUrl: account.getClientUrl()
}
],
duration: video.duration,
customTags
}
}
async function addVideosToPodcastFeed (feed: Feed, videos: VideoModel[]) {
const captionsGroup = await VideoCaptionModel.listCaptionsOfMultipleVideos(videos.map(v => v.id))
for (const video of videos) {
if (!video.isLive) {
await addVODPodcastItem({ feed, video, captionsGroup })
} else if (video.isLive && video.state !== VideoState.LIVE_ENDED) {
await addLivePodcastItem({ feed, video })
}
}
}
async function addVODPodcastItem (options: {
feed: Feed
video: VideoModel
captionsGroup: { [id: number]: MVideoCaptionVideo[] }
}) {
const { feed, video, captionsGroup } = options
const webVideos = video.getFormattedWebVideoFilesJSON(true)
.map(f => buildVODWebVideoFile(video, f))
.sort(sortObjectComparator('bitrate', 'asc'))
const streamingPlaylistFiles = buildVODStreamingPlaylists(video)
// Order matters here, the first media URI will be the "default"
// So web videos are default if enabled
const media = [ ...webVideos, ...streamingPlaylistFiles ]
const videoCaptions = buildVODCaptions(video, captionsGroup[video.id])
const item = await generatePodcastItem({ video, liveItem: false, media })
feed.addPodcastItem({ ...item, subTitle: videoCaptions })
}
async function addLivePodcastItem (options: {
feed: Feed
video: VideoModel
}) {
const { feed, video } = options
let status: LiveItemStatus
switch (video.state) {
case VideoState.WAITING_FOR_LIVE:
status = LiveItemStatus.pending
break
case VideoState.PUBLISHED:
status = LiveItemStatus.live
break
}
const item = await generatePodcastItem({ video, liveItem: true, media: buildLiveStreamingPlaylists(video) })
feed.addPodcastLiveItem({ ...item, status, start: video.updatedAt.toISOString() })
}
// ---------------------------------------------------------------------------
function buildVODWebVideoFile (video: MVideo, videoFile: VideoFile) {
const sources = [
{ uri: videoFile.fileUrl },
{ uri: videoFile.torrentUrl, contentType: 'application/x-bittorrent' }
]
if (videoFile.magnetUri) {
sources.push({ uri: videoFile.magnetUri })
}
return {
type: getAppleMimeType(extname(videoFile.fileUrl), videoFile.resolution.id === VideoResolution.H_NOVIDEO),
title: videoFile.resolution.label,
length: videoFile.size,
bitrate: Math.round(videoFile.size / video.duration * 8),
language: video.language,
sources
}
}
function buildVODStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
if (!hls) return []
const { separatedAudioFile } = video.getMaxQualityAudioAndVideoFiles()
return [
...hls.VideoFiles
.sort(sortObjectComparator('resolution', 'asc'))
.map(videoFile => {
const files = [ videoFile ]
if (videoFile.resolution !== VideoResolution.H_NOVIDEO && separatedAudioFile) {
files.push(separatedAudioFile)
}
return {
type: getAppleMimeType(videoFile.extname, videoFile.resolution === VideoResolution.H_NOVIDEO),
title: getResolutionLabel(videoFile),
length: files.reduce((p, f) => p + f.size, 0),
language: video.language,
sources: [
{
uri: buildDownloadFilesUrl({
baseUrl: WEBSERVER.URL,
videoFiles: files.map(f => f.id),
videoUUID: video.uuid,
extension: videoFile.hasVideo()
? '.mp4'
: '.m4a'
})
}
]
}
}),
{
type: 'application/x-mpegURL',
title: 'HLS',
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildLiveStreamingPlaylists (video: MVideoFullLight) {
const hls = video.getHLSPlaylist()
return [
{
type: 'application/x-mpegURL',
title: `HLS live stream`,
sources: [
{ uri: hls.getMasterPlaylistUrl(video) }
],
language: video.language
}
]
}
function buildVODCaptions (video: MVideo, videoCaptions: MVideoCaptionVideo[]) {
return videoCaptions.map(caption => {
const type = MIMETYPES.VIDEO_CAPTIONS.EXT_MIMETYPE[extname(caption.filename)]
if (!type) return null
return {
url: caption.getFileUrl(video),
language: caption.language,
type,
rel: 'captions'
}
}).filter(c => c)
}
function categoryToItunes (category: number) {
const itunesMap: { [id in keyof typeof VIDEO_CATEGORIES]: string } = {
1: 'Music',
2: 'TV &amp; Film',
3: 'Leisure',
4: 'Arts',
5: 'Sports',
6: 'Places &amp; Travel',
7: 'Video Games',
8: 'Society &amp; Culture',
9: 'Comedy',
10: 'Fiction',
11: 'News',
12: 'Leisure',
13: 'Education',
14: 'Society &amp; Culture',
15: 'Technology',
16: 'Pets &amp; Animals',
17: 'Kids &amp; Family',
18: 'Food'
}
return itunesMap[category]
}
// Guidelines: https://help.apple.com/itc/podcasts_connect/#/itcb54353390
// "The type values for the supported file formats are: audio/x-m4a, audio/mpeg, video/quicktime, video/mp4, video/x-m4v, ..."
function getAppleMimeType (extname: string, isAudio: boolean) {
if (extname === '.mp4' && isAudio) return 'audio/x-m4a'
if (extname === '.mp3') return 'audio/mpeg'
return getVideoFileMimeType(extname, isAudio)
}

View File

@@ -0,0 +1,14 @@
export * from './activitypub/index.js'
export * from './api/index.js'
export * from './sitemap.js'
export * from './client.js'
export * from './download.js'
export * from './feeds/index.js'
export * from './lazy-static.js'
export * from './misc.js'
export * from './object-storage-proxy.js'
export * from './plugins.js'
export * from './services.js'
export * from './static.js'
export * from './tracker.js'
export * from './well-known.js'

View File

@@ -0,0 +1,207 @@
import cors from 'cors'
import express from 'express'
import { join } from 'path'
import { HttpStatusCode } from '@peertube/peertube-models'
import { CONFIG } from '@server/initializers/config.js'
import { EventModel } from '@server/models/video/event.js'
import { LiveVideoModel } from '@server/models/video/live-video.js'
import { FILES_CACHE, LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants.js'
import {
AvatarPermanentFileCache,
VideoCaptionsSimpleFileCache,
VideoMiniaturePermanentFileCache,
VideoPreviewsSimpleFileCache,
VideoStoryboardsSimpleFileCache,
VideoTorrentsSimpleFileCache
} from '../lib/files-cache/index.js'
import { asyncMiddleware, handleStaticError } from '../middlewares/index.js'
// ---------------------------------------------------------------------------
// Cache initializations
// ---------------------------------------------------------------------------
VideoPreviewsSimpleFileCache.Instance.init(CONFIG.CACHE.PREVIEWS.SIZE, FILES_CACHE.PREVIEWS.MAX_AGE)
VideoCaptionsSimpleFileCache.Instance.init(CONFIG.CACHE.VIDEO_CAPTIONS.SIZE, FILES_CACHE.VIDEO_CAPTIONS.MAX_AGE)
VideoTorrentsSimpleFileCache.Instance.init(CONFIG.CACHE.TORRENTS.SIZE, FILES_CACHE.TORRENTS.MAX_AGE)
VideoStoryboardsSimpleFileCache.Instance.init(CONFIG.CACHE.STORYBOARDS.SIZE, FILES_CACHE.STORYBOARDS.MAX_AGE)
// ---------------------------------------------------------------------------
const lazyStaticRouter = express.Router()
lazyStaticRouter.use(cors())
lazyStaticRouter.use(
LAZY_STATIC_PATHS.AVATARS + ':filename',
asyncMiddleware(getActorImage),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.BANNERS + ':filename',
asyncMiddleware(getActorImage),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.THUMBNAILS + ':filename',
asyncMiddleware(getThumbnail),
handleStaticError
)
// Event thumbnails - serve directly from filesystem without database lookup
lazyStaticRouter.use(
'/lazy-static/event-thumbnails/:filename',
asyncMiddleware(getEventThumbnail),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.PREVIEWS + ':filename',
asyncMiddleware(getPreview),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.STORYBOARDS + ':filename',
asyncMiddleware(getStoryboard),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.VIDEO_CAPTIONS + ':filename',
asyncMiddleware(getVideoCaption),
handleStaticError
)
lazyStaticRouter.use(
LAZY_STATIC_PATHS.TORRENTS + ':filename',
asyncMiddleware(getTorrent),
handleStaticError
)
// ---------------------------------------------------------------------------
export {
lazyStaticRouter
}
// ---------------------------------------------------------------------------
const avatarPermanentFileCache = new AvatarPermanentFileCache()
function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) {
const filename = req.params.filename
return avatarPermanentFileCache.lazyServe({ filename, res, next })
}
// ---------------------------------------------------------------------------
const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache()
function getThumbnail (req: express.Request, res: express.Response, next: express.NextFunction) {
const filename = req.params.filename
return serveSpecialThumbnailOrFallback({
filename,
fallback: () => videoMiniaturePermanentFileCache.lazyServe({ filename, res, next }),
res
})
}
// ---------------------------------------------------------------------------
async function getEventThumbnail (req: express.Request, res: express.Response) {
const filename = req.params.filename
const event = await EventModel.loadByThumbnailFilename(filename)
console.log('event thumbnail debug', {
filename,
found: !!event,
mime: event?.thumbnailMimeType,
size: event?.thumbnailSize,
hasData: !!event?.thumbnailData,
dataLength: event?.thumbnailData ? event.thumbnailData.length : null
})
if (event?.thumbnailData) return sendSpecialThumbnail({
data: event.thumbnailData,
mimeType: event.thumbnailMimeType,
etag: event.thumbnailEtag,
size: event.thumbnailSize,
res
})
const path = join(CONFIG.STORAGE.THUMBNAILS_DIR, filename)
return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
if (err) {
return res.status(HttpStatusCode.NOT_FOUND_404).end()
}
})
}
async function serveSpecialThumbnailOrFallback (options: {
filename: string
fallback: () => Promise<any>
res: express.Response
}) {
const { filename, fallback, res } = options
const liveVideo = await LiveVideoModel.loadByThumbnailFilename(filename)
if (liveVideo?.thumbnailData) return sendSpecialThumbnail({
data: liveVideo.thumbnailData,
mimeType: liveVideo.thumbnailMimeType,
etag: liveVideo.thumbnailEtag,
size: liveVideo.thumbnailSize,
res
})
return fallback()
}
function sendSpecialThumbnail (options: {
data: Buffer
mimeType: string
etag: string
size: number
res: express.Response
}) {
const { data, mimeType, etag, size, res } = options
res.contentType(mimeType)
res.setHeader('Cache-Control', 'public, max-age=172800')
if (etag) res.setHeader('ETag', etag)
if (size !== null && size !== undefined) res.setHeader('Content-Length', size.toString())
return res.status(HttpStatusCode.OK_200).send(data)
}
// ---------------------------------------------------------------------------
async function getPreview (req: express.Request, res: express.Response) {
const result = await VideoPreviewsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
}
async function getStoryboard (req: express.Request, res: express.Response) {
const result = await VideoStoryboardsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
}
async function getVideoCaption (req: express.Request, res: express.Response) {
const result = await VideoCaptionsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
}
async function getTorrent (req: express.Request, res: express.Response) {
const result = await VideoTorrentsSimpleFileCache.Instance.getFilePath(req.params.filename)
if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
}

View File

@@ -0,0 +1,194 @@
import cors from 'cors'
import express from 'express'
import { HttpNodeinfoDiasporaSoftwareNsSchema20, HttpStatusCode } from '@peertube/peertube-models'
import { CONFIG, isEmailEnabled } from '@server/initializers/config.js'
import { serveIndexHTML } from '@server/lib/html/client-html.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION, ROUTE_CACHE_LIFETIME } from '../initializers/constants.js'
import { getThemeOrDefault } from '../lib/plugins/theme-utils.js'
import { cacheRoute } from '../middlewares/cache/cache.js'
import { apiRateLimiter, asyncMiddleware } from '../middlewares/index.js'
import { UserModel } from '../models/user/user.js'
import { VideoCommentModel } from '../models/video/video-comment.js'
import { VideoModel } from '../models/video/video.js'
const miscRouter = express.Router()
miscRouter.use(cors())
miscRouter.use('/nodeinfo/:version.json', apiRateLimiter, cacheRoute(ROUTE_CACHE_LIFETIME.NODEINFO), asyncMiddleware(generateNodeinfo))
// robots.txt service
miscRouter.get('/robots.txt', apiRateLimiter, cacheRoute(ROUTE_CACHE_LIFETIME.ROBOTS), (_, res: express.Response) => {
res.type('text/plain')
return res.send(CONFIG.INSTANCE.ROBOTS)
})
miscRouter.all('/teapot', apiRateLimiter, getCup, asyncMiddleware(serveIndexHTML))
// security.txt service
miscRouter.get('/security.txt', apiRateLimiter, (_, res: express.Response) => {
return res.redirect(HttpStatusCode.MOVED_PERMANENTLY_301, '/.well-known/security.txt')
})
// ---------------------------------------------------------------------------
export {
miscRouter
}
// ---------------------------------------------------------------------------
async function generateNodeinfo (req: express.Request, res: express.Response) {
const { totalLocalVideos } = await VideoModel.getStats()
const { totalLocalVideoComments } = await VideoCommentModel.getStats()
const { totalUsers, totalMonthlyActiveUsers, totalHalfYearActiveUsers } = await UserModel.getStats()
if (!req.params.version || req.params.version !== '2.0') {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Nodeinfo schema version not handled'
})
}
const json = {
version: '2.0',
software: {
name: 'peertube',
version: PEERTUBE_VERSION
},
protocols: [
'activitypub'
],
services: {
inbound: [],
outbound: [
'atom1.0',
'rss2.0'
]
},
openRegistrations: CONFIG.SIGNUP.ENABLED,
usage: {
users: {
total: totalUsers,
activeMonth: totalMonthlyActiveUsers,
activeHalfyear: totalHalfYearActiveUsers
},
localPosts: totalLocalVideos,
localComments: totalLocalVideoComments
},
metadata: {
taxonomy: {
postsName: 'Videos'
},
nodeName: CONFIG.INSTANCE.NAME,
nodeDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
nodeConfig: {
search: {
remoteUri: {
users: CONFIG.SEARCH.REMOTE_URI.USERS,
anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
}
},
plugin: {
registered: ServerConfigManager.Instance.getRegisteredPlugins()
},
theme: {
registered: ServerConfigManager.Instance.getRegisteredThemes(),
default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
},
email: {
enabled: isEmailEnabled()
},
contactForm: {
enabled: CONFIG.CONTACT_FORM.ENABLED
},
transcoding: {
hls: {
enabled: CONFIG.TRANSCODING.HLS.ENABLED
},
web_videos: {
enabled: CONFIG.TRANSCODING.WEB_VIDEOS.ENABLED
},
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
}
},
import: {
videos: {
http: {
enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
},
torrent: {
enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
}
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
}
}
},
avatar: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
video: {
image: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
size: {
max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
}
},
file: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
}
},
videoCaption: {
file: {
size: {
max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
}
},
user: {
videoQuota: CONFIG.USER.VIDEO_QUOTA,
videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
},
trending: {
videos: {
intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
}
},
tracker: {
enabled: CONFIG.TRACKER.ENABLED
}
}
}
} as HttpNodeinfoDiasporaSoftwareNsSchema20
res.contentType('application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"')
.send(json)
.end()
}
function getCup (req: express.Request, res: express.Response, next: express.NextFunction) {
res.status(HttpStatusCode.I_AM_A_TEAPOT_418)
res.setHeader('Accept-Additions', 'Non-Dairy;1,Sugar;1')
res.setHeader('Safe', 'if-sepia-awake')
return next()
}

View File

@@ -0,0 +1,60 @@
import cors from 'cors'
import express from 'express'
import { OBJECT_STORAGE_PROXY_PATHS } from '@server/initializers/constants.js'
import { proxifyHLS, proxifyWebVideoFile } from '@server/lib/object-storage/index.js'
import {
asyncMiddleware,
ensureCanAccessPrivateVideoHLSFiles,
ensureCanAccessVideoPrivateWebVideoFiles,
ensurePrivateObjectStorageProxyIsEnabled,
optionalAuthenticate
} from '@server/middlewares/index.js'
import { doReinjectVideoFileToken } from './shared/m3u8-playlist.js'
const objectStorageProxyRouter = express.Router()
objectStorageProxyRouter.use(cors())
objectStorageProxyRouter.get(
[ OBJECT_STORAGE_PROXY_PATHS.PRIVATE_WEB_VIDEOS + ':filename', OBJECT_STORAGE_PROXY_PATHS.LEGACY_PRIVATE_WEB_VIDEOS + ':filename' ],
ensurePrivateObjectStorageProxyIsEnabled,
optionalAuthenticate,
asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles),
asyncMiddleware(proxifyWebVideoController)
)
objectStorageProxyRouter.get(OBJECT_STORAGE_PROXY_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename',
ensurePrivateObjectStorageProxyIsEnabled,
optionalAuthenticate,
asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles),
asyncMiddleware(proxifyHLSController)
)
// ---------------------------------------------------------------------------
export {
objectStorageProxyRouter
}
function proxifyWebVideoController (req: express.Request, res: express.Response) {
const filename = req.params.filename
return proxifyWebVideoFile({ req, res, filename })
}
function proxifyHLSController (req: express.Request, res: express.Response) {
const playlist = res.locals.videoStreamingPlaylist
const video = res.locals.onlyVideo
const filename = req.params.filename
const reinjectVideoFileToken = filename.endsWith('.m3u8') && doReinjectVideoFileToken(req)
return proxifyHLS({
req,
res,
playlist,
video,
filename,
reinjectVideoFileToken
})
}

View File

@@ -0,0 +1,174 @@
import express from 'express'
import { join } from 'path'
import { getCompleteLocale, is18nLocale } from '@peertube/peertube-core-utils'
import { HttpStatusCode, PluginType } from '@peertube/peertube-models'
import { isProdInstance } from '@peertube/peertube-node-utils'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import { optionalAuthenticate } from '@server/middlewares/auth.js'
import { buildRateLimiter } from '@server/middlewares/index.js'
import { PLUGIN_GLOBAL_CSS_PATH } from '../initializers/constants.js'
import { PluginManager, RegisteredPlugin } from '../lib/plugins/plugin-manager.js'
import { getExternalAuthValidator, getPluginValidator, pluginStaticDirectoryValidator } from '../middlewares/validators/plugins.js'
import { serveThemeCSSValidator } from '../middlewares/validators/themes.js'
const sendFileOptions = {
maxAge: '30 days',
immutable: isProdInstance()
}
const pluginsRouter = express.Router()
const pluginsRateLimiter = buildRateLimiter({
windowMs: CONFIG.RATES_LIMIT.PLUGINS.WINDOW_MS,
max: CONFIG.RATES_LIMIT.PLUGINS.MAX
})
pluginsRouter.get('/plugins/global.css',
pluginsRateLimiter,
servePluginGlobalCSS
)
pluginsRouter.get('/plugins/translations/:locale.json',
pluginsRateLimiter,
getPluginTranslations
)
pluginsRouter.get('/plugins/:pluginName/:pluginVersion/auth/:authName',
pluginsRateLimiter,
getPluginValidator(PluginType.PLUGIN),
getExternalAuthValidator,
handleAuthInPlugin
)
pluginsRouter.get('/plugins/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
pluginsRateLimiter,
getPluginValidator(PluginType.PLUGIN),
pluginStaticDirectoryValidator,
servePluginStaticDirectory
)
pluginsRouter.get('/plugins/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
pluginsRateLimiter,
getPluginValidator(PluginType.PLUGIN),
pluginStaticDirectoryValidator,
servePluginClientScripts
)
pluginsRouter.use('/plugins/:pluginName/router',
pluginsRateLimiter,
getPluginValidator(PluginType.PLUGIN, false),
optionalAuthenticate,
servePluginCustomRoutes
)
pluginsRouter.use('/plugins/:pluginName/:pluginVersion/router',
pluginsRateLimiter,
getPluginValidator(PluginType.PLUGIN),
optionalAuthenticate,
servePluginCustomRoutes
)
pluginsRouter.get('/themes/:pluginName/:pluginVersion/static/:staticEndpoint(*)',
pluginsRateLimiter,
getPluginValidator(PluginType.THEME),
pluginStaticDirectoryValidator,
servePluginStaticDirectory
)
pluginsRouter.get('/themes/:pluginName/:pluginVersion/client-scripts/:staticEndpoint(*)',
pluginsRateLimiter,
getPluginValidator(PluginType.THEME),
pluginStaticDirectoryValidator,
servePluginClientScripts
)
pluginsRouter.get('/themes/:themeName/:themeVersion/css/:staticEndpoint(*)',
pluginsRateLimiter,
serveThemeCSSValidator,
serveThemeCSSDirectory
)
// ---------------------------------------------------------------------------
export {
pluginsRouter
}
// ---------------------------------------------------------------------------
function servePluginGlobalCSS (req: express.Request, res: express.Response) {
// Only cache requests that have a ?hash=... query param
const globalCSSOptions = req.query.hash
? sendFileOptions
: {}
return res.sendFile(PLUGIN_GLOBAL_CSS_PATH, globalCSSOptions)
}
function getPluginTranslations (req: express.Request, res: express.Response) {
const locale = req.params.locale
if (is18nLocale(locale)) {
const completeLocale = getCompleteLocale(locale)
const json = PluginManager.Instance.getTranslations(completeLocale)
return res.json(json)
}
return res.status(HttpStatusCode.NOT_FOUND_404).end()
}
function servePluginStaticDirectory (req: express.Request, res: express.Response) {
const plugin: RegisteredPlugin = res.locals.registeredPlugin
const staticEndpoint = req.params.staticEndpoint
const [ directory, ...file ] = staticEndpoint.split('/')
const staticPath = plugin.staticDirs[directory]
if (!staticPath) return res.status(HttpStatusCode.NOT_FOUND_404).end()
const filepath = file.join('/')
return res.sendFile(join(plugin.path, staticPath, filepath), sendFileOptions)
}
function servePluginCustomRoutes (req: express.Request, res: express.Response, next: express.NextFunction) {
const plugin: RegisteredPlugin = res.locals.registeredPlugin
const router = PluginManager.Instance.getRouter(plugin.npmName)
if (!router) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return router(req, res, next)
}
function servePluginClientScripts (req: express.Request, res: express.Response) {
const plugin: RegisteredPlugin = res.locals.registeredPlugin
const staticEndpoint = req.params.staticEndpoint
const file = plugin.clientScripts[staticEndpoint]
if (!file) return res.status(HttpStatusCode.NOT_FOUND_404).end()
return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
}
function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
const plugin: RegisteredPlugin = res.locals.registeredPlugin
const staticEndpoint = req.params.staticEndpoint
if (plugin.css.includes(staticEndpoint) === false) {
return res.status(HttpStatusCode.NOT_FOUND_404).end()
}
return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
}
function handleAuthInPlugin (req: express.Request, res: express.Response) {
const authOptions = res.locals.externalAuth
try {
logger.debug('Forwarding auth plugin request in %s of plugin %s.', authOptions.authName, res.locals.registeredPlugin.npmName)
authOptions.onAuthRequest(req, res)
} catch (err) {
logger.error('Forward request error in auth %s of plugin %s.', authOptions.authName, res.locals.registeredPlugin.npmName, { err })
}
}

View File

@@ -0,0 +1,206 @@
import { escapeHTML, forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode } from '@peertube/peertube-models'
import { getOrCreateAPActor } from '@server/lib/activitypub/actors/get.js'
import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors/webfinger.js'
import { AccountModel } from '@server/models/account/account.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { MChannelSummary } from '@server/types/models/index.js'
import cors from 'cors'
import express from 'express'
import { EMBED_SIZE, PREVIEWS_SIZE, THUMBNAILS_SIZE, WEBSERVER } from '../initializers/constants.js'
import { apiRateLimiter, asyncMiddleware, oembedValidator } from '../middlewares/index.js'
import { accountHandleGetValidatorFactory } from '../middlewares/validators/index.js'
import { logger } from '@server/helpers/logger.js'
const servicesRouter = express.Router()
servicesRouter.use('/oembed', cors(), apiRateLimiter, asyncMiddleware(oembedValidator), generateOEmbed)
// TODO: deprecated, remove in PeerTube 8.1
servicesRouter.use(
'/redirect/accounts/:handle',
apiRateLimiter,
asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })),
redirectToAccountUrl
)
servicesRouter.use(
'/redirect/actors/:handle',
apiRateLimiter,
asyncMiddleware(redirectToActorUrl)
)
// ---------------------------------------------------------------------------
export {
servicesRouter
}
// ---------------------------------------------------------------------------
function generateOEmbed (req: express.Request, res: express.Response) {
if (res.locals.videoAll) return generateVideoOEmbed(req, res)
return generatePlaylistOEmbed(req, res)
}
function generatePlaylistOEmbed (req: express.Request, res: express.Response) {
const playlist = res.locals.videoPlaylistSummary
const json = buildOEmbed({
channel: playlist.VideoChannel,
title: playlist.name,
embedPath: playlist.getEmbedStaticPath() + buildPlayerURLQuery(req.query.url),
previewPath: playlist.getThumbnailStaticPath(),
previewSize: THUMBNAILS_SIZE,
req
})
return res.json(json)
}
function generateVideoOEmbed (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const json = buildOEmbed({
channel: video.VideoChannel,
title: video.name,
embedPath: video.getEmbedStaticPath() + buildPlayerURLQuery(req.query.url),
previewPath: video.getPreviewStaticPath(),
previewSize: PREVIEWS_SIZE,
req
})
return res.json(json)
}
function buildPlayerURLQuery (inputQueryUrl: string) {
const allowedParameters = new Set([
'start',
'stop',
'loop',
'autoplay',
'muted',
'controls',
'controlBar',
'title',
'api',
'warningTitle',
'peertubeLink',
'p2p',
'subtitle',
'bigPlayBackgroundColor',
'mode',
'foregroundColor',
'playbackRate',
'api',
'waitPasswordFromEmbedAPI',
'playlistPosition'
])
const params = new URLSearchParams()
new URL(inputQueryUrl).searchParams.forEach((v, k) => {
if (allowedParameters.has(k)) {
params.append(k, v)
}
})
const stringQuery = params.toString()
if (!stringQuery) return ''
return '?' + stringQuery
}
function buildOEmbed (options: {
req: express.Request
title: string
channel: MChannelSummary
previewPath: string | null
embedPath: string
previewSize: {
height: number
width: number
}
}) {
const { req, previewSize, previewPath, title, channel, embedPath } = options
const webserverUrl = WEBSERVER.URL
const maxHeight = forceNumber(req.query.maxheight)
const maxWidth = forceNumber(req.query.maxwidth)
const embedUrl = webserverUrl + embedPath
const embedTitle = escapeHTML(title)
let thumbnailUrl = previewPath
? webserverUrl + previewPath
: undefined
let embedWidth = EMBED_SIZE.width
if (maxWidth < embedWidth) embedWidth = maxWidth
let embedHeight = EMBED_SIZE.height
if (maxHeight < embedHeight) embedHeight = maxHeight
// Our thumbnail is too big for the consumer
if (
(maxHeight !== undefined && maxHeight < previewSize.height) ||
(maxWidth !== undefined && maxWidth < previewSize.width)
) {
thumbnailUrl = undefined
}
const html = `<iframe width="${embedWidth}" height="${embedHeight}" sandbox="allow-same-origin allow-scripts allow-popups allow-forms" ` +
`title="${embedTitle}" src="${embedUrl}" style="border: none" allow="fullscreen"></iframe>`
const json: any = {
type: 'video',
version: '1.0',
html,
width: embedWidth,
height: embedHeight,
title,
author_name: channel.name,
author_url: channel.Actor.url,
provider_name: 'PeerTube',
provider_url: webserverUrl
}
if (thumbnailUrl !== undefined) {
json.thumbnail_url = thumbnailUrl
json.thumbnail_width = previewSize.width
json.thumbnail_height = previewSize.height
}
return json
}
// ---------------------------------------------------------------------------
function redirectToAccountUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
return res.redirect(res.locals.account.Actor.url)
}
async function redirectToActorUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
const handle = req.params.handle
if (handle.includes('@')) {
try {
const actorUrl = await loadActorUrlOrGetFromWebfinger(handle)
const actor = await getOrCreateAPActor(actorUrl, 'all')
if (actor) return res.redirect(actor.url)
} catch (error) {
logger.info('Cannot redirect to actor URL from handle.', { handle, error })
return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
}
} else {
const account = await AccountModel.loadByHandle(handle)
if (account) return res.redirect(account.Actor.url)
const channel = await VideoChannelModel.loadByHandleAndPopulateAccount(handle)
if (channel) return res.redirect(channel.Actor.url)
}
return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
}

View File

@@ -0,0 +1,18 @@
import express from 'express'
function doReinjectVideoFileToken (req: express.Request) {
return req.query.videoFileToken && req.query.reinjectVideoFileToken
}
function buildReinjectVideoFileTokenQuery (req: express.Request, isMaster: boolean) {
const query = 'videoFileToken=' + req.query.videoFileToken
if (isMaster) {
return query + '&reinjectVideoFileToken=true'
}
return query
}
export {
doReinjectVideoFileToken,
buildReinjectVideoFileTokenQuery
}

View File

@@ -0,0 +1,147 @@
import { VideoFileStream, VideoInclude } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { getServerActor } from '@server/models/application/application.js'
import express from 'express'
import truncate from 'lodash-es/truncate.js'
import { ErrorLevel, SitemapStream, streamToPromise } from 'sitemap'
import { buildNSFWFilters } from '../helpers/express-utils.js'
import { ROUTE_CACHE_LIFETIME, WEBSERVER } from '../initializers/constants.js'
import { apiRateLimiter, asyncMiddleware, cacheRoute } from '../middlewares/index.js'
import { AccountModel } from '../models/account/account.js'
import { VideoChannelModel } from '../models/video/video-channel.js'
import { VideoModel } from '../models/video/video.js'
const sitemapRouter = express.Router()
sitemapRouter.use('/sitemap.xml', apiRateLimiter, cacheRoute(ROUTE_CACHE_LIFETIME.SITEMAP), asyncMiddleware(getSitemap))
// ---------------------------------------------------------------------------
export {
sitemapRouter
}
// ---------------------------------------------------------------------------
async function getSitemap (req: express.Request, res: express.Response) {
let urls = getSitemapBasicUrls()
urls = urls.concat(await getSitemapLocalVideoUrls())
urls = urls.concat(await getSitemapVideoChannelUrls())
urls = urls.concat(await getSitemapAccountUrls())
const sitemapStream = new SitemapStream({
hostname: WEBSERVER.URL,
errorHandler: (err: Error, level: ErrorLevel) => {
if (level === ErrorLevel.WARN) {
logger.warn('Warning in sitemap generation.', { err })
} else if (level === ErrorLevel.THROW) {
logger.error('Error in sitemap generation.', { err })
throw err
}
}
})
for (const urlObj of urls) {
sitemapStream.write(urlObj)
}
sitemapStream.end()
const xml = await streamToPromise(sitemapStream)
res.header('Content-Type', 'application/xml')
res.send(xml)
}
async function getSitemapVideoChannelUrls () {
const rows = await VideoChannelModel.listLocalsForSitemap('createdAt')
return rows.map(channel => ({ url: channel.getClientUrl() }))
}
async function getSitemapAccountUrls () {
const rows = await AccountModel.listLocalsForSitemap('createdAt')
return rows.map(account => ({ url: account.getClientUrl() }))
}
async function getSitemapLocalVideoUrls () {
const serverActor = await getServerActor()
let acc: { url: string, video: any[] }[] = []
const chunkSize = 200
let hasData = true
let i = 0
while (hasData && i < 1000) {
const { data } = await VideoModel.listForApi({
...buildNSFWFilters(),
start: chunkSize * i,
count: chunkSize,
sort: 'createdAt',
displayOnlyForFollower: {
actorId: serverActor.id,
orLocalVideos: true
},
isLocal: true,
countVideos: false,
include: VideoInclude.FILES | VideoInclude.TAGS
})
hasData = data.length !== 0
i++
acc = acc.concat(
data.map(v => {
const contentLoc = v.getHLSPlaylist()?.getMasterPlaylistUrl(v) ||
v.getMaxQualityFile(VideoFileStream.VIDEO)?.getFileUrl(v) ||
v.getMaxQualityFile(VideoFileStream.AUDIO)?.getFileUrl(v)
return {
url: process.env.EXPERIMENTAL_SITEMAP_VIDEO_URL === 'true'
? WEBSERVER.URL + '/videos/watch/' + v.uuid
: WEBSERVER.URL + v.getWatchStaticPath(),
video: [
{
// Sitemap title should be < 100 characters
'title': truncate(v.name, { length: 100, omission: '...' }),
// Sitemap description should be < 2000 characters
'description': truncate(v.description || v.name, { length: 2000, omission: '...' }),
'player_loc': WEBSERVER.URL + v.getEmbedStaticPath(),
'thumbnail_loc': WEBSERVER.URL + v.getMiniatureStaticPath(),
'content_loc': contentLoc,
'duration': v.duration,
'view_count': v.views,
'publication_date': v.publishedAt.toISOString(),
'uploader': v.VideoChannel.getDisplayName(),
'uploader:info': v.VideoChannel.getClientUrl(),
'live': v.isLive ? 'YES' : 'NO',
'family_friendly': v.nsfw ? 'NO' : 'YES',
'rating': (v.likes * 5) / (v.likes + v.dislikes) || 0, // Rating is between 0.0 and 5.0
'tag': v.Tags.map(t => t.name)
}
]
}
})
)
}
return acc
}
function getSitemapBasicUrls () {
const paths = [
'/about/instance/home',
'/videos/browse?scope=local'
]
if (ServerConfigManager.Instance.isHomepageEnabled()) {
paths.push('/home')
}
return paths.map(p => ({ url: WEBSERVER.URL + p }))
}

View File

@@ -0,0 +1,127 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { injectQueryToPlaylistUrls } from '@server/lib/hls.js'
import {
asyncMiddleware,
ensureCanAccessPrivateVideoHLSFiles,
ensureCanAccessVideoPrivateWebVideoFiles,
handleStaticError,
optionalAuthenticate,
privateHLSFileValidator,
privateM3U8PlaylistValidator
} from '@server/middlewares/index.js'
import cors from 'cors'
import express from 'express'
import { readFile } from 'fs/promises'
import { join } from 'path'
import { CONFIG } from '../initializers/config.js'
import { DIRECTORIES, STATIC_PATHS } from '../initializers/constants.js'
import { buildReinjectVideoFileTokenQuery, doReinjectVideoFileToken } from './shared/m3u8-playlist.js'
const staticRouter = express.Router()
// Cors is very important to let other servers access torrent and video files
staticRouter.use(cors())
// ---------------------------------------------------------------------------
// Web videos/Classic videos
// ---------------------------------------------------------------------------
const privateWebVideoStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true
? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessVideoPrivateWebVideoFiles) ]
: []
staticRouter.use(
[ STATIC_PATHS.PRIVATE_WEB_VIDEOS, STATIC_PATHS.LEGACY_PRIVATE_WEB_VIDEOS ],
...privateWebVideoStaticMiddlewares,
express.static(DIRECTORIES.WEB_VIDEOS.PRIVATE, { fallthrough: false }),
handleStaticError
)
staticRouter.use(
[ STATIC_PATHS.WEB_VIDEOS, STATIC_PATHS.LEGACY_WEB_VIDEOS ],
express.static(DIRECTORIES.WEB_VIDEOS.PUBLIC, { fallthrough: false }),
handleStaticError
)
staticRouter.use(
STATIC_PATHS.REDUNDANCY,
express.static(CONFIG.STORAGE.REDUNDANCY_DIR, { fallthrough: false }),
handleStaticError
)
// ---------------------------------------------------------------------------
// HLS
// ---------------------------------------------------------------------------
const privateHLSStaticMiddlewares = CONFIG.STATIC_FILES.PRIVATE_FILES_REQUIRE_AUTH === true
? [ optionalAuthenticate, asyncMiddleware(ensureCanAccessPrivateVideoHLSFiles) ]
: []
staticRouter.use(
STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:playlistNameWithoutExtension([a-z0-9-]+).m3u8',
privateM3U8PlaylistValidator,
...privateHLSStaticMiddlewares,
asyncMiddleware(servePrivateM3U8)
)
staticRouter.use(
STATIC_PATHS.STREAMING_PLAYLISTS.PRIVATE_HLS + ':videoUUID/:filename',
privateHLSFileValidator,
...privateHLSStaticMiddlewares,
servePrivateHLSFile
)
// ---------------------------------------------------------------------------
staticRouter.use(
STATIC_PATHS.STREAMING_PLAYLISTS.HLS,
express.static(DIRECTORIES.HLS_STREAMING_PLAYLIST.PUBLIC, { fallthrough: false }),
handleStaticError
)
// ---------------------------------------------------------------------------
// Uploads
// ---------------------------------------------------------------------------
staticRouter.use(
STATIC_PATHS.UPLOAD_IMAGES,
express.static(DIRECTORIES.UPLOAD_IMAGES, { fallthrough: false }),
handleStaticError
)
export {
staticRouter
}
// ---------------------------------------------------------------------------
function servePrivateHLSFile (req: express.Request, res: express.Response) {
const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.filename)
return res.sendFile(path)
}
async function servePrivateM3U8 (req: express.Request, res: express.Response) {
const path = join(DIRECTORIES.HLS_STREAMING_PLAYLIST.PRIVATE, req.params.videoUUID, req.params.playlistNameWithoutExtension + '.m3u8')
const filename = req.params.playlistNameWithoutExtension + '.m3u8'
let playlistContent: string
try {
playlistContent = await readFile(path, 'utf-8')
} catch (err) {
if (err.message.includes('ENOENT')) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'File not found'
})
}
throw err
}
// Inject token in playlist so players that cannot alter the HTTP request can still watch the video
const transformedContent = doReinjectVideoFileToken(req)
? injectQueryToPlaylistUrls(playlistContent, buildReinjectVideoFileTokenQuery(req, filename.endsWith('master.m3u8')))
: playlistContent
return res.set('content-type', 'application/x-mpegurl; charset=utf-8').send(transformedContent).end()
}

Some files were not shown because too many files have changed in this diff Show More