diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 3ceea02ca..6ae7b1b79 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html @@ -63,7 +63,7 @@
-
+
There are errors in the form:
    @@ -77,7 +77,11 @@ You cannot allow live replay if you don't enable transcoding. - + + You cannot change the server configuration because it's managed externally. + + +
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss index 5951d0aaa..0458d257f 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss @@ -33,6 +33,11 @@ input[type=number] { top: 5px; right: 2.5rem; } + + input[disabled] { + background-color: #f9f9f9; + pointer-events: none; + } } input[type=checkbox] { @@ -93,6 +98,11 @@ textarea { } } +input[disabled] { + opacity: 0.5; +} + + .form-group-right { padding-top: 2px; } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index f13fe4bf9..04b0175a7 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -258,6 +258,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { this.loadConfigAndUpdateForm() this.loadCategoriesAndLanguages() + if (!this.serverConfig.allowEdits) { + this.form.disable() + } } formValidated () { diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html index 6e70e2f37..a460cb9b7 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.html +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html @@ -2,6 +2,7 @@ @@ -25,11 +26,11 @@
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts index 80ca6690f..dcb5d20da 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts @@ -45,6 +45,7 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { previewHTML: SafeHtml | string = '' isMaximized = false + disabled = false maximizeInText = $localize`Maximize editor` maximizeOutText = $localize`Exit maximized editor` @@ -108,6 +109,10 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { } } + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } + private lockBodyScroll () { this.scrollPosition = this.viewportScroller.getScrollPosition() document.getElementById('content').classList.add('lock-scroll') diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.html b/client/src/app/shared/shared-forms/select/select-checkbox.component.html index 7b49a0c01..03db2875b 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.html +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.html @@ -7,6 +7,7 @@ [multiple]="true" [searchable]="true" [closeOnSelect]="false" + [disabled]="disabled" bindValue="id" bindLabel="label" diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts index 12f697628..c9a500324 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts @@ -23,6 +23,8 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { @Input() selectableGroupAsModel: boolean @Input() placeholder: string + disabled = false + ngOnInit () { if (!this.placeholder) this.placeholder = $localize`Add a new option` } @@ -59,6 +61,10 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { this.propagateChange(this.selectedItems) } + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } + compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { if (typeof selected === 'string' || typeof selected === 'number') { return item.id === selected diff --git a/client/src/app/shared/shared-forms/select/select-custom-value.component.html b/client/src/app/shared/shared-forms/select/select-custom-value.component.html index 9dc8c2ec2..69fdedc10 100644 --- a/client/src/app/shared/shared-forms/select/select-custom-value.component.html +++ b/client/src/app/shared/shared-forms/select/select-custom-value.component.html @@ -5,6 +5,7 @@ [searchable]="searchable" [groupBy]="groupBy" [labelForId]="labelForId" + [disabled]="disabled" [(ngModel)]="selectedId" (ngModelChange)="onModelChange()" diff --git a/client/src/app/shared/shared-forms/select/select-custom-value.component.ts b/client/src/app/shared/shared-forms/select/select-custom-value.component.ts index bc6b863c7..636bd6101 100644 --- a/client/src/app/shared/shared-forms/select/select-custom-value.component.ts +++ b/client/src/app/shared/shared-forms/select/select-custom-value.component.ts @@ -25,6 +25,7 @@ export class SelectCustomValueComponent implements ControlValueAccessor, OnChang customValue: number | string = '' selectedId: number | string + disabled = false itemsWithCustom: SelectOptionsItem[] = [] @@ -75,4 +76,8 @@ export class SelectCustomValueComponent implements ControlValueAccessor, OnChang isCustomValue () { return this.selectedId === 'other' } + + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } } diff --git a/client/src/app/shared/shared-forms/select/select-options.component.html b/client/src/app/shared/shared-forms/select/select-options.component.html index 3b1761255..83c7de9f5 100644 --- a/client/src/app/shared/shared-forms/select/select-options.component.html +++ b/client/src/app/shared/shared-forms/select/select-options.component.html @@ -7,6 +7,7 @@ [labelForId]="labelForId" [searchable]="searchable" [searchFn]="searchFn" + [disabled]="disabled" bindLabel="label" bindValue="id" diff --git a/client/src/app/shared/shared-forms/select/select-options.component.ts b/client/src/app/shared/shared-forms/select/select-options.component.ts index 8482b9dea..820a82c24 100644 --- a/client/src/app/shared/shared-forms/select/select-options.component.ts +++ b/client/src/app/shared/shared-forms/select/select-options.component.ts @@ -23,6 +23,7 @@ export class SelectOptionsComponent implements ControlValueAccessor { @Input() searchFn: any selectedId: number | string + disabled = false propagateChange = (_: any) => { /* empty */ } @@ -48,4 +49,8 @@ export class SelectOptionsComponent implements ControlValueAccessor { onModelChange () { this.propagateChange(this.selectedId) } + + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } } diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 9f6d69131..679c235a6 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -364,6 +364,9 @@ cursor: default; } } + select[disabled] { + background-color: #f9f9f9; + } @media screen and (max-width: $width) { width: 100%; diff --git a/config/default.yaml b/config/default.yaml index 3865ab5cf..eb96b6bbb 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -243,6 +243,11 @@ peertube: # You can use a custom URL if your want, that respect the format behind https://joinpeertube.org/api/v1/versions.json url: 'https://joinpeertube.org/api/v1/versions.json' +webadmin: + configuration: + edit: + allowed: true + cache: previews: size: 500 # Max number of previews you want to cache diff --git a/config/production.yaml.example b/config/production.yaml.example index 94238fad0..082c75e53 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -241,6 +241,11 @@ peertube: # You can use a custom URL if your want, that respect the format behind https://joinpeertube.org/api/v1/versions.json url: 'https://joinpeertube.org/api/v1/versions.json' +webadmin: + configuration: + # Set to false if you want the config to be readonly + allow_edits: true + ############################################################################### # # From this point, all the following keys can be overridden by the web interface diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts index d542f62aa..5ea1f67c9 100644 --- a/server/controllers/api/config.ts +++ b/server/controllers/api/config.ts @@ -11,7 +11,7 @@ import { objectConverter } from '../../helpers/core-utils' import { CONFIG, reloadConfig } from '../../initializers/config' import { ClientHtml } from '../../lib/client-html' import { asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares' -import { customConfigUpdateValidator } from '../../middlewares/validators/config' +import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config' const configRouter = express.Router() @@ -38,6 +38,7 @@ configRouter.put('/custom', openapiOperationDoc({ operationId: 'putCustomConfig' }), authenticate, ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), + ensureConfigIsEditable, customConfigUpdateValidator, asyncMiddleware(updateCustomConfig) ) @@ -46,6 +47,7 @@ configRouter.delete('/custom', openapiOperationDoc({ operationId: 'delCustomConfig' }), authenticate, ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), + ensureConfigIsEditable, asyncMiddleware(deleteCustomConfig) ) diff --git a/server/initializers/config.ts b/server/initializers/config.ts index be9fc61f0..b2a8e9e19 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -195,6 +195,13 @@ const CONFIG = { URL: config.get('peertube.check_latest_version.url') } }, + WEBADMIN: { + CONFIGURATION: { + EDITS: { + ALLOWED: config.get('webadmin.configuration.edit.allowed') + } + } + }, ADMIN: { get EMAIL () { return config.get('admin.email') } }, @@ -411,14 +418,22 @@ export { // --------------------------------------------------------------------------- function getLocalConfigFilePath () { - const configSources = config.util.getConfigSources() - if (configSources.length === 0) throw new Error('Invalid config source.') + const localConfigDir = getLocalConfigDir() let filename = 'local' if (process.env.NODE_ENV) filename += `-${process.env.NODE_ENV}` if (process.env.NODE_APP_INSTANCE) filename += `-${process.env.NODE_APP_INSTANCE}` - return join(dirname(configSources[0].name), filename + '.json') + return join(localConfigDir, filename + '.json') +} + +function getLocalConfigDir () { + if (process.env.PEERTUBE_LOCAL_CONFIG) return process.env.PEERTUBE_LOCAL_CONFIG + + const configSources = config.util.getConfigSources() + if (configSources.length === 0) throw new Error('Invalid config source.') + + return dirname(configSources[0].name) } function buildVideosRedundancy (objs: any[]): VideosRedundancyStrategy[] { @@ -437,19 +452,19 @@ function buildVideosRedundancy (objs: any[]): VideosRedundancyStrategy[] { export function reloadConfig () { - function getConfigDirectory () { + function getConfigDirectories () { if (process.env.NODE_CONFIG_DIR) { - return process.env.NODE_CONFIG_DIR + return process.env.NODE_CONFIG_DIR.split(":") } - return join(root(), 'config') + return [ join(root(), 'config') ] } function purge () { - const directory = getConfigDirectory() + const directories = getConfigDirectories() for (const fileName in require.cache) { - if (fileName.includes(directory) === false) { + if (directories.some((dir) => fileName.includes(dir)) === false) { continue } diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts index 80d87a9d3..358f47133 100644 --- a/server/lib/server-config-manager.ts +++ b/server/lib/server-config-manager.ts @@ -42,6 +42,7 @@ class ServerConfigManager { const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) return { + allowEdits: CONFIG.WEBADMIN.CONFIGURATION.EDITS.ALLOWED, instance: { name: CONFIG.INSTANCE.NAME, shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index 16a840667..5f1ac89bc 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts @@ -1,13 +1,14 @@ import express from 'express' import { body } from 'express-validator' import { isIntOrNull } from '@server/helpers/custom-validators/misc' -import { isEmailEnabled } from '@server/initializers/config' +import { CONFIG, isEmailEnabled } from '@server/initializers/config' import { CustomConfig } from '../../../shared/models/server/custom-config.model' import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' import { logger } from '../../helpers/logger' import { isThemeRegistered } from '../../lib/plugins/theme-utils' import { areValidationErrors } from './shared' +import { HttpStatusCode } from '@shared/models/http/http-error-codes' const customConfigUpdateValidator = [ body('instance.name').exists().withMessage('Should have a valid instance name'), @@ -104,10 +105,21 @@ const customConfigUpdateValidator = [ } ] +function ensureConfigIsEditable (req: express.Request, res: express.Response, next: express.NextFunction) { + if (!CONFIG.WEBADMIN.CONFIGURATION.EDITS.ALLOWED) { + return res.fail({ + status: HttpStatusCode.METHOD_NOT_ALLOWED_405, + message: 'Server configuration is static and cannot be edited' + }) + } + return next() +} + // --------------------------------------------------------------------------- export { - customConfigUpdateValidator + customConfigUpdateValidator, + ensureConfigIsEditable } function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: express.Response) { diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts index c4dd882b8..e057ec1a2 100644 --- a/server/tests/api/server/config.ts +++ b/server/tests/api/server/config.ts @@ -201,6 +201,199 @@ function checkUpdatedConfig (data: CustomConfig) { expect(data.broadcastMessage.dismissable).to.be.true } +const newCustomConfig: CustomConfig = { + instance: { + name: 'PeerTube updated', + shortDescription: 'my short description', + description: 'my super description', + terms: 'my super terms', + codeOfConduct: 'my super coc', + + creationReason: 'my super creation reason', + moderationInformation: 'my super moderation information', + administrator: 'Kuja', + maintenanceLifetime: 'forever', + businessModel: 'my super business model', + hardwareInformation: '2vCore 3GB RAM', + + languages: [ 'en', 'es' ], + categories: [ 1, 2 ], + + isNSFW: true, + defaultNSFWPolicy: 'blur' as 'blur', + + defaultClientRoute: '/videos/recently-added', + + customizations: { + javascript: 'alert("coucou")', + css: 'body { background-color: red; }' + } + }, + theme: { + default: 'default' + }, + services: { + twitter: { + username: '@Kuja', + whitelisted: true + } + }, + cache: { + previews: { + size: 2 + }, + captions: { + size: 3 + }, + torrents: { + size: 4 + } + }, + signup: { + enabled: false, + limit: 5, + requiresEmailVerification: false, + minimumAge: 10 + }, + admin: { + email: 'superadmin1@example.com' + }, + contactForm: { + enabled: false + }, + user: { + videoQuota: 5242881, + videoQuotaDaily: 318742 + }, + transcoding: { + enabled: true, + allowAdditionalExtensions: true, + allowAudioFiles: true, + threads: 1, + concurrency: 3, + profile: 'vod_profile', + resolutions: { + '0p': false, + '240p': false, + '360p': true, + '480p': true, + '720p': false, + '1080p': false, + '1440p': false, + '2160p': false + }, + webtorrent: { + enabled: true + }, + hls: { + enabled: false + } + }, + live: { + enabled: true, + allowReplay: true, + maxDuration: 5000, + maxInstanceLives: -1, + maxUserLives: 10, + transcoding: { + enabled: true, + threads: 4, + profile: 'live_profile', + resolutions: { + '240p': true, + '360p': true, + '480p': true, + '720p': true, + '1080p': true, + '1440p': true, + '2160p': true + } + } + }, + import: { + videos: { + concurrency: 4, + http: { + enabled: false + }, + torrent: { + enabled: false + } + } + }, + trending: { + videos: { + algorithms: { + enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], + default: 'hot' + } + } + }, + autoBlacklist: { + videos: { + ofUsers: { + enabled: true + } + } + }, + followers: { + instance: { + enabled: false, + manualApproval: true + } + }, + followings: { + instance: { + autoFollowBack: { + enabled: true + }, + autoFollowIndex: { + enabled: true, + indexUrl: 'https://updated.example.com' + } + } + }, + broadcastMessage: { + enabled: true, + level: 'error', + message: 'super bad message', + dismissable: true + }, + search: { + remoteUri: { + anonymous: true, + users: true + }, + searchIndex: { + enabled: true, + url: 'https://search.joinpeertube.org', + disableLocalSearch: true, + isDefaultSearch: true + } + } +} + +describe('Test static config', function () { + let server: PeerTubeServer = null + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { webadmin: { configuration: { edit: { allowed: false } } } }) + await setAccessTokensToServers([ server ]) + }) + + it('Should tell the client that edits are not allowed', async function () { + const data = await server.config.getConfig() + + expect(data.allowEdits).to.be.false + }) + + it('Should error when client tries to update', async function () { + await server.config.updateCustomConfig({ newCustomConfig, expectedStatus: 405 }) + }) +}) + describe('Test config', function () { let server: PeerTubeServer = null @@ -252,177 +445,6 @@ describe('Test config', function () { }) it('Should update the customized configuration', async function () { - const newCustomConfig: CustomConfig = { - instance: { - name: 'PeerTube updated', - shortDescription: 'my short description', - description: 'my super description', - terms: 'my super terms', - codeOfConduct: 'my super coc', - - creationReason: 'my super creation reason', - moderationInformation: 'my super moderation information', - administrator: 'Kuja', - maintenanceLifetime: 'forever', - businessModel: 'my super business model', - hardwareInformation: '2vCore 3GB RAM', - - languages: [ 'en', 'es' ], - categories: [ 1, 2 ], - - isNSFW: true, - defaultNSFWPolicy: 'blur' as 'blur', - - defaultClientRoute: '/videos/recently-added', - - customizations: { - javascript: 'alert("coucou")', - css: 'body { background-color: red; }' - } - }, - theme: { - default: 'default' - }, - services: { - twitter: { - username: '@Kuja', - whitelisted: true - } - }, - cache: { - previews: { - size: 2 - }, - captions: { - size: 3 - }, - torrents: { - size: 4 - } - }, - signup: { - enabled: false, - limit: 5, - requiresEmailVerification: false, - minimumAge: 10 - }, - admin: { - email: 'superadmin1@example.com' - }, - contactForm: { - enabled: false - }, - user: { - videoQuota: 5242881, - videoQuotaDaily: 318742 - }, - transcoding: { - enabled: true, - allowAdditionalExtensions: true, - allowAudioFiles: true, - threads: 1, - concurrency: 3, - profile: 'vod_profile', - resolutions: { - '0p': false, - '240p': false, - '360p': true, - '480p': true, - '720p': false, - '1080p': false, - '1440p': false, - '2160p': false - }, - webtorrent: { - enabled: true - }, - hls: { - enabled: false - } - }, - live: { - enabled: true, - allowReplay: true, - maxDuration: 5000, - maxInstanceLives: -1, - maxUserLives: 10, - transcoding: { - enabled: true, - threads: 4, - profile: 'live_profile', - resolutions: { - '240p': true, - '360p': true, - '480p': true, - '720p': true, - '1080p': true, - '1440p': true, - '2160p': true - } - } - }, - import: { - videos: { - concurrency: 4, - http: { - enabled: false - }, - torrent: { - enabled: false - } - } - }, - trending: { - videos: { - algorithms: { - enabled: [ 'best', 'hot', 'most-viewed', 'most-liked' ], - default: 'hot' - } - } - }, - autoBlacklist: { - videos: { - ofUsers: { - enabled: true - } - } - }, - followers: { - instance: { - enabled: false, - manualApproval: true - } - }, - followings: { - instance: { - autoFollowBack: { - enabled: true - }, - autoFollowIndex: { - enabled: true, - indexUrl: 'https://updated.example.com' - } - } - }, - broadcastMessage: { - enabled: true, - level: 'error', - message: 'super bad message', - dismissable: true - }, - search: { - remoteUri: { - anonymous: true, - users: true - }, - searchIndex: { - enabled: true, - url: 'https://search.joinpeertube.org', - disableLocalSearch: true, - isDefaultSearch: true - } - } - } await server.config.updateCustomConfig({ newCustomConfig }) const data = await server.config.getCustomConfig() diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts index 585e99aca..3b026e3a5 100644 --- a/shared/models/server/server-config.model.ts +++ b/shared/models/server/server-config.model.ts @@ -30,6 +30,7 @@ export interface RegisteredIdAndPassAuthConfig { } export interface ServerConfig { + allowEdits: boolean serverVersion: string serverCommit?: string diff --git a/support/docker/production/Dockerfile.buster b/support/docker/production/Dockerfile.buster index 2ff0591f9..163c514f5 100644 --- a/support/docker/production/Dockerfile.buster +++ b/support/docker/production/Dockerfile.buster @@ -33,7 +33,8 @@ RUN mkdir /data /config RUN chown -R peertube:peertube /data /config ENV NODE_ENV production -ENV NODE_CONFIG_DIR /config +ENV NODE_CONFIG_DIR /app/config:/app/support/docker/production/config:/config +ENV PEERTUBE_LOCAL_CONFIG /config VOLUME /data VOLUME /config diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml index 1b474582a..7c430a995 100644 --- a/support/docker/production/config/custom-environment-variables.yaml +++ b/support/docker/production/config/custom-environment-variables.yaml @@ -68,6 +68,13 @@ object_storage: prefix: "PEERTUBE_OBJECT_STORAGE_VIDEOS_PREFIX" base_url: "PEERTUBE_OBJECT_STORAGE_VIDEOS_BASE_URL" +webadmin: + configuration: + edit: + allowed: + __name: "PEERTUBE_ALLOW_WEBADMIN_CONFIG" + __format: "json" + log: level: "PEERTUBE_LOG_LEVEL" log_ping_requests: diff --git a/support/docker/production/entrypoint.sh b/support/docker/production/entrypoint.sh index 7dd626b9f..261055e84 100755 --- a/support/docker/production/entrypoint.sh +++ b/support/docker/production/entrypoint.sh @@ -1,15 +1,8 @@ #!/bin/sh set -e -# Populate config directory -if [ -z "$(ls -A /config)" ]; then - cp /app/support/docker/production/config/* /config -fi -# Always copy default and custom env configuration file, in cases where new keys were added -cp /app/config/default.yaml /config -cp /app/support/docker/production/config/custom-environment-variables.yaml /config -find /config ! -user peertube -exec chown peertube:peertube {} \; +find /config ! -user peertube -exec chown peertube:peertube {} \; || true # first arg is `-f` or `--some-option` # or first arg is `something.conf`