WIP plugins: add theme support

pull/1987/head
Chocobozzz 2019-07-09 11:45:19 +02:00 committed by Chocobozzz
parent 8d76959e11
commit 7cd4d2ba10
34 changed files with 311 additions and 38 deletions

View File

@ -85,6 +85,23 @@
</ng-container>
<div i18n class="inner-form-title">Theme</div>
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Global theme</label>
<div class="peertube-select-container">
<select formControlName="default" id="themeDefault">
<option i18n value="default">default</option>
<option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
</select>
</div>
</div>
</ng-container>
<div i18n class="inner-form-title">Signup</div>
<ng-container formGroupName="signup">

View File

@ -73,6 +73,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
return this.configService.videoQuotaDailyOptions
}
get availableThemes () {
return this.serverService.getConfig().theme.registered
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
@ -92,6 +96,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
css: null
}
},
theme: {
default: null
},
services: {
twitter: {
username: this.customConfigValidatorsService.SERVICES_TWITTER_USERNAME,

View File

@ -0,0 +1 @@
export * from './my-account-interface-settings.component'

View File

@ -0,0 +1,13 @@
<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
<div class="form-group">
<label i18n for="theme">Theme</label>
<div class="peertube-select-container">
<select formControlName="theme" id="theme">
<option i18n value="default">default</option>
<option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
</select>
</div>
</div>
</form>

View File

@ -0,0 +1,16 @@
@import '_variables';
@import '_mixins';
input[type=submit] {
@include peertube-button;
@include orange-button;
display: block;
margin-top: 15px;
}
.peertube-select-container {
@include peertube-select-container(340px);
margin-bottom: 30px;
}

View File

@ -0,0 +1,64 @@
import { Component, Input, OnInit } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
import { UserUpdateMe } from '../../../../../../shared'
import { AuthService } from '../../../core'
import { FormReactive, User, UserService } from '../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { Subject } from 'rxjs'
@Component({
selector: 'my-account-interface-settings',
templateUrl: './my-account-interface-settings.component.html',
styleUrls: [ './my-account-interface-settings.component.scss' ]
})
export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit {
@Input() user: User = null
@Input() userInformationLoaded: Subject<any>
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
private notifier: Notifier,
private userService: UserService,
private serverService: ServerService,
private i18n: I18n
) {
super()
}
get availableThemes () {
return this.serverService.getConfig().theme.registered
}
ngOnInit () {
this.buildForm({
theme: null
})
this.userInformationLoaded
.subscribe(() => {
this.form.patchValue({
theme: this.user.theme
})
})
}
updateInterfaceSettings () {
const theme = this.form.value['theme']
const details: UserUpdateMe = {
theme
}
this.userService.updateMyProfile(details).subscribe(
() => {
this.notifier.success(this.i18n('Interface settings updated.'))
window.location.reload()
},
err => this.notifier.error(err.message)
)
}
}

View File

@ -10,9 +10,12 @@
<div i18n class="account-title">Video settings</div>
<my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
<div i18n class="account-title" id="notifications">Notifications</div>
<div i18n class="account-title">Notifications</div>
<my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences>
<div i18n class="account-title">Interface</div>
<my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings>
<div i18n class="account-title">Password</div>
<my-account-change-password></my-account-change-password>

View File

@ -1,4 +1,4 @@
import { Component, OnInit, ViewChild } from '@angular/core'
import { Component, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
import { BytesPipe } from 'ngx-pipes'
import { AuthService } from '../../core'

View File

@ -25,19 +25,14 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b
import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
import {
MyAccountVideoPlaylistCreateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
import {
MyAccountVideoPlaylistUpdateComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
import { MyAccountVideoPlaylistCreateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
import { MyAccountVideoPlaylistUpdateComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
import {
MyAccountVideoPlaylistElementsComponent
} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
import { MyAccountVideoPlaylistElementsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
import { MultiSelectModule } from 'primeng/primeng'
import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
@NgModule({
imports: [
@ -58,6 +53,7 @@ import { MultiSelectModule } from 'primeng/primeng'
MyAccountVideoSettingsComponent,
MyAccountProfileComponent,
MyAccountChangeEmailComponent,
MyAccountInterfaceSettingsComponent,
MyAccountVideosComponent,

View File

@ -33,7 +33,7 @@ export class PluginService {
initializePlugins () {
this.server.configLoaded
.subscribe(() => {
this.plugins = this.server.getConfig().plugins
this.plugins = this.server.getConfig().plugin.registered
this.buildScopeStruct()

View File

@ -42,7 +42,13 @@ export class ServerService {
css: ''
}
},
plugins: [],
plugin: {
registered: []
},
theme: {
registered: [],
default: 'default'
},
email: {
enabled: false
},

View File

@ -26,6 +26,8 @@ export class User implements UserServerModel {
videoChannels: VideoChannel[]
createdAt: Date
theme: string
adminFlags?: UserAdminFlag
blocked: boolean
@ -49,6 +51,8 @@ export class User implements UserServerModel {
this.autoPlayVideo = hash.autoPlayVideo
this.createdAt = hash.createdAt
this.theme = hash.theme
this.adminFlags = hash.adminFlags
this.blocked = hash.blocked

View File

@ -264,3 +264,6 @@ followers:
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
theme:
default: 'default'

View File

@ -279,3 +279,6 @@ followers:
enabled: true
# Whether or not an administrator must manually validate a new follower
manual_approval: false
theme:
default: 'default'

View File

@ -261,7 +261,7 @@ async function startApplication () {
updateStreamingPlaylistsInfohashesIfNeeded()
.catch(err => logger.error('Cannot update streaming playlist infohashes.', { err }))
await PluginManager.Instance.registerPlugins()
await PluginManager.Instance.registerPluginsAndThemes()
// Make server listening
server.listen(port, hostname, () => {

View File

@ -1,6 +1,6 @@
import * as express from 'express'
import { snakeCase } from 'lodash'
import { ServerConfig, ServerConfigPlugin, UserRight } from '../../../shared'
import { ServerConfig, UserRight } from '../../../shared'
import { About } from '../../../shared/models/server/about.model'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
@ -16,7 +16,7 @@ import { isNumeric } from 'validator'
import { objectConverter } from '../../helpers/core-utils'
import { CONFIG, reloadConfig } from '../../initializers/config'
import { PluginManager } from '../../lib/plugins/plugin-manager'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
const packageJSON = require('../../../../package.json')
const configRouter = express.Router()
@ -56,19 +56,23 @@ async function getConfig (req: express.Request, res: express.Response) {
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
const plugins: ServerConfigPlugin[] = []
const registeredPlugins = PluginManager.Instance.getRegisteredPlugins()
for (const pluginName of Object.keys(registeredPlugins)) {
const plugin = registeredPlugins[ pluginName ]
if (plugin.type !== PluginType.PLUGIN) continue
.map(p => ({
name: p.name,
version: p.version,
description: p.description,
clientScripts: p.clientScripts
}))
plugins.push({
name: plugin.name,
version: plugin.version,
description: plugin.description,
clientScripts: plugin.clientScripts
})
}
const registeredThemes = PluginManager.Instance.getRegisteredThemes()
.map(t => ({
name: t.name,
version: t.version,
description: t.description,
clientScripts: t.clientScripts
}))
const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT)
const json: ServerConfig = {
instance: {
@ -82,7 +86,13 @@ async function getConfig (req: express.Request, res: express.Response) {
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
}
},
plugins,
plugin: {
registered: registeredPlugins
},
theme: {
registered: registeredThemes,
default: defaultTheme
},
email: {
enabled: Emailer.isEnabled()
},
@ -240,6 +250,9 @@ function customConfig (): CustomConfig {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT
}
},
theme: {
default: CONFIG.THEME.DEFAULT
},
services: {
twitter: {
username: CONFIG.SERVICES.TWITTER.USERNAME,

View File

@ -183,6 +183,7 @@ async function updateMe (req: express.Request, res: express.Response) {
if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo
if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled
if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages
if (body.theme !== undefined) user.theme = body.theme
if (body.email !== undefined) {
if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {

View File

@ -4,6 +4,7 @@ import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
import { PluginPackageJson } from '../../../shared/models/plugins/plugin-package-json.model'
import { isUrlValid } from './activitypub/misc'
import { isThemeRegistered } from '../../lib/plugins/theme-utils'
const PLUGINS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.PLUGINS
@ -61,6 +62,10 @@ function isCSSPathsValid (css: any[]) {
return isArray(css) && css.every(c => isSafePath(c))
}
function isThemeValid (name: string) {
return isPluginNameValid(name) && isThemeRegistered(name)
}
function isPackageJSONValid (packageJSON: PluginPackageJson, pluginType: PluginType) {
return isNpmPluginNameValid(packageJSON.name) &&
isPluginDescriptionValid(packageJSON.description) &&
@ -82,6 +87,7 @@ function isLibraryCodeValid (library: any) {
export {
isPluginTypeValid,
isPackageJSONValid,
isThemeValid,
isPluginVersionValid,
isPluginNameValid,
isPluginDescriptionValid,

View File

@ -29,7 +29,8 @@ function checkMissedConfig () {
'followers.instance.enabled', 'followers.instance.manual_approval',
'tracker.enabled', 'tracker.private', 'tracker.reject_too_many_announces',
'history.videos.max_age', 'views.videos.remote.max_age',
'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max'
'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
'theme.default'
]
const requiredAlternatives = [
[ // set

View File

@ -224,6 +224,9 @@ const CONFIG = {
get ENABLED () { return config.get<boolean>('followers.instance.enabled') },
get MANUAL_APPROVAL () { return config.get<boolean>('followers.instance.manual_approval') }
}
},
THEME: {
get DEFAULT () { return config.get<string>('theme.default') }
}
}

View File

@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 395
const LAST_MIGRATION_VERSION = 400
// ---------------------------------------------------------------------------
@ -585,6 +585,8 @@ const P2P_MEDIA_LOADER_PEER_VERSION = 2
const PLUGIN_GLOBAL_CSS_FILE_NAME = 'plugins-global.css'
const PLUGIN_GLOBAL_CSS_PATH = join(CONFIG.STORAGE.TMP_DIR, PLUGIN_GLOBAL_CSS_FILE_NAME)
const DEFAULT_THEME = 'default'
// ---------------------------------------------------------------------------
// Special constants for a test instance
@ -667,6 +669,7 @@ export {
HLS_STREAMING_PLAYLIST_DIRECTORY,
FEEDS,
JOB_TTL,
DEFAULT_THEME,
NSFW_POLICY_TYPES,
STATIC_MAX_AGE,
STATIC_PATHS,

View File

@ -0,0 +1,25 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction,
queryInterface: Sequelize.QueryInterface,
sequelize: Sequelize.Sequelize,
db: any
}): Promise<void> {
const data = {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'default'
}
await utils.queryInterface.addColumn('user', 'theme', data)
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -11,6 +11,7 @@ import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
import { outputFile } from 'fs-extra'
import { ServerConfigPlugin } from '../../../shared/models/server'
export interface RegisteredPlugin {
name: string
@ -47,7 +48,7 @@ export class PluginManager {
private constructor () {
}
async registerPlugins () {
async registerPluginsAndThemes () {
await this.resetCSSGlobalFile()
const plugins = await PluginModel.listEnabledPluginsAndThemes()
@ -63,12 +64,20 @@ export class PluginManager {
this.sortHooksByPriority()
}
getRegisteredPluginOrTheme (name: string) {
return this.registeredPlugins[name]
}
getRegisteredPlugin (name: string) {
return this.registeredPlugins[ name ]
const registered = this.getRegisteredPluginOrTheme(name)
if (!registered || registered.type !== PluginType.PLUGIN) return undefined
return registered
}
getRegisteredTheme (name: string) {
const registered = this.getRegisteredPlugin(name)
const registered = this.getRegisteredPluginOrTheme(name)
if (!registered || registered.type !== PluginType.THEME) return undefined
@ -76,7 +85,11 @@ export class PluginManager {
}
getRegisteredPlugins () {
return this.registeredPlugins
return this.getRegisteredPluginsOrThemes(PluginType.PLUGIN)
}
getRegisteredThemes () {
return this.getRegisteredPluginsOrThemes(PluginType.THEME)
}
async runHook (hookName: string, param?: any) {
@ -309,6 +322,19 @@ export class PluginManager {
}
}
private getRegisteredPluginsOrThemes (type: PluginType) {
const plugins: RegisteredPlugin[] = []
for (const pluginName of Object.keys(this.registeredPlugins)) {
const plugin = this.registeredPlugins[ pluginName ]
if (plugin.type !== type) continue
plugins.push(plugin)
}
return plugins
}
static get Instance () {
return this.instance || (this.instance = new this())
}

View File

@ -0,0 +1,24 @@
import { DEFAULT_THEME } from '../../initializers/constants'
import { PluginManager } from './plugin-manager'
import { CONFIG } from '../../initializers/config'
function getThemeOrDefault (name: string) {
if (isThemeRegistered(name)) return name
// Fallback to admin default theme
if (name !== CONFIG.THEME.DEFAULT) return getThemeOrDefault(CONFIG.THEME.DEFAULT)
return DEFAULT_THEME
}
function isThemeRegistered (name: string) {
if (name === DEFAULT_THEME) return true
return !!PluginManager.Instance.getRegisteredThemes()
.find(r => r.name === name)
}
export {
getThemeOrDefault,
isThemeRegistered
}

View File

@ -1,10 +1,11 @@
import * as express from 'express'
import { body } from 'express-validator/check'
import { isUserNSFWPolicyValid, isUserVideoQuotaValid, isUserVideoQuotaDailyValid } from '../../helpers/custom-validators/users'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
import { logger } from '../../helpers/logger'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { Emailer } from '../../lib/emailer'
import { areValidationErrors } from './utils'
import { isThemeValid } from '../../helpers/custom-validators/plugins'
const customConfigUpdateValidator = [
body('instance.name').exists().withMessage('Should have a valid instance name'),
@ -47,6 +48,8 @@ const customConfigUpdateValidator = [
body('followers.instance.enabled').isBoolean().withMessage('Should have a valid followers of instance boolean'),
body('followers.instance.manualApproval').isBoolean().withMessage('Should have a valid manual approval boolean'),
body('theme.default').custom(isThemeValid).withMessage('Should have a valid theme'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })

View File

@ -16,7 +16,7 @@ const servePluginStaticDirectoryValidator = [
if (areValidationErrors(req, res)) return
const plugin = PluginManager.Instance.getRegisteredPlugin(req.params.pluginName)
const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(req.params.pluginName)
if (!plugin || plugin.version !== req.params.pluginVersion) {
return res.sendStatus(404)

View File

@ -28,6 +28,7 @@ import { ActorModel } from '../../models/activitypub/actor'
import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor'
import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
import { UserRegister } from '../../../shared/models/users/user-register.model'
import { isThemeValid } from '../../helpers/custom-validators/plugins'
const usersAddValidator = [
body('username').custom(isUserUsernameValid).withMessage('Should have a valid username (lowercase alphanumeric characters)'),
@ -204,6 +205,9 @@ const usersUpdateMeValidator = [
body('videosHistoryEnabled')
.optional()
.custom(isUserVideosHistoryEnabledValid).withMessage('Should have a valid videos history enabled attribute'),
body('theme')
.optional()
.custom(isThemeValid).withMessage('Should have a valid theme'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking usersUpdateMe parameters', { parameters: omit(req.body, 'password') })

View File

@ -44,7 +44,7 @@ import { VideoChannelModel } from '../video/video-channel'
import { AccountModel } from './account'
import { NSFWPolicyType } from '../../../shared/models/videos/nsfw-policy.type'
import { values } from 'lodash'
import { NSFW_POLICY_TYPES } from '../../initializers/constants'
import { DEFAULT_THEME, NSFW_POLICY_TYPES } from '../../initializers/constants'
import { clearCacheByUserId } from '../../lib/oauth-model'
import { UserNotificationSettingModel } from './user-notification-setting'
import { VideoModel } from '../video/video'
@ -52,6 +52,8 @@ import { ActorModel } from '../activitypub/actor'
import { ActorFollowModel } from '../activitypub/actor-follow'
import { VideoImportModel } from '../video/video-import'
import { UserAdminFlag } from '../../../shared/models/users/user-flag.model'
import { isThemeValid } from '../../helpers/custom-validators/plugins'
import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
enum ScopeNames {
WITH_VIDEO_CHANNEL = 'WITH_VIDEO_CHANNEL'
@ -187,6 +189,12 @@ export class UserModel extends Model<UserModel> {
@Column(DataType.BIGINT)
videoQuotaDaily: number
@AllowNull(false)
@Default(DEFAULT_THEME)
@Is('UserTheme', value => throwIfNotValid(value, isThemeValid, 'theme'))
@Column
theme: string
@CreatedAt
createdAt: Date
@ -560,6 +568,7 @@ export class UserModel extends Model<UserModel> {
autoPlayVideo: this.autoPlayVideo,
videoLanguages: this.videoLanguages,
role: this.role,
theme: getThemeOrDefault(this.theme),
roleLabel: USER_ROLE_LABELS[ this.role ],
videoQuota: this.videoQuota,
videoQuotaDaily: this.videoQuotaDaily,

View File

@ -27,6 +27,9 @@ describe('Test config API validators', function () {
css: 'body { background-color: red; }'
}
},
theme: {
default: 'default'
},
services: {
twitter: {
username: '@MySuperUsername',

View File

@ -190,6 +190,9 @@ describe('Test config', function () {
css: 'body { background-color: red; }'
}
},
theme: {
default: 'default'
},
services: {
twitter: {
username: '@Kuja',

View File

@ -59,6 +59,9 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
css: 'body { background-color: red; }'
}
},
theme: {
default: 'default'
},
services: {
twitter: {
username: '@MySuperUsername',

View File

@ -15,6 +15,10 @@ export interface CustomConfig {
}
}
theme: {
default: string
}
services: {
twitter: {
username: string

View File

@ -24,7 +24,14 @@ export interface ServerConfig {
}
}
plugins: ServerConfigPlugin[]
plugin: {
registered: ServerConfigPlugin[]
}
theme: {
registered: ServerConfigPlugin[]
default: string
}
email: {
enabled: boolean

View File

@ -13,4 +13,6 @@ export interface UserUpdateMe {
email?: string
currentPassword?: string
password?: string
theme?: string
}