Init commit
This commit is contained in:
270
server/core/controllers/api/abuse.ts
Normal file
270
server/core/controllers/api/abuse.ts
Normal 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()
|
||||
}
|
||||
289
server/core/controllers/api/accounts.ts
Normal file
289
server/core/controllers/api/accounts.ts
Normal 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))
|
||||
}
|
||||
82
server/core/controllers/api/automatic-tags.ts
Normal file
82
server/core/controllers/api/automatic-tags.ts
Normal 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)
|
||||
}
|
||||
110
server/core/controllers/api/blocklist.ts
Normal file
110
server/core/controllers/api/blocklist.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
39
server/core/controllers/api/bulk.ts
Normal file
39
server/core/controllers/api/bulk.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
36
server/core/controllers/api/client-config.ts
Normal file
36
server/core/controllers/api/client-config.ts
Normal 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)
|
||||
}
|
||||
646
server/core/controllers/api/config.ts
Normal file
646
server/core/controllers/api/config.ts
Normal 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)
|
||||
}
|
||||
48
server/core/controllers/api/custom-page.ts
Normal file
48
server/core/controllers/api/custom-page.ts
Normal 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()
|
||||
}
|
||||
537
server/core/controllers/api/events.ts
Normal file
537
server/core/controllers/api/events.ts
Normal 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 }
|
||||
86
server/core/controllers/api/index.ts
Normal file
86
server/core/controllers/api/index.ts
Normal 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()
|
||||
}
|
||||
112
server/core/controllers/api/jobs.ts
Normal file
112
server/core/controllers/api/jobs.ts
Normal 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
|
||||
}
|
||||
541
server/core/controllers/api/live-stream.ts
Normal file
541
server/core/controllers/api/live-stream.ts
Normal 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
|
||||
)
|
||||
}
|
||||
403
server/core/controllers/api/live-videos.ts
Normal file
403
server/core/controllers/api/live-videos.ts
Normal 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
|
||||
}
|
||||
34
server/core/controllers/api/metrics.ts
Normal file
34
server/core/controllers/api/metrics.ts
Normal 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)
|
||||
}
|
||||
54
server/core/controllers/api/oauth-clients.ts
Normal file
54
server/core/controllers/api/oauth-clients.ts
Normal 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
|
||||
}
|
||||
142
server/core/controllers/api/overviews.ts
Normal file
142
server/core/controllers/api/overviews.ts
Normal 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())
|
||||
}
|
||||
112
server/core/controllers/api/player-settings.ts
Normal file
112
server/core/controllers/api/player-settings.ts
Normal 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))
|
||||
}
|
||||
230
server/core/controllers/api/plugins.ts
Normal file
230
server/core/controllers/api/plugins.ts
Normal 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)
|
||||
}
|
||||
20
server/core/controllers/api/runners/index.ts
Normal file
20
server/core/controllers/api/runners/index.ts
Normal 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
|
||||
}
|
||||
165
server/core/controllers/api/runners/jobs-files.ts
Normal file
165
server/core/controllers/api/runners/jobs-files.ts
Normal 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))
|
||||
}
|
||||
472
server/core/controllers/api/runners/jobs.ts
Normal file
472
server/core/controllers/api/runners/jobs.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
122
server/core/controllers/api/runners/manage-runners.ts
Normal file
122
server/core/controllers/api/runners/manage-runners.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
91
server/core/controllers/api/runners/registration-tokens.ts
Normal file
91
server/core/controllers/api/runners/registration-tokens.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
19
server/core/controllers/api/search/index.ts
Normal file
19
server/core/controllers/api/search/index.ts
Normal 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
|
||||
}
|
||||
152
server/core/controllers/api/search/search-video-channels.ts
Normal file
152
server/core/controllers/api/search/search-video-channels.ts
Normal 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/')
|
||||
}
|
||||
131
server/core/controllers/api/search/search-video-playlists.ts
Normal file
131
server/core/controllers/api/search/search-video-playlists.ts
Normal 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/')
|
||||
}
|
||||
173
server/core/controllers/api/search/search-videos.ts
Normal file
173
server/core/controllers/api/search/search-videos.ts
Normal 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/')
|
||||
}
|
||||
1
server/core/controllers/api/search/shared/index.ts
Normal file
1
server/core/controllers/api/search/shared/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './utils.js'
|
||||
16
server/core/controllers/api/search/shared/utils.ts
Normal file
16
server/core/controllers/api/search/shared/utils.ts
Normal 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()
|
||||
}
|
||||
30
server/core/controllers/api/server/contact.ts
Normal file
30
server/core/controllers/api/server/contact.ts
Normal 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
|
||||
}
|
||||
189
server/core/controllers/api/server/debug.ts
Normal file
189
server/core/controllers/api/server/debug.ts
Normal 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)
|
||||
}
|
||||
217
server/core/controllers/api/server/follows.ts
Normal file
217
server/core/controllers/api/server/follows.ts
Normal 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()
|
||||
}
|
||||
27
server/core/controllers/api/server/index.ts
Normal file
27
server/core/controllers/api/server/index.ts
Normal 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
|
||||
}
|
||||
201
server/core/controllers/api/server/logs.ts
Normal file
201
server/core/controllers/api/server/logs.ts
Normal 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$')
|
||||
}
|
||||
115
server/core/controllers/api/server/redundancy.ts
Normal file
115
server/core/controllers/api/server/redundancy.ts
Normal 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()
|
||||
}
|
||||
152
server/core/controllers/api/server/server-blocklist.ts
Normal file
152
server/core/controllers/api/server/server-blocklist.ts
Normal 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)
|
||||
}
|
||||
26
server/core/controllers/api/server/stats.ts
Normal file
26
server/core/controllers/api/server/stats.ts
Normal 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
|
||||
}
|
||||
85
server/core/controllers/api/users/email-verification.ts
Normal file
85
server/core/controllers/api/users/email-verification.ts
Normal 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)
|
||||
}
|
||||
340
server/core/controllers/api/users/index.ts
Normal file
340
server/core/controllers/api/users/index.ts
Normal 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)
|
||||
}
|
||||
383
server/core/controllers/api/users/me.ts
Normal file
383
server/core/controllers/api/users/me.ts
Normal 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()
|
||||
}
|
||||
48
server/core/controllers/api/users/my-abuses.ts
Normal file
48
server/core/controllers/api/users/my-abuses.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
144
server/core/controllers/api/users/my-blocklist.ts
Normal file
144
server/core/controllers/api/users/my-blocklist.ts
Normal 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)
|
||||
}
|
||||
75
server/core/controllers/api/users/my-history.ts
Normal file
75
server/core/controllers/api/users/my-history.ts
Normal 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()
|
||||
}
|
||||
119
server/core/controllers/api/users/my-notifications.ts
Normal file
119
server/core/controllers/api/users/my-notifications.ts
Normal 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)
|
||||
}
|
||||
184
server/core/controllers/api/users/my-subscriptions.ts
Normal file
184
server/core/controllers/api/users/my-subscriptions.ts
Normal 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)))
|
||||
}
|
||||
51
server/core/controllers/api/users/my-video-playlists.ts
Normal file
51
server/core/controllers/api/users/my-video-playlists.ts
Normal 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)
|
||||
}
|
||||
264
server/core/controllers/api/users/registrations.ts
Normal file
264
server/core/controllers/api/users/registrations.ts
Normal 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)
|
||||
}
|
||||
203
server/core/controllers/api/users/token.ts
Normal file
203
server/core/controllers/api/users/token.ts
Normal 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)
|
||||
}
|
||||
95
server/core/controllers/api/users/two-factor.ts
Normal file
95
server/core/controllers/api/users/two-factor.ts
Normal 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)
|
||||
}
|
||||
97
server/core/controllers/api/users/user-exports.ts
Normal file
97
server/core/controllers/api/users/user-exports.ts
Normal 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)
|
||||
}
|
||||
79
server/core/controllers/api/users/user-imports.ts
Normal file
79
server/core/controllers/api/users/user-imports.ts
Normal 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())
|
||||
}
|
||||
100
server/core/controllers/api/video-channel-sync.ts
Normal file
100
server/core/controllers/api/video-channel-sync.ts
Normal 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()
|
||||
}
|
||||
466
server/core/controllers/api/video-channels/index.ts
Normal file
466
server/core/controllers/api/video-channels/index.ts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
619
server/core/controllers/api/video-playlist.ts
Normal file
619
server/core/controllers/api/video-playlist.ts
Normal 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)
|
||||
}
|
||||
112
server/core/controllers/api/videos/blacklist.ts
Normal file
112
server/core/controllers/api/videos/blacklist.ts
Normal 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()
|
||||
}
|
||||
148
server/core/controllers/api/videos/captions.ts
Normal file
148
server/core/controllers/api/videos/captions.ts
Normal 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()
|
||||
}
|
||||
62
server/core/controllers/api/videos/chapters.ts
Normal file
62
server/core/controllers/api/videos/chapters.ts
Normal 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)
|
||||
}
|
||||
255
server/core/controllers/api/videos/comment.ts
Normal file
255
server/core/controllers/api/videos/comment.ts
Normal 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)
|
||||
}
|
||||
120
server/core/controllers/api/videos/files.ts
Normal file
120
server/core/controllers/api/videos/files.ts
Normal 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)
|
||||
}
|
||||
299
server/core/controllers/api/videos/import.ts
Normal file
299
server/core/controllers/api/videos/import.ts
Normal 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
|
||||
}
|
||||
200
server/core/controllers/api/videos/index.ts
Normal file
200
server/core/controllers/api/videos/index.ts
Normal 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()
|
||||
}
|
||||
256
server/core/controllers/api/videos/live.ts
Normal file
256
server/core/controllers/api/videos/live.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
142
server/core/controllers/api/videos/ownership.ts
Normal file
142
server/core/controllers/api/videos/ownership.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
138
server/core/controllers/api/videos/passwords.ts
Normal file
138
server/core/controllers/api/videos/passwords.ts
Normal 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)
|
||||
}
|
||||
36
server/core/controllers/api/videos/rate.ts
Normal file
36
server/core/controllers/api/videos/rate.ts
Normal 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)
|
||||
}
|
||||
247
server/core/controllers/api/videos/source.ts
Normal file
247
server/core/controllers/api/videos/source.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
103
server/core/controllers/api/videos/stats.ts
Normal file
103
server/core/controllers/api/videos/stats.ts
Normal 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)
|
||||
}
|
||||
29
server/core/controllers/api/videos/storyboard.ts
Normal file
29
server/core/controllers/api/videos/storyboard.ts
Normal 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())
|
||||
})
|
||||
}
|
||||
156
server/core/controllers/api/videos/studio.ts
Normal file
156
server/core/controllers/api/videos/studio.ts
Normal 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
|
||||
}
|
||||
33
server/core/controllers/api/videos/token.ts
Normal file
33
server/core/controllers/api/videos/token.ts
Normal 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)
|
||||
}
|
||||
61
server/core/controllers/api/videos/transcoding.ts
Normal file
61
server/core/controllers/api/videos/transcoding.ts
Normal 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)
|
||||
}
|
||||
326
server/core/controllers/api/videos/update.ts
Normal file
326
server/core/controllers/api/videos/update.ts
Normal 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)
|
||||
}
|
||||
194
server/core/controllers/api/videos/upload.ts
Normal file
194
server/core/controllers/api/videos/upload.ts
Normal 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()
|
||||
}
|
||||
70
server/core/controllers/api/videos/view.ts
Normal file
70
server/core/controllers/api/videos/view.ts
Normal 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
|
||||
})
|
||||
}
|
||||
162
server/core/controllers/api/watched-words.ts
Normal file
162
server/core/controllers/api/watched-words.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user