Unverified Commit 8d8a037e authored by Jelle Besseling's avatar Jelle Besseling Committed by GitHub
Browse files

Allow configuration to be static/readonly (#4315)

* Allow configuration to be static/readonly

* Make all components disableable

* Improve disabled component styling

* Rename edits allowed field in configuration

* Fix CI
parent badacdbb
......@@ -63,7 +63,7 @@
<div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5">
<div class="form-error submit-error" i18n *ngIf="!form.valid">
<div class="form-error submit-error" i18n *ngIf="!form.valid && serverConfig.allowEdits">
There are errors in the form:
<ul>
......@@ -77,7 +77,11 @@
You cannot allow live replay if you don't enable transcoding.
</span>
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions()">
<span i18n *ngIf="!serverConfig.allowEdits">
You cannot change the server configuration because it's managed externally.
</span>
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions() || !serverConfig.allowEdits">
</div>
</div>
</form>
......@@ -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;
}
......
......@@ -258,6 +258,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
this.loadConfigAndUpdateForm()
this.loadCategoriesAndLanguages()
if (!this.serverConfig.allowEdits) {
this.form.disable()
}
}
formValidated () {
......
......@@ -2,6 +2,7 @@
<textarea #textarea
[(ngModel)]="content" (ngModelChange)="onModelChange()"
class="form-control" [ngClass]="classes"
[attr.disabled]="disabled"
[ngStyle]="{ height: textareaHeight }"
[id]="name" [name]="name">
</textarea>
......@@ -25,11 +26,11 @@
</ng-container>
<my-button
*ngIf="!isMaximized" [title]="maximizeInText" className="maximize-button" icon="fullscreen" (click)="onMaximizeClick()"
*ngIf="!isMaximized" [title]="maximizeInText" className="maximize-button" icon="fullscreen" (click)="onMaximizeClick()" [disabled]="disabled"
></my-button>
<my-button
*ngIf="isMaximized" [title]="maximizeOutText" className="maximize-button" icon="exit-fullscreen" (click)="onMaximizeClick()"
*ngIf="isMaximized" [title]="maximizeOutText" className="maximize-button" icon="exit-fullscreen" (click)="onMaximizeClick()" [disabled]="disabled"
></my-button>
</div>
......
......@@ -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')
......
......@@ -7,6 +7,7 @@
[multiple]="true"
[searchable]="true"
[closeOnSelect]="false"
[disabled]="disabled"
bindValue="id"
bindLabel="label"
......
......@@ -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
......
......@@ -5,6 +5,7 @@
[searchable]="searchable"
[groupBy]="groupBy"
[labelForId]="labelForId"
[disabled]="disabled"
[(ngModel)]="selectedId"
(ngModelChange)="onModelChange()"
......
......@@ -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
}
}
......@@ -7,6 +7,7 @@
[labelForId]="labelForId"
[searchable]="searchable"
[searchFn]="searchFn"
[disabled]="disabled"
bindLabel="label"
bindValue="id"
......
......@@ -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
}
}
......@@ -364,6 +364,9 @@
cursor: default;
}
}
select[disabled] {
background-color: #f9f9f9;
}
@media screen and (max-width: $width) {
width: 100%;
......
......@@ -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
......
......@@ -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
......
......@@ -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)
)
......
......@@ -195,6 +195,13 @@ const CONFIG = {
URL: config.get<string>('peertube.check_latest_version.url')
}
},
WEBADMIN: {
CONFIGURATION: {
EDITS: {
ALLOWED: config.get<boolean>('webadmin.configuration.edit.allowed')
}
}
},
ADMIN: {
get EMAIL () { return config.get<string>('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
}
......
......@@ -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,
......
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) {
......
......@@ -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