From 2539932e16129992a2c0889b4ff527c265a8e2c7 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 27 May 2021 15:59:55 +0200 Subject: Instance homepage support (#4007) * Prepare homepage parsers * Add ability to update instance hompage * Add ability to set homepage as landing page * Add homepage preview in admin * Dynamically update left menu for homepage * Inject home content in homepage * Add videos list and channel miniature custom markup * Remove unused elements in markup service --- server/controllers/api/config.ts | 4 +- server/controllers/api/custom-page.ts | 42 +++ server/controllers/api/index.ts | 2 + server/controllers/api/videos/import.ts | 4 +- server/controllers/static.ts | 10 +- server/helpers/markdown.ts | 8 +- server/initializers/constants.ts | 2 +- server/initializers/database.ts | 4 +- .../migrations/0650-actor-custom-pages.ts | 33 +++ server/lib/client-html.ts | 6 +- server/lib/config.ts | 274 ------------------- server/lib/job-queue/handlers/video-import.ts | 6 +- server/lib/plugins/plugin-helpers-builder.ts | 4 +- server/lib/server-config-manager.ts | 303 +++++++++++++++++++++ server/models/account/actor-custom-page.ts | 69 +++++ server/tests/api/check-params/custom-pages.ts | 81 ++++++ server/tests/api/check-params/index.ts | 1 + server/tests/api/server/homepage.ts | 85 ++++++ server/tests/api/server/index.ts | 1 + server/types/models/account/actor-custom-page.ts | 4 + server/types/models/account/index.ts | 1 + 21 files changed, 648 insertions(+), 296 deletions(-) create mode 100644 server/controllers/api/custom-page.ts create mode 100644 server/initializers/migrations/0650-actor-custom-pages.ts delete mode 100644 server/lib/config.ts create mode 100644 server/lib/server-config-manager.ts create mode 100644 server/models/account/actor-custom-page.ts create mode 100644 server/tests/api/check-params/custom-pages.ts create mode 100644 server/tests/api/server/homepage.ts create mode 100644 server/types/models/account/actor-custom-page.ts (limited to 'server') diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index 5ce7adc35..c9b5c8047 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -1,8 +1,8 @@ +import { ServerConfigManager } from '@server/lib/server-config-manager' import * as express from 'express' import { remove, writeJSON } from 'fs-extra' import { snakeCase } from 'lodash' import validator from 'validator' -import { getServerConfig } from '@server/lib/config' import { UserRight } from '../../../shared' import { About } from '../../../shared/models/server/about.model' import { CustomConfig } from '../../../shared/models/server/custom-config.model' @@ -43,7 +43,7 @@ configRouter.delete('/custom', ) async function getConfig (req: express.Request, res: express.Response) { - const json = await getServerConfig(req.ip) + const json = await ServerConfigManager.Instance.getServerConfig(req.ip) return res.json(json) } diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts new file mode 100644 index 000000000..3c47f7b9a --- /dev/null +++ b/server/controllers/api/custom-page.ts @@ -0,0 +1,42 @@ +import * as express from 'express' +import { ServerConfigManager } from '@server/lib/server-config-manager' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' +import { HttpStatusCode } from '@shared/core-utils' +import { UserRight } from '@shared/models' +import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' + +const customPageRouter = express.Router() + +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.sendStatus(HttpStatusCode.NOT_FOUND_404) + + 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.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts index 7ade1df3a..28378654a 100644 --- a/server/controllers/api/index.ts +++ b/server/controllers/api/index.ts @@ -8,6 +8,7 @@ import { abuseRouter } from './abuse' import { accountsRouter } from './accounts' import { bulkRouter } from './bulk' import { configRouter } from './config' +import { customPageRouter } from './custom-page' import { jobsRouter } from './jobs' import { oauthClientsRouter } from './oauth-clients' import { overviewsRouter } from './overviews' @@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter) apiRouter.use('/search', searchRouter) apiRouter.use('/overviews', overviewsRouter) apiRouter.use('/plugins', pluginRouter) +apiRouter.use('/custom-pages', customPageRouter) apiRouter.use('/ping', pong) apiRouter.use('/*', badRequest) diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts index ee63c7b77..0d5d7a962 100644 --- a/server/controllers/api/videos/import.ts +++ b/server/controllers/api/videos/import.ts @@ -3,7 +3,7 @@ import { move, readFile } from 'fs-extra' import * as magnetUtil from 'magnet-uri' import * as parseTorrent from 'parse-torrent' import { join } from 'path' -import { getEnabledResolutions } from '@server/lib/config' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { setVideoTags } from '@server/lib/video' import { FilteredModelAttributes } from '@server/types' import { @@ -134,7 +134,7 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response) const targetUrl = body.targetUrl const user = res.locals.oauth.token.User - const youtubeDL = new YoutubeDL(targetUrl, getEnabledResolutions('vod')) + const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod')) // Get video infos let youtubeDLInfo: YoutubeDLInfo diff --git a/server/controllers/static.ts b/server/controllers/static.ts index 8a747ec52..3870ebfe9 100644 --- a/server/controllers/static.ts +++ b/server/controllers/static.ts @@ -2,7 +2,7 @@ import * as cors from 'cors' import * as express from 'express' import { join } from 'path' import { serveIndexHTML } from '@server/lib/client-html' -import { getEnabledResolutions, getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model' import { root } from '../helpers/core-utils' @@ -203,10 +203,10 @@ async function generateNodeinfo (req: express.Request, res: express.Response) { } }, plugin: { - registered: getRegisteredPlugins() + registered: ServerConfigManager.Instance.getRegisteredPlugins() }, theme: { - registered: getRegisteredThemes(), + registered: ServerConfigManager.Instance.getRegisteredThemes(), default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) }, email: { @@ -222,13 +222,13 @@ async function generateNodeinfo (req: express.Request, res: express.Response) { webtorrent: { enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED }, - enabledResolutions: getEnabledResolutions('vod') + enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod') }, live: { enabled: CONFIG.LIVE.ENABLED, transcoding: { enabled: CONFIG.LIVE.TRANSCODING.ENABLED, - enabledResolutions: getEnabledResolutions('live') + enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live') } }, import: { diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts index 2126bb752..41e57d857 100644 --- a/server/helpers/markdown.ts +++ b/server/helpers/markdown.ts @@ -1,4 +1,6 @@ -import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils' +import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils' + +const sanitizeOptions = getSanitizeOptions() const sanitizeHtml = require('sanitize-html') const markdownItEmoji = require('markdown-it-emoji/light') @@ -18,7 +20,7 @@ const toSafeHtml = text => { const html = markdownIt.render(textWithLineFeed) // Convert to safe Html - return sanitizeHtml(html, SANITIZE_OPTIONS) + return sanitizeHtml(html, sanitizeOptions) } const mdToPlainText = text => { @@ -28,7 +30,7 @@ const mdToPlainText = text => { const html = markdownIt.render(text) // Convert to safe Html - const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS) + const safeHtml = sanitizeHtml(html, sanitizeOptions) return safeHtml.replace(/<[^>]+>/g, '') .replace(/\n$/, '') diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 4cf7dcf0a..919f9ea6e 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 645 +const LAST_MIGRATION_VERSION = 650 // --------------------------------------------------------------------------- diff --git a/server/initializers/database.ts b/server/initializers/database.ts index 75a13ec8b..38e7a76d0 100644 --- a/server/initializers/database.ts +++ b/server/initializers/database.ts @@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla import { VideoTagModel } from '../models/video/video-tag' import { VideoViewModel } from '../models/video/video-view' import { CONFIG } from './config' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) { ThumbnailModel, TrackerModel, VideoTrackerModel, - PluginModel + PluginModel, + ActorCustomPageModel ]) // Check extensions exist in the database diff --git a/server/initializers/migrations/0650-actor-custom-pages.ts b/server/initializers/migrations/0650-actor-custom-pages.ts new file mode 100644 index 000000000..1338327e8 --- /dev/null +++ b/server/initializers/migrations/0650-actor-custom-pages.ts @@ -0,0 +1,33 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize + db: any +}): Promise { + { + const query = ` + CREATE TABLE IF NOT EXISTS "actorCustomPage" ( + "id" serial, + "content" TEXT, + "type" varchar(255) NOT NULL, + "actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp WITH time zone NOT NULL, + "updatedAt" timestamp WITH time zone NOT NULL, + PRIMARY KEY ("id") + ); + ` + + await utils.sequelize.query(query) + } +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts index 85fdc8754..4b2968e8b 100644 --- a/server/lib/client-html.ts +++ b/server/lib/client-html.ts @@ -26,7 +26,7 @@ import { VideoChannelModel } from '../models/video/video-channel' import { getActivityStreamDuration } from '../models/video/video-format-utils' import { VideoPlaylistModel } from '../models/video/video-playlist' import { MAccountActor, MChannelActor } from '../types/models' -import { getHTMLServerConfig } from './config' +import { ServerConfigManager } from './server-config-manager' type Tags = { ogType: string @@ -211,7 +211,7 @@ class ClientHtml { if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) - const serverConfig = await getHTMLServerConfig() + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() html = await ClientHtml.addAsyncPluginCSS(html) @@ -280,7 +280,7 @@ class ClientHtml { if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] const buffer = await readFile(path) - const serverConfig = await getHTMLServerConfig() + const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig() let html = buffer.toString() diff --git a/server/lib/config.ts b/server/lib/config.ts deleted file mode 100644 index 18d49f05a..000000000 --- a/server/lib/config.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup' -import { getServerCommit } from '@server/helpers/utils' -import { CONFIG, isEmailEnabled } from '@server/initializers/config' -import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants' -import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models' -import { Hooks } from './plugins/hooks' -import { PluginManager } from './plugins/plugin-manager' -import { getThemeOrDefault } from './plugins/theme-utils' -import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' - -async function getServerConfig (ip?: string): Promise { - const { allowed } = await Hooks.wrapPromiseFun( - isSignupAllowed, - { - ip - }, - 'filter:api.user.signup.allowed.result' - ) - - const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) - - const signup = { - allowed, - allowedForCurrentIP, - requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION - } - - const htmlConfig = await getHTMLServerConfig() - - return { ...htmlConfig, signup } -} - -// Config injected in HTML -let serverCommit: string -async function getHTMLServerConfig (): Promise { - if (serverCommit === undefined) serverCommit = await getServerCommit() - - const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) - - return { - instance: { - name: CONFIG.INSTANCE.NAME, - shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, - isNSFW: CONFIG.INSTANCE.IS_NSFW, - defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, - defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, - customizations: { - javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, - css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS - } - }, - 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 - } - }, - plugin: { - registered: getRegisteredPlugins(), - registeredExternalAuths: getExternalAuthsPlugins(), - registeredIdAndPassAuths: getIdAndPassAuthPlugins() - }, - theme: { - registered: getRegisteredThemes(), - default: defaultTheme - }, - email: { - enabled: isEmailEnabled() - }, - contactForm: { - enabled: CONFIG.CONTACT_FORM.ENABLED - }, - serverVersion: PEERTUBE_VERSION, - serverCommit, - transcoding: { - hls: { - enabled: CONFIG.TRANSCODING.HLS.ENABLED - }, - webtorrent: { - enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED - }, - enabledResolutions: getEnabledResolutions('vod'), - profile: CONFIG.TRANSCODING.PROFILE, - availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod') - }, - live: { - enabled: CONFIG.LIVE.ENABLED, - - allowReplay: CONFIG.LIVE.ALLOW_REPLAY, - maxDuration: CONFIG.LIVE.MAX_DURATION, - maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, - maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, - - transcoding: { - enabled: CONFIG.LIVE.TRANSCODING.ENABLED, - enabledResolutions: getEnabledResolutions('live'), - profile: CONFIG.LIVE.TRANSCODING.PROFILE, - availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live') - }, - - rtmp: { - port: CONFIG.LIVE.RTMP.PORT - } - }, - import: { - videos: { - http: { - enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED - }, - torrent: { - enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED - } - } - }, - autoBlacklist: { - videos: { - ofUsers: { - enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED - } - } - }, - avatar: { - file: { - size: { - max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME - } - }, - banner: { - file: { - size: { - max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME - } - }, - video: { - image: { - extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME, - size: { - max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max - } - }, - file: { - extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME - } - }, - videoCaption: { - file: { - size: { - max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max - }, - extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME - } - }, - user: { - videoQuota: CONFIG.USER.VIDEO_QUOTA, - videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY - }, - trending: { - videos: { - intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS, - algorithms: { - enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED, - default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT - } - } - }, - tracker: { - enabled: CONFIG.TRACKER.ENABLED - }, - - followings: { - instance: { - autoFollowIndex: { - 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 - } - } -} - -function getRegisteredThemes () { - return PluginManager.Instance.getRegisteredThemes() - .map(t => ({ - name: t.name, - version: t.version, - description: t.description, - css: t.css, - clientScripts: t.clientScripts - })) -} - -function getRegisteredPlugins () { - return PluginManager.Instance.getRegisteredPlugins() - .map(p => ({ - name: p.name, - version: p.version, - description: p.description, - clientScripts: p.clientScripts - })) -} - -function getEnabledResolutions (type: 'vod' | 'live') { - const transcoding = type === 'vod' - ? CONFIG.TRANSCODING - : CONFIG.LIVE.TRANSCODING - - return Object.keys(transcoding.RESOLUTIONS) - .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true) - .map(r => parseInt(r, 10)) -} - -// --------------------------------------------------------------------------- - -export { - getServerConfig, - getRegisteredThemes, - getEnabledResolutions, - getRegisteredPlugins, - getHTMLServerConfig -} - -// --------------------------------------------------------------------------- - -function getIdAndPassAuthPlugins () { - const result: RegisteredIdAndPassAuthConfig[] = [] - - for (const p of PluginManager.Instance.getIdAndPassAuths()) { - for (const auth of p.idAndPassAuths) { - result.push({ - npmName: p.npmName, - name: p.name, - version: p.version, - authName: auth.authName, - weight: auth.getWeight() - }) - } - } - - return result -} - -function getExternalAuthsPlugins () { - const result: RegisteredExternalAuthConfig[] = [] - - for (const p of PluginManager.Instance.getExternalAuths()) { - for (const auth of p.externalAuths) { - result.push({ - npmName: p.npmName, - name: p.name, - version: p.version, - authName: auth.authName, - authDisplayName: auth.authDisplayName() - }) - } - } - - return result -} diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts index 3067ce214..d71053e87 100644 --- a/server/lib/job-queue/handlers/video-import.ts +++ b/server/lib/job-queue/handlers/video-import.ts @@ -2,8 +2,10 @@ import * as Bull from 'bull' import { move, remove, stat } from 'fs-extra' import { extname } from 'path' import { retryTransactionWrapper } from '@server/helpers/database-utils' +import { YoutubeDL } from '@server/helpers/youtube-dl' import { isPostImportVideoAccepted } from '@server/lib/moderation' import { Hooks } from '@server/lib/plugins/hooks' +import { ServerConfigManager } from '@server/lib/server-config-manager' import { isAbleToUploadVideo } from '@server/lib/user' import { addOptimizeOrMergeAudioJob } from '@server/lib/video' import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' @@ -33,8 +35,6 @@ import { MThumbnail } from '../../../types/models/video/thumbnail' import { federateVideoIfNeeded } from '../../activitypub/videos' import { Notifier } from '../../notifier' import { generateVideoMiniature } from '../../thumbnail' -import { YoutubeDL } from '@server/helpers/youtube-dl' -import { getEnabledResolutions } from '@server/lib/config' async function processVideoImport (job: Bull.Job) { const payload = job.data as VideoImportPayload @@ -76,7 +76,7 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub videoImportId: videoImport.id } - const youtubeDL = new YoutubeDL(videoImport.targetUrl, getEnabledResolutions('vod')) + const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod')) return processFile( () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT), diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts index cb1cd4d9a..8487672ba 100644 --- a/server/lib/plugins/plugin-helpers-builder.ts +++ b/server/lib/plugins/plugin-helpers-builder.ts @@ -15,7 +15,7 @@ import { MPlugin } from '@server/types/models' import { PeerTubeHelpers } from '@server/types/plugins' import { VideoBlacklistCreate } from '@shared/models' import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' -import { getServerConfig } from '../config' +import { ServerConfigManager } from '../server-config-manager' import { blacklistVideo, unblacklistVideo } from '../video-blacklist' import { UserModel } from '@server/models/user/user' @@ -147,7 +147,7 @@ function buildConfigHelpers () { }, getServerConfig () { - return getServerConfig() + return ServerConfigManager.Instance.getServerConfig() } } } diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts new file mode 100644 index 000000000..1aff6f446 --- /dev/null +++ b/server/lib/server-config-manager.ts @@ -0,0 +1,303 @@ +import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup' +import { getServerCommit } from '@server/helpers/utils' +import { CONFIG, isEmailEnabled } from '@server/initializers/config' +import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants' +import { ActorCustomPageModel } from '@server/models/account/actor-custom-page' +import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models' +import { Hooks } from './plugins/hooks' +import { PluginManager } from './plugins/plugin-manager' +import { getThemeOrDefault } from './plugins/theme-utils' +import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles' + +/** + * + * Used to send the server config to clients (using REST/API or plugins API) + * We need a singleton class to manage config state depending on external events (to build menu entries etc) + * + */ + +class ServerConfigManager { + + private static instance: ServerConfigManager + + private serverCommit: string + + private homepageEnabled = false + + private constructor () {} + + async init () { + const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage() + + this.updateHomepageState(instanceHomepage?.content) + } + + updateHomepageState (content: string) { + this.homepageEnabled = !!content + } + + async getHTMLServerConfig (): Promise { + if (this.serverCommit === undefined) this.serverCommit = await getServerCommit() + + const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) + + return { + instance: { + name: CONFIG.INSTANCE.NAME, + shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, + isNSFW: CONFIG.INSTANCE.IS_NSFW, + defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY, + defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE, + customizations: { + javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, + css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS + } + }, + 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 + } + }, + plugin: { + registered: this.getRegisteredPlugins(), + registeredExternalAuths: this.getExternalAuthsPlugins(), + registeredIdAndPassAuths: this.getIdAndPassAuthPlugins() + }, + theme: { + registered: this.getRegisteredThemes(), + default: defaultTheme + }, + email: { + enabled: isEmailEnabled() + }, + contactForm: { + enabled: CONFIG.CONTACT_FORM.ENABLED + }, + serverVersion: PEERTUBE_VERSION, + serverCommit: this.serverCommit, + transcoding: { + hls: { + enabled: CONFIG.TRANSCODING.HLS.ENABLED + }, + webtorrent: { + enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED + }, + enabledResolutions: this.getEnabledResolutions('vod'), + profile: CONFIG.TRANSCODING.PROFILE, + availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod') + }, + live: { + enabled: CONFIG.LIVE.ENABLED, + + allowReplay: CONFIG.LIVE.ALLOW_REPLAY, + maxDuration: CONFIG.LIVE.MAX_DURATION, + maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, + maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, + + transcoding: { + enabled: CONFIG.LIVE.TRANSCODING.ENABLED, + enabledResolutions: this.getEnabledResolutions('live'), + profile: CONFIG.LIVE.TRANSCODING.PROFILE, + availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live') + }, + + rtmp: { + port: CONFIG.LIVE.RTMP.PORT + } + }, + import: { + videos: { + http: { + enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED + }, + torrent: { + enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED + } + } + }, + avatar: { + file: { + size: { + max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME + } + }, + banner: { + file: { + size: { + max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME + } + }, + video: { + image: { + extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME, + size: { + max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max + } + }, + file: { + extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME + } + }, + videoCaption: { + file: { + size: { + max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max + }, + extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME + } + }, + user: { + videoQuota: CONFIG.USER.VIDEO_QUOTA, + videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY + }, + trending: { + videos: { + intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS, + algorithms: { + enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED, + default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT + } + } + }, + tracker: { + enabled: CONFIG.TRACKER.ENABLED + }, + + followings: { + instance: { + autoFollowIndex: { + 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 + }, + + homepage: { + enabled: this.homepageEnabled + } + } + } + + async getServerConfig (ip?: string): Promise { + const { allowed } = await Hooks.wrapPromiseFun( + isSignupAllowed, + { + ip + }, + 'filter:api.user.signup.allowed.result' + ) + + const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip) + + const signup = { + allowed, + allowedForCurrentIP, + requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION + } + + const htmlConfig = await this.getHTMLServerConfig() + + return { ...htmlConfig, signup } + } + + getRegisteredThemes () { + return PluginManager.Instance.getRegisteredThemes() + .map(t => ({ + name: t.name, + version: t.version, + description: t.description, + css: t.css, + clientScripts: t.clientScripts + })) + } + + getRegisteredPlugins () { + return PluginManager.Instance.getRegisteredPlugins() + .map(p => ({ + name: p.name, + version: p.version, + description: p.description, + clientScripts: p.clientScripts + })) + } + + getEnabledResolutions (type: 'vod' | 'live') { + const transcoding = type === 'vod' + ? CONFIG.TRANSCODING + : CONFIG.LIVE.TRANSCODING + + return Object.keys(transcoding.RESOLUTIONS) + .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true) + .map(r => parseInt(r, 10)) + } + + private getIdAndPassAuthPlugins () { + const result: RegisteredIdAndPassAuthConfig[] = [] + + for (const p of PluginManager.Instance.getIdAndPassAuths()) { + for (const auth of p.idAndPassAuths) { + result.push({ + npmName: p.npmName, + name: p.name, + version: p.version, + authName: auth.authName, + weight: auth.getWeight() + }) + } + } + + return result + } + + private getExternalAuthsPlugins () { + const result: RegisteredExternalAuthConfig[] = [] + + for (const p of PluginManager.Instance.getExternalAuths()) { + for (const auth of p.externalAuths) { + result.push({ + npmName: p.npmName, + name: p.name, + version: p.version, + authName: auth.authName, + authDisplayName: auth.authDisplayName() + }) + } + } + + return result + } + + static get Instance () { + return this.instance || (this.instance = new this()) + } +} + +// --------------------------------------------------------------------------- + +export { + ServerConfigManager +} diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts new file mode 100644 index 000000000..893023181 --- /dev/null +++ b/server/models/account/actor-custom-page.ts @@ -0,0 +1,69 @@ +import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' +import { CustomPage } from '@shared/models' +import { ActorModel } from '../actor/actor' +import { getServerActor } from '../application/application' + +@Table({ + tableName: 'actorCustomPage', + indexes: [ + { + fields: [ 'actorId', 'type' ], + unique: true + } + ] +}) +export class ActorCustomPageModel extends Model { + + @AllowNull(true) + @Column(DataType.TEXT) + content: string + + @AllowNull(false) + @Column + type: 'homepage' + + @CreatedAt + createdAt: Date + + @UpdatedAt + updatedAt: Date + + @ForeignKey(() => ActorModel) + @Column + actorId: number + + @BelongsTo(() => ActorModel, { + foreignKey: { + name: 'actorId', + allowNull: false + }, + onDelete: 'cascade' + }) + Actor: ActorModel + + static async updateInstanceHomepage (content: string) { + const serverActor = await getServerActor() + + return ActorCustomPageModel.upsert({ + content, + actorId: serverActor.id, + type: 'homepage' + }) + } + + static async loadInstanceHomepage () { + const serverActor = await getServerActor() + + return ActorCustomPageModel.findOne({ + where: { + actorId: serverActor.id + } + }) + } + + toFormattedJSON (): CustomPage { + return { + content: this.content + } + } +} diff --git a/server/tests/api/check-params/custom-pages.ts b/server/tests/api/check-params/custom-pages.ts new file mode 100644 index 000000000..74ca3384c --- /dev/null +++ b/server/tests/api/check-params/custom-pages.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' +import { + cleanupTests, + createUser, + flushAndRunServer, + ServerInfo, + setAccessTokensToServers, + userLogin +} from '../../../../shared/extra-utils' +import { makeGetRequest, makePutBodyRequest } from '../../../../shared/extra-utils/requests/requests' + +describe('Test custom pages validators', function () { + const path = '/api/v1/custom-pages/homepage/instance' + + let server: ServerInfo + let userAccessToken: string + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(120000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + + const user = { username: 'user1', password: 'password' } + await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password }) + + userAccessToken = await userLogin(server, user) + }) + + describe('When updating instance homepage', function () { + + it('Should fail with an unauthenticated user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: userAccessToken, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should succeed with the correct params', async function () { + await makePutBodyRequest({ + url: server.url, + path, + token: server.accessToken, + fields: { content: 'super content' }, + statusCodeExpected: HttpStatusCode.NO_CONTENT_204 + }) + }) + }) + + describe('When getting instance homapage', function () { + + it('Should succeed with the correct params', async function () { + await makeGetRequest({ + url: server.url, + path, + statusCodeExpected: HttpStatusCode.OK_200 + }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts index 143515838..ce2335e42 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -3,6 +3,7 @@ import './accounts' import './blocklist' import './bulk' import './config' +import './custom-pages' import './contact-form' import './debug' import './follows' diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts new file mode 100644 index 000000000..e8ba89ca6 --- /dev/null +++ b/server/tests/api/server/homepage.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import * as chai from 'chai' +import { HttpStatusCode } from '@shared/core-utils' +import { CustomPage, ServerConfig } from '@shared/models' +import { + cleanupTests, + flushAndRunServer, + getConfig, + getInstanceHomepage, + killallServers, + reRunServer, + ServerInfo, + setAccessTokensToServers, + updateInstanceHomepage +} from '../../../../shared/extra-utils/index' + +const expect = chai.expect + +async function getHomepageState (server: ServerInfo) { + const res = await getConfig(server.url) + + const config = res.body as ServerConfig + return config.homepage.enabled +} + +describe('Test instance homepage actions', function () { + let server: ServerInfo + + before(async function () { + this.timeout(30000) + + server = await flushAndRunServer(1) + await setAccessTokensToServers([ server ]) + }) + + it('Should not have a homepage', async function () { + const state = await getHomepageState(server) + expect(state).to.be.false + + await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404) + }) + + it('Should set a homepage', async function () { + await updateInstanceHomepage(server.url, server.accessToken, '') + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should have the same homepage after a restart', async function () { + this.timeout(30000) + + killallServers([ server ]) + + await reRunServer(server) + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.equal('') + + const state = await getHomepageState(server) + expect(state).to.be.true + }) + + it('Should empty the homepage', async function () { + await updateInstanceHomepage(server.url, server.accessToken, '') + + const res = await getInstanceHomepage(server.url) + const page: CustomPage = res.body + expect(page.content).to.be.empty + + const state = await getHomepageState(server) + expect(state).to.be.false + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts index be743973a..56e6eb5da 100644 --- a/server/tests/api/server/index.ts +++ b/server/tests/api/server/index.ts @@ -5,6 +5,7 @@ import './email' import './follow-constraints' import './follows' import './follows-moderation' +import './homepage' import './handle-down' import './jobs' import './logs' diff --git a/server/types/models/account/actor-custom-page.ts b/server/types/models/account/actor-custom-page.ts new file mode 100644 index 000000000..2cb8aa7e4 --- /dev/null +++ b/server/types/models/account/actor-custom-page.ts @@ -0,0 +1,4 @@ + +import { ActorCustomPageModel } from '../../../models/account/actor-custom-page' + +export type MActorCustomPage = Omit diff --git a/server/types/models/account/index.ts b/server/types/models/account/index.ts index dab2eea7e..9679c01e4 100644 --- a/server/types/models/account/index.ts +++ b/server/types/models/account/index.ts @@ -1,2 +1,3 @@ export * from './account' +export * from './actor-custom-page' export * from './account-blocklist' -- cgit v1.2.3