Add hooks support for video download
This commit is contained in:
parent
c0ab041c2c
commit
4bc45da342
|
@ -1,7 +1,9 @@
|
||||||
import { mapValues, pick } from 'lodash-es'
|
import { mapValues, pick } from 'lodash-es'
|
||||||
|
import { pipe } from 'rxjs'
|
||||||
|
import { tap } from 'rxjs/operators'
|
||||||
import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
|
import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
|
||||||
import { AuthService, Notifier } from '@app/core'
|
import { AuthService, HooksService, Notifier } from '@app/core'
|
||||||
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
|
import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
|
||||||
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
|
import { BytesPipe, NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
|
||||||
|
|
||||||
|
@ -26,7 +28,7 @@ export class VideoDownloadComponent {
|
||||||
videoFileMetadataVideoStream: FileMetadata | undefined
|
videoFileMetadataVideoStream: FileMetadata | undefined
|
||||||
videoFileMetadataAudioStream: FileMetadata | undefined
|
videoFileMetadataAudioStream: FileMetadata | undefined
|
||||||
videoCaptions: VideoCaption[]
|
videoCaptions: VideoCaption[]
|
||||||
activeModal: NgbActiveModal
|
activeModal: NgbModalRef
|
||||||
|
|
||||||
type: DownloadType = 'video'
|
type: DownloadType = 'video'
|
||||||
|
|
||||||
|
@ -38,7 +40,8 @@ export class VideoDownloadComponent {
|
||||||
private notifier: Notifier,
|
private notifier: Notifier,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
private videoService: VideoService,
|
private videoService: VideoService,
|
||||||
private auth: AuthService
|
private auth: AuthService,
|
||||||
|
private hooks: HooksService
|
||||||
) {
|
) {
|
||||||
this.bytesPipe = new BytesPipe()
|
this.bytesPipe = new BytesPipe()
|
||||||
this.numbersPipe = new NumberFormatterPipe(this.localeId)
|
this.numbersPipe = new NumberFormatterPipe(this.localeId)
|
||||||
|
@ -64,7 +67,12 @@ export class VideoDownloadComponent {
|
||||||
|
|
||||||
this.resolutionId = this.getVideoFiles()[0].resolution.id
|
this.resolutionId = this.getVideoFiles()[0].resolution.id
|
||||||
this.onResolutionIdChange()
|
this.onResolutionIdChange()
|
||||||
|
|
||||||
if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
|
if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
|
||||||
|
|
||||||
|
this.activeModal.shown.subscribe(() => {
|
||||||
|
this.hooks.runAction('action:modal.video-download.shown', 'common')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose () {
|
onClose () {
|
||||||
|
@ -88,6 +96,7 @@ export class VideoDownloadComponent {
|
||||||
if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
|
if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
|
||||||
|
|
||||||
await this.hydrateMetadataFromMetadataUrl(this.videoFile)
|
await this.hydrateMetadataFromMetadataUrl(this.videoFile)
|
||||||
|
if (!this.videoFile.metadata) return
|
||||||
|
|
||||||
this.videoFileMetadataFormat = this.videoFile
|
this.videoFileMetadataFormat = this.videoFile
|
||||||
? this.getMetadataFormat(this.videoFile.metadata.format)
|
? this.getMetadataFormat(this.videoFile.metadata.format)
|
||||||
|
@ -201,7 +210,7 @@ export class VideoDownloadComponent {
|
||||||
|
|
||||||
private hydrateMetadataFromMetadataUrl (file: VideoFile) {
|
private hydrateMetadataFromMetadataUrl (file: VideoFile) {
|
||||||
const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
|
const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
|
||||||
observable.subscribe(res => file.metadata = res)
|
.pipe(tap(res => file.metadata = res))
|
||||||
|
|
||||||
return observable.toPromise()
|
return observable.toPromise()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import * as cors from 'cors'
|
import * as cors from 'cors'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
|
import { logger } from '@server/helpers/logger'
|
||||||
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
|
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
|
||||||
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
import { getVideoFilePath } from '@server/lib/video-paths'
|
import { getVideoFilePath } from '@server/lib/video-paths'
|
||||||
import { MVideoFile, MVideoFullLight } from '@server/types/models'
|
import { MStreamingPlaylist, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
|
||||||
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
|
||||||
import { VideoStreamingPlaylistType } from '@shared/models'
|
import { VideoStreamingPlaylistType } from '@shared/models'
|
||||||
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
|
import { STATIC_DOWNLOAD_PATHS } from '../initializers/constants'
|
||||||
|
@ -14,19 +16,19 @@ downloadRouter.use(cors())
|
||||||
|
|
||||||
downloadRouter.use(
|
downloadRouter.use(
|
||||||
STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
|
STATIC_DOWNLOAD_PATHS.TORRENTS + ':filename',
|
||||||
downloadTorrent
|
asyncMiddleware(downloadTorrent)
|
||||||
)
|
)
|
||||||
|
|
||||||
downloadRouter.use(
|
downloadRouter.use(
|
||||||
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
|
STATIC_DOWNLOAD_PATHS.VIDEOS + ':id-:resolution([0-9]+).:extension',
|
||||||
asyncMiddleware(videosDownloadValidator),
|
asyncMiddleware(videosDownloadValidator),
|
||||||
downloadVideoFile
|
asyncMiddleware(downloadVideoFile)
|
||||||
)
|
)
|
||||||
|
|
||||||
downloadRouter.use(
|
downloadRouter.use(
|
||||||
STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
|
STATIC_DOWNLOAD_PATHS.HLS_VIDEOS + ':id-:resolution([0-9]+)-fragmented.:extension',
|
||||||
asyncMiddleware(videosDownloadValidator),
|
asyncMiddleware(videosDownloadValidator),
|
||||||
downloadHLSVideoFile
|
asyncMiddleware(downloadHLSVideoFile)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
@ -41,28 +43,58 @@ async function downloadTorrent (req: express.Request, res: express.Response) {
|
||||||
const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
|
const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
|
||||||
if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
|
if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
|
||||||
|
|
||||||
|
const allowParameters = { torrentPath: result.path, downloadName: result.downloadName }
|
||||||
|
|
||||||
|
const allowedResult = await Hooks.wrapFun(
|
||||||
|
isTorrentDownloadAllowed,
|
||||||
|
allowParameters,
|
||||||
|
'filter:api.download.torrent.allowed.result'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||||
|
|
||||||
return res.download(result.path, result.downloadName)
|
return res.download(result.path, result.downloadName)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadVideoFile (req: express.Request, res: express.Response) {
|
async function downloadVideoFile (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
|
|
||||||
const videoFile = getVideoFile(req, video.VideoFiles)
|
const videoFile = getVideoFile(req, video.VideoFiles)
|
||||||
if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||||
|
|
||||||
|
const allowParameters = { video, videoFile }
|
||||||
|
|
||||||
|
const allowedResult = await Hooks.wrapFun(
|
||||||
|
isVideoDownloadAllowed,
|
||||||
|
allowParameters,
|
||||||
|
'filter:api.download.video.allowed.result'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||||
|
|
||||||
return res.download(getVideoFilePath(video, videoFile), `${video.name}-${videoFile.resolution}p${videoFile.extname}`)
|
return res.download(getVideoFilePath(video, videoFile), `${video.name}-${videoFile.resolution}p${videoFile.extname}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadHLSVideoFile (req: express.Request, res: express.Response) {
|
async function downloadHLSVideoFile (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
const playlist = getHLSPlaylist(video)
|
const streamingPlaylist = getHLSPlaylist(video)
|
||||||
if (!playlist) return res.status(HttpStatusCode.NOT_FOUND_404).end
|
if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end
|
||||||
|
|
||||||
const videoFile = getVideoFile(req, playlist.VideoFiles)
|
const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
|
||||||
if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end()
|
||||||
|
|
||||||
const filename = `${video.name}-${videoFile.resolution}p-${playlist.getStringType()}${videoFile.extname}`
|
const allowParameters = { video, streamingPlaylist, videoFile }
|
||||||
return res.download(getVideoFilePath(playlist, videoFile), filename)
|
|
||||||
|
const allowedResult = await Hooks.wrapFun(
|
||||||
|
isVideoDownloadAllowed,
|
||||||
|
allowParameters,
|
||||||
|
'filter:api.download.video.allowed.result'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!checkAllowResult(res, allowParameters, allowedResult)) return
|
||||||
|
|
||||||
|
const filename = `${video.name}-${videoFile.resolution}p-${streamingPlaylist.getStringType()}${videoFile.extname}`
|
||||||
|
return res.download(getVideoFilePath(streamingPlaylist, videoFile), filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVideoFile (req: express.Request, files: MVideoFile[]) {
|
function getVideoFile (req: express.Request, files: MVideoFile[]) {
|
||||||
|
@ -76,3 +108,34 @@ function getHLSPlaylist (video: MVideoFullLight) {
|
||||||
|
|
||||||
return Object.assign(playlist, { Video: video })
|
return Object.assign(playlist, { Video: video })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AllowedResult = {
|
||||||
|
allowed: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTorrentDownloadAllowed (_object: {
|
||||||
|
torrentPath: string
|
||||||
|
}): AllowedResult {
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoDownloadAllowed (_object: {
|
||||||
|
video: MVideo
|
||||||
|
videoFile: MVideoFile
|
||||||
|
streamingPlaylist?: MStreamingPlaylist
|
||||||
|
}): AllowedResult {
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
|
||||||
|
if (!result || result.allowed !== true) {
|
||||||
|
logger.info('Download is not allowed.', { result, allowParameters })
|
||||||
|
res.status(HttpStatusCode.FORBIDDEN_403)
|
||||||
|
.json({ error: result.errorMessage || 'Refused download' })
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { CONFIG } from '../../initializers/config'
|
||||||
import { FILES_CACHE } from '../../initializers/constants'
|
import { FILES_CACHE } from '../../initializers/constants'
|
||||||
import { VideoModel } from '../../models/video/video'
|
import { VideoModel } from '../../models/video/video'
|
||||||
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
|
import { AbstractVideoStaticFileCache } from './abstract-video-static-file-cache'
|
||||||
|
import { MVideo, MVideoFile } from '@server/types/models'
|
||||||
|
|
||||||
class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
|
class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
|
||||||
|
|
||||||
|
@ -22,7 +23,11 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
|
||||||
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
|
const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename)
|
||||||
if (!file) return undefined
|
if (!file) return undefined
|
||||||
|
|
||||||
if (file.getVideo().isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename) }
|
if (file.getVideo().isOwned()) {
|
||||||
|
const downloadName = this.buildDownloadName(file.getVideo(), file)
|
||||||
|
|
||||||
|
return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName }
|
||||||
|
}
|
||||||
|
|
||||||
return this.loadRemoteFile(filename)
|
return this.loadRemoteFile(filename)
|
||||||
}
|
}
|
||||||
|
@ -43,10 +48,14 @@ class VideosTorrentCache extends AbstractVideoStaticFileCache <string> {
|
||||||
|
|
||||||
await doRequestAndSaveToFile(remoteUrl, destPath)
|
await doRequestAndSaveToFile(remoteUrl, destPath)
|
||||||
|
|
||||||
const downloadName = `${video.name}-${file.resolution}p.torrent`
|
const downloadName = this.buildDownloadName(video, file)
|
||||||
|
|
||||||
return { isOwned: false, path: destPath, downloadName }
|
return { isOwned: false, path: destPath, downloadName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildDownloadName (video: MVideo, file: MVideoFile) {
|
||||||
|
return `${video.name}-${file.resolution}p.torrent`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -184,6 +184,32 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerHook({
|
||||||
|
target: 'filter:api.download.torrent.allowed.result',
|
||||||
|
handler: (result, params) => {
|
||||||
|
if (params && params.downloadName.includes('bad torrent')) {
|
||||||
|
return { allowed: false, errorMessage: 'Liu Bei' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
registerHook({
|
||||||
|
target: 'filter:api.download.video.allowed.result',
|
||||||
|
handler: (result, params) => {
|
||||||
|
if (params && !params.streamingPlaylist && params.video.name.includes('bad file')) {
|
||||||
|
return { allowed: false, errorMessage: 'Cao Cao' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params && params.streamingPlaylist && params.video.name.includes('bad playlist file')) {
|
||||||
|
return { allowed: false, errorMessage: 'Sun Jian' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unregister () {
|
async function unregister () {
|
||||||
|
|
|
@ -20,12 +20,14 @@ import {
|
||||||
getVideoThreadComments,
|
getVideoThreadComments,
|
||||||
getVideoWithToken,
|
getVideoWithToken,
|
||||||
installPlugin,
|
installPlugin,
|
||||||
|
makeRawRequest,
|
||||||
registerUser,
|
registerUser,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
setDefaultVideoChannel,
|
setDefaultVideoChannel,
|
||||||
updateCustomSubConfig,
|
updateCustomSubConfig,
|
||||||
updateVideo,
|
updateVideo,
|
||||||
uploadVideo,
|
uploadVideo,
|
||||||
|
uploadVideoAndGetId,
|
||||||
waitJobs
|
waitJobs
|
||||||
} from '../../../shared/extra-utils'
|
} from '../../../shared/extra-utils'
|
||||||
import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers'
|
import { cleanupTests, flushAndRunMultipleServers, ServerInfo } from '../../../shared/extra-utils/server/servers'
|
||||||
|
@ -355,6 +357,67 @@ describe('Test plugin filter hooks', function () {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Download hooks', function () {
|
||||||
|
const downloadVideos: VideoDetails[] = []
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(60000)
|
||||||
|
|
||||||
|
await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
|
||||||
|
transcoding: {
|
||||||
|
webtorrent: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
hls: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const uuids: string[] = []
|
||||||
|
|
||||||
|
for (const name of [ 'bad torrent', 'bad file', 'bad playlist file' ]) {
|
||||||
|
const uuid = (await uploadVideoAndGetId({ server: servers[0], videoName: name })).uuid
|
||||||
|
uuids.push(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitJobs(servers)
|
||||||
|
|
||||||
|
for (const uuid of uuids) {
|
||||||
|
const res = await getVideo(servers[0].url, uuid)
|
||||||
|
downloadVideos.push(res.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should run filter:api.download.torrent.allowed.result', async function () {
|
||||||
|
const res = await makeRawRequest(downloadVideos[0].files[0].torrentDownloadUrl, 403)
|
||||||
|
expect(res.body.error).to.equal('Liu Bei')
|
||||||
|
|
||||||
|
await makeRawRequest(downloadVideos[1].files[0].torrentDownloadUrl, 200)
|
||||||
|
await makeRawRequest(downloadVideos[2].files[0].torrentDownloadUrl, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should run filter:api.download.video.allowed.result', async function () {
|
||||||
|
{
|
||||||
|
const res = await makeRawRequest(downloadVideos[1].files[0].fileDownloadUrl, 403)
|
||||||
|
expect(res.body.error).to.equal('Cao Cao')
|
||||||
|
|
||||||
|
await makeRawRequest(downloadVideos[0].files[0].fileDownloadUrl, 200)
|
||||||
|
await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await makeRawRequest(downloadVideos[2].streamingPlaylists[0].files[0].fileDownloadUrl, 403)
|
||||||
|
expect(res.body.error).to.equal('Sun Jian')
|
||||||
|
|
||||||
|
await makeRawRequest(downloadVideos[2].files[0].fileDownloadUrl, 200)
|
||||||
|
|
||||||
|
await makeRawRequest(downloadVideos[0].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
|
||||||
|
await makeRawRequest(downloadVideos[1].streamingPlaylists[0].files[0].fileDownloadUrl, 200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
after(async function () {
|
after(async function () {
|
||||||
await cleanupTests(servers)
|
await cleanupTests(servers)
|
||||||
})
|
})
|
||||||
|
|
|
@ -85,8 +85,12 @@ export const clientActionHookObject = {
|
||||||
// Fired when the registration page is being initialized
|
// Fired when the registration page is being initialized
|
||||||
'action:signup.register.init': true,
|
'action:signup.register.init': true,
|
||||||
|
|
||||||
|
// Fired when the modal to download a video/caption is shown
|
||||||
|
'action:modal.video-download.shown': true,
|
||||||
|
|
||||||
// ####### Embed hooks #######
|
// ####### Embed hooks #######
|
||||||
// In embed scope, peertube helpers are not available
|
// /!\ In embed scope, peertube helpers are not available
|
||||||
|
// ###########################
|
||||||
|
|
||||||
// Fired when the embed loaded the player
|
// Fired when the embed loaded the player
|
||||||
'action:embed.player.loaded': true
|
'action:embed.player.loaded': true
|
||||||
|
|
|
@ -50,7 +50,11 @@ export const serverFilterHookObject = {
|
||||||
'filter:video.auto-blacklist.result': true,
|
'filter:video.auto-blacklist.result': true,
|
||||||
|
|
||||||
// Filter result used to check if a user can register on the instance
|
// Filter result used to check if a user can register on the instance
|
||||||
'filter:api.user.signup.allowed.result': true
|
'filter:api.user.signup.allowed.result': true,
|
||||||
|
|
||||||
|
// Filter result used to check if video/torrent download is allowed
|
||||||
|
'filter:api.download.video.allowed.result': true,
|
||||||
|
'filter:api.download.torrent.allowed.result': true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerFilterHookName = keyof typeof serverFilterHookObject
|
export type ServerFilterHookName = keyof typeof serverFilterHookObject
|
||||||
|
|
Loading…
Reference in New Issue