WIP plugins: add theme support
authorChocobozzz <me@florianbigard.com>
Tue, 9 Jul 2019 09:45:19 +0000 (11:45 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Wed, 24 Jul 2019 08:58:16 +0000 (10:58 +0200)
34 files changed:
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+my-account/my-account-settings/my-account-interface/index.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-settings.component.html
client/src/app/+my-account/my-account-settings/my-account-settings.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/core/plugins/plugin.service.ts
client/src/app/core/server/server.service.ts
client/src/app/shared/users/user.model.ts
config/default.yaml
config/production.yaml.example
server.ts
server/controllers/api/config.ts
server/controllers/api/users/me.ts
server/helpers/custom-validators/plugins.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/migrations/0400-user-theme.ts [new file with mode: 0644]
server/lib/plugins/plugin-manager.ts
server/lib/plugins/theme-utils.ts [new file with mode: 0644]
server/middlewares/validators/config.ts
server/middlewares/validators/plugins.ts
server/middlewares/validators/users.ts
server/models/account/user.ts
server/tests/api/check-params/config.ts
server/tests/api/server/config.ts
shared/extra-utils/server/config.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts
shared/models/users/user-update-me.model.ts

index d5b625d9c873e949a348ae22365e515e79986d30..fe9d856d050161c5756419129ccd54f06ec9d281 100644 (file)
         </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">
index 055bae8516e306d88d8609c1d1b788cfbfe26195..19a40842505cb9cd69fe365dd1b25724b9cd6bf2 100644 (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,
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/index.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/index.ts
new file mode 100644 (file)
index 0000000..62fce79
--- /dev/null
@@ -0,0 +1 @@
+export * from './my-account-interface-settings.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html
new file mode 100644 (file)
index 0000000..f34e77f
--- /dev/null
@@ -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>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss
new file mode 100644 (file)
index 0000000..629f017
--- /dev/null
@@ -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;
+}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts
new file mode 100644 (file)
index 0000000..f705507
--- /dev/null
@@ -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)
+    )
+  }
+}
index e51302f7cb97a38d38ff8aef0dd45747802b984d..eb9367d1f6813bb7f8c7482930415fa8afa17ada 100644 (file)
 <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>
 
index f4b954e5491264415f6033e3403240f06c5fca57..95fd2a3dbd0f0d64761965f8262b675a570d067c 100644 (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'
index a1b198e3e6d165b7133024875845e5a68bb90622..5be1b0d052d3b56f25481d43a988088809c5a6e1 100644 (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,
 
index 6c567d3ca3733706e0e3f4ef2eef0cdd71667d19..7f751f479c7630fa46f2a7c8fe3671027a117577 100644 (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()
 
index 80c52164d578f3bb6323380e3c559ed952f7c2ad..7fb95fe4e963eb6c9471ad5e3aea6c42bf64fafb 100644 (file)
@@ -42,7 +42,13 @@ export class ServerService {
         css: ''
       }
     },
-    plugins: [],
+    plugin: {
+      registered: []
+    },
+    theme: {
+      registered: [],
+      default: 'default'
+    },
     email: {
       enabled: false
     },
index 95a6ce9f946d5cd90af3a605257ef86193a3fff6..53809f82c6e6d9fd41c32d3e8616623fa0d0a20c 100644 (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
index ff3d6d54c3498f9f794e4e84a4d3275521ac78d6..a1b2991cf1e8eab04071487a3a020d54ccbc9301 100644 (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'
index 7158e076bc882bdc50c6e2695320b285b871d422..6c2eb441618f3b15d46a66d0dc70e5816164c3e2 100644 (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'
index ac373b041b4f2fe3b9bcfff7a36aaa8f77a69457..d8e8f1e97d78c97db700dea19114f82bcd5a264d 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -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, () => {
index 8563b74370bd3f277d825f98d503e28605e83ac7..088234074e00fac61fe501bfb7a1e1e2c050b8b8 100644 (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
-
-    plugins.push({
-      name: plugin.name,
-      version: plugin.version,
-      description: plugin.description,
-      clientScripts: plugin.clientScripts
-    })
-  }
+    .map(p => ({
+      name: p.name,
+      version: p.version,
+      description: p.description,
+      clientScripts: p.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,
index a078334fec4fd3309907ea35be08716e452f31b2..e7ed3de6466d7fa8ea21cdc4c36fd789403a54f7 100644 (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) {
index 2fcdc581f68957dac55e1b5cc5723f19df80b50a..4ab5f9ce8392f825c95f4de5ebed8d7c399d03a2 100644 (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,
index 1f5ec20df7886e76aba804d31c0b05d1e418ab26..c94bca2f89b501a279867d455dad3f982037369a 100644 (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
index 6737edcd600cc0b46ac0dc2935569e06fdba0e07..dfc4bea2144d487ecfa3af3124de93b022634845 100644 (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') }
   }
 }
 
index 8ceefbd0e2f26421e73f7a5ef6698b45e457faf6..9d61ed537127bea86db14cecc04d1860126b7116 100644 (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,
diff --git a/server/initializers/migrations/0400-user-theme.ts b/server/initializers/migrations/0400-user-theme.ts
new file mode 100644 (file)
index 0000000..2c17638
--- /dev/null
@@ -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
+}
index 7cbfa856943904648eba842708fbc3e39b470a83..8496979f8b203c412e7103c5bc60928aee171471 100644 (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())
   }
diff --git a/server/lib/plugins/theme-utils.ts b/server/lib/plugins/theme-utils.ts
new file mode 100644 (file)
index 0000000..066339e
--- /dev/null
@@ -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
+}
index d015fa6fece80d738ee553f477ed50c27c6ba050..31b1319146b360c36d94e3d1cf9b4779a811f5ad 100644 (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 })
 
index 672299ee137060c98fcc6c1e98ce67e37dddaef3..fcb461624450729ddaedb28cffc12baec228e801 100644 (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)
index 947ed36c3b0f27ac30449cfdeb3a006a52cd23fc..df7f77b840bf18155c8ba4231b27ad59b4003b65 100644 (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') })
index 0f425bb820b88848eb9df7bcf5567064431684a0..b8ca1dd5c343168c1b4571023ec53680db0f741f 100644 (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,
index a0d9392dc53af6e5b34e984867ef5c83d0fe4e54..7773ae1e7db7a6cf5d8ae7b6d6a8c740f1bd3a21 100644 (file)
@@ -27,6 +27,9 @@ describe('Test config API validators', function () {
         css: 'body { background-color: red; }'
       }
     },
+    theme: {
+      default: 'default'
+    },
     services: {
       twitter: {
         username: '@MySuperUsername',
index c39516dee6d6253ade26217005bee00be1ebb7c8..78fdc9cc09479faa98919e8cb3ad7b13c3881d5b 100644 (file)
@@ -190,6 +190,9 @@ describe('Test config', function () {
           css: 'body { background-color: red; }'
         }
       },
+      theme: {
+        default: 'default'
+      },
       services: {
         twitter: {
           username: '@Kuja',
index 2b7965bc24a828b00816a1be88243948955926b3..8736f083f55ea8e6c6245a1ac3abf2c77fd6fed5 100644 (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',
index 670553d16c6f8c819c52f961ac727a09b7e63cd7..a0541f5b621f17fbf8880003bb9c308907f933b3 100644 (file)
@@ -15,6 +15,10 @@ export interface CustomConfig {
     }
   }
 
+  theme: {
+    default: string
+  }
+
   services: {
     twitter: {
       username: string
index c259a849a4b6e82e2b9f084d6c8665cd0f80afed..d6c660aac05c56bda8d32ab237c1e7ed8b9b0873 100644 (file)
@@ -24,7 +24,14 @@ export interface ServerConfig {
     }
   }
 
-  plugins: ServerConfigPlugin[]
+  plugin: {
+    registered: ServerConfigPlugin[]
+  }
+
+  theme: {
+    registered: ServerConfigPlugin[]
+    default: string
+  }
 
   email: {
     enabled: boolean
index 6e6cd711501e073c2912e6ec9a966475fafe360c..b6c0002e52e702eea665336ef1557217796f9bb3 100644 (file)
@@ -13,4 +13,6 @@ export interface UserUpdateMe {
   email?: string
   currentPassword?: string
   password?: string
+
+  theme?: string
 }