Support broadcast messages
authorChocobozzz <me@florianbigard.com>
Thu, 28 May 2020 09:15:38 +0000 (11:15 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Fri, 29 May 2020 07:32:12 +0000 (09:32 +0200)
20 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.scss
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/app.component.html
client/src/app/app.component.scss
client/src/app/app.component.ts
client/src/app/core/server/server.service.ts
config/default.yaml
config/production.yaml.example
server/controllers/api/config.ts
server/initializers/checker-after-init.ts
server/initializers/config.ts
server/middlewares/validators/config.ts
server/tests/api/check-params/config.ts
server/tests/api/server/config.ts
shared/extra-utils/server/config.ts
shared/models/server/broadcast-message-level.type.ts [new file with mode: 0644]
shared/models/server/custom-config.model.ts
shared/models/server/index.ts
shared/models/server/server-config.model.ts

index 5703d5a2e0daef72ac3da61b82cf64a45c99900f..4ee573696705a605a116c768055dda2cb0d31c19 100644 (file)
           </div>
         </div>
 
+        <div class="form-row mt-4"> <!-- broadcast grid -->
+          <div class="form-group col-12 col-lg-4 col-xl-3">
+            <div i18n class="inner-form-title">BROADCAST MESSAGE</div>
+            <div i18n class="inner-for-description">
+              Display a message on your instance
+            </div>
+          </div>
+
+          <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
+
+            <ng-container formGroupName="broadcastMessage">
+
+              <div class="form-group">
+                <my-peertube-checkbox
+                  inputName="broadcastMessageEnabled" formControlName="enabled"
+                  i18n-labelText labelText="Enable broadcast message"
+                ></my-peertube-checkbox>
+              </div>
+
+              <div class="form-group">
+                <my-peertube-checkbox
+                  inputName="broadcastMessageDismissable" formControlName="dismissable"
+                  i18n-labelText labelText="Allow users to dismiss the broadcast message "
+                ></my-peertube-checkbox>
+              </div>
+
+              <div class="form-group">
+                <label i18n for="broadcastMessageLevel">Broadcast message level</label>
+                <div class="peertube-select-container">
+                  <select id="broadcastMessageLevel" formControlName="level" class="form-control">
+                    <option value="info">info</option>
+                    <option value="warning">warning</option>
+                    <option value="error">error</option>
+                  </select>
+                </div>
+                <div *ngIf="formErrors.broadcastMessage.level" class="form-error">{{ formErrors.broadcastMessage.level }}</div>
+              </div>
+
+              <div class="form-group">
+                <label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
+                <my-markdown-textarea
+                  name="broadcastMessageMessage" formControlName="message" textareaMaxWidth="500px"
+                  [classes]="{ 'input-error': formErrors['broadcastMessage.message'] }"
+                ></my-markdown-textarea>
+                <div *ngIf="formErrors.broadcastMessage.message" class="form-error">{{ formErrors.broadcastMessage.message }}</div>
+              </div>
+
+            </ng-container>
+
+          </div>
+        </div>
+
         <div class="form-row mt-4"> <!-- new users grid -->
           <div class="form-group col-12 col-lg-4 col-xl-3">
             <div i18n class="inner-form-title">NEW USERS</div>
   <div class="form-row mt-4"> <!-- submit placement block -->
     <div class="col-md-7 col-xl-5"></div>
     <div class="col-md-5 col-xl-5">
+      <span class="form-error submit-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
+
       <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid">
-      <span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
     </div>
   </div>
 </form>
index 9ee960ad6863497ddb4f91f62494bac81e394039..2bfa92da4e0cef0c05da0f501c8e99059a33f657 100644 (file)
@@ -76,4 +76,8 @@ ngb-tabset:not(.previews) ::ng-deep {
   .nav-link {
     font-size: 105%;
   }
-}
\ No newline at end of file
+}
+
+.submit-error {
+  margin-bottom: 20px;
+}
index cea314cead2802734544703b28024a5a9f9fa2a5..6d59494c88a7192ec8690a5747b24ffeb2a69726 100644 (file)
@@ -215,6 +215,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
             indexUrl: this.customConfigValidatorsService.INDEX_URL
           }
         }
+      },
+      broadcastMessage: {
+        enabled: null,
+        level: null,
+        dismissable: null,
+        message: null
       }
     }
 
index b0d2e5050a0c1d18baac76d8e19099ffed612dce..b243c129bf0c36322bd00d5e7e011c995546b036 100644 (file)
     <div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }">
 
       <div class="main-row">
+
+        <div *ngIf="broadcastMessage" class="broadcast-message alert" [ngClass]="broadcastMessage.class">
+          <div [innerHTML]="broadcastMessage.message"></div>
+
+          <my-global-icon
+            *ngIf="broadcastMessage.dismissable" (click)="hideBroadcastMessage()"
+            iconName="cross" role="button" title="Close this message" i18n-title
+          ></my-global-icon>
+        </div>
+
         <router-outlet></router-outlet>
       </div>
     </div>
index 0c33dc4a12b1807b6487ad7316c109748854d57a..27fd69c8da7bdc1c299a8deb03546123605c98c4 100644 (file)
@@ -1,5 +1,7 @@
 @import '_variables';
 @import '_mixins';
+@import '~bootstrap/scss/functions';
+@import '~bootstrap/scss/variables';
 
 .peertube-container {
   padding-bottom: 20px;
     flex: 1;
   }
 }
+
+.broadcast-message {
+  min-height: 50px;
+  text-align: center;
+  margin-bottom: 0;
+  border-radius: 0;
+  display: grid;
+  grid-template-columns: 1fr 30px;
+  column-gap: 10px;
+
+  my-global-icon {
+    justify-self: center;
+    align-self: center;
+    cursor: pointer;
+
+    width: 20px;
+  }
+
+  @each $color, $value in $theme-colors {
+    &.alert-#{$color} {
+      my-global-icon {
+        @include apply-svg-color(theme-color-level($color, $alert-color-level));
+      }
+    }
+  }
+
+  ::ng-deep {
+    p {
+      font-size: 16px;
+    }
+
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
+}
index 12c0efd8a5f9efa09d1dfe6ec5a6ae7d1a8195a4..a464e90fac65fee5d3c711025b9eea93b75cac0f 100644 (file)
@@ -4,7 +4,7 @@ import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular
 import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
 import { is18nPath } from '../../../shared/models/i18n'
 import { ScreenService } from '@app/shared/misc/screen.service'
-import { filter, map, pairwise } from 'rxjs/operators'
+import { filter, map, pairwise, first } from 'rxjs/operators'
 import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { PlatformLocation, ViewportScroller } from '@angular/common'
@@ -19,6 +19,10 @@ import { ServerConfig, UserRole } from '@shared/models'
 import { User } from '@app/shared'
 import { InstanceService } from '@app/shared/instance/instance.service'
 import { MenuService } from './core/menu/menu.service'
+import { BroadcastMessageLevel } from '@shared/models/server'
+import { MarkdownService } from './shared/renderer'
+import { concat } from 'rxjs'
+import { peertubeLocalStorage } from './shared/misc/peertube-web-storage'
 
 @Component({
   selector: 'my-app',
@@ -26,11 +30,14 @@ import { MenuService } from './core/menu/menu.service'
   styleUrls: [ './app.component.scss' ]
 })
 export class AppComponent implements OnInit, AfterViewInit {
+  private static BROADCAST_MESSAGE_KEY = 'app-broadcast-message-dismissed'
+
   @ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent
   @ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent
   @ViewChild('customModal') customModal: CustomModalComponent
 
   customCSS: SafeHtml
+  broadcastMessage: { message: string, dismissable: boolean, class: string } | null = null
 
   private serverConfig: ServerConfig
 
@@ -50,6 +57,7 @@ export class AppComponent implements OnInit, AfterViewInit {
     private hooks: HooksService,
     private location: PlatformLocation,
     private modalService: NgbModal,
+    private markdownService: MarkdownService,
     public menu: MenuService
   ) { }
 
@@ -81,6 +89,7 @@ export class AppComponent implements OnInit, AfterViewInit {
     this.initRouteEvents()
     this.injectJS()
     this.injectCSS()
+    this.injectBroadcastMessage()
 
     this.initHotkeys()
 
@@ -97,6 +106,12 @@ export class AppComponent implements OnInit, AfterViewInit {
     return this.authService.isLoggedIn()
   }
 
+  hideBroadcastMessage () {
+    peertubeLocalStorage.setItem(AppComponent.BROADCAST_MESSAGE_KEY, this.serverConfig.broadcastMessage.message)
+
+    this.broadcastMessage = null
+  }
+
   private initRouteEvents () {
     let resetScroll = true
     const eventsObs = this.router.events
@@ -165,6 +180,36 @@ export class AppComponent implements OnInit, AfterViewInit {
     ).subscribe(() => this.menu.isMenuDisplayed = false) // User clicked on a link in the menu, change the page
   }
 
+  private injectBroadcastMessage () {
+    concat(
+      this.serverService.getConfig().pipe(first()),
+      this.serverService.configReloaded
+    ).subscribe(async config => {
+      this.broadcastMessage = null
+
+      const messageConfig = config.broadcastMessage
+
+      if (messageConfig.enabled) {
+        // Already dismissed this message?
+        if (messageConfig.dismissable && localStorage.getItem(AppComponent.BROADCAST_MESSAGE_KEY) === messageConfig.message) {
+          return
+        }
+
+        const classes: { [id in BroadcastMessageLevel]: string } = {
+          info: 'alert-info',
+          warning: 'alert-warning',
+          error: 'alert-danger'
+        }
+
+        this.broadcastMessage = {
+          message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
+          dismissable: messageConfig.dismissable,
+          class: classes[messageConfig.level]
+        }
+      }
+    })
+  }
+
   private injectJS () {
     // Inject JS
     this.serverService.getConfig()
@@ -182,17 +227,19 @@ export class AppComponent implements OnInit, AfterViewInit {
 
   private injectCSS () {
     // Inject CSS if modified (admin config settings)
-    this.serverService.configReloaded
-        .subscribe(() => {
-          const headStyle = document.querySelector('style.custom-css-style')
-          if (headStyle) headStyle.parentNode.removeChild(headStyle)
-
-          // We test customCSS if the admin removed the css
-          if (this.customCSS || this.serverConfig.instance.customizations.css) {
-            const styleTag = '<style>' + this.serverConfig.instance.customizations.css + '</style>'
-            this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag)
-          }
-        })
+    concat(
+      this.serverService.getConfig().pipe(first()),
+      this.serverService.configReloaded
+    ).subscribe(config => {
+      const headStyle = document.querySelector('style.custom-css-style')
+      if (headStyle) headStyle.parentNode.removeChild(headStyle)
+
+      // We test customCSS if the admin removed the css
+      if (this.customCSS || config.instance.customizations.css) {
+        const styleTag = '<style>' + config.instance.customizations.css + '</style>'
+        this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag)
+      }
+    })
   }
 
   private async loadPlugins () {
index eac8f85e49ef49b147c3e0d4d657f5806734ee71..fdfbe4c0260b68d17ca6da0aa9869a4416c9c0e6 100644 (file)
@@ -21,7 +21,7 @@ export class ServerService {
 
   private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
 
-  configReloaded = new Subject<void>()
+  configReloaded = new Subject<ServerConfig>()
 
   private localeObservable: Observable<any>
   private videoLicensesObservable: Observable<VideoConstant<number>[]>
@@ -139,6 +139,12 @@ export class ServerService {
           indexUrl: 'https://instances.joinpeertube.org'
         }
       }
+    },
+    broadcastMessage: {
+      enabled: false,
+      message: '',
+      level: 'info',
+      dismissable: false
     }
   }
 
@@ -162,6 +168,11 @@ export class ServerService {
   resetConfig () {
     this.configLoaded = false
     this.configReset = true
+
+    // Notify config update
+    this.getConfig().subscribe(() => {
+      // empty, to fire a reset config event
+    })
   }
 
   getConfig () {
@@ -175,9 +186,9 @@ export class ServerService {
                                       this.config = config
                                       this.configLoaded = true
                                     }),
-                                    tap(() => {
+                                    tap(config => {
                                       if (this.configReset) {
-                                        this.configReloaded.next()
+                                        this.configReloaded.next(config)
                                         this.configReset = false
                                       }
                                     }),
index a0f2eb3a177b004666ea1473b88e57fef45fcf39..34a0a146fa3687567f463744d7c146170e439298 100644 (file)
@@ -372,3 +372,9 @@ followings:
 
 theme:
   default: 'default'
+
+broadcast_message:
+  enabled: false
+  message: '' # Support markdown
+  level: 'info' # 'info' | 'warning' | 'error'
+  dismissable: false
index 8b8c98f8c1e9cdff8467c7766d492db7b7b6f3f6..0ac05c5153d14e44a530467dc90b746a04e1b798 100644 (file)
@@ -386,3 +386,9 @@ followings:
 
 theme:
   default: 'default'
+
+broadcast_message:
+  enabled: false
+  message: '' # Support markdown
+  level: 'info' # 'info' | 'warning' | 'error'
+  dismissable: false
index edcb0b99ec17f2bce7850ec52deff92303a70189..41e5027b9b8c4bdc69d0cff3b4dc864d19251c03 100644 (file)
@@ -172,6 +172,13 @@ async function getConfig (req: express.Request, res: express.Response) {
           indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
         }
       }
+    },
+
+    broadcastMessage: {
+      enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
+      message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
+      level: CONFIG.BROADCAST_MESSAGE.LEVEL,
+      dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
     }
   }
 
@@ -432,6 +439,12 @@ function customConfig (): CustomConfig {
           indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
         }
       }
+    },
+    broadcastMessage: {
+      enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
+      message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
+      level: CONFIG.BROADCAST_MESSAGE.LEVEL,
+      dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
     }
   }
 }
index f111be2ae6fccee3319f063c3f734ccf35aa1ca9..b5b8541379d4abcad1ed4c783801fb2dc3341f25 100644 (file)
@@ -107,6 +107,10 @@ function checkConfig () {
     }
   }
 
+  if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
+    logger.warn('Redundancy directory should be different than the videos folder.')
+  }
+
   // Transcoding
   if (CONFIG.TRANSCODING.ENABLED) {
     if (CONFIG.TRANSCODING.WEBTORRENT.ENABLED === false && CONFIG.TRANSCODING.HLS.ENABLED === false) {
@@ -114,8 +118,14 @@ function checkConfig () {
     }
   }
 
-  if (CONFIG.STORAGE.VIDEOS_DIR === CONFIG.STORAGE.REDUNDANCY_DIR) {
-    logger.warn('Redundancy directory should be different than the videos folder.')
+  // Broadcast message
+  if (CONFIG.BROADCAST_MESSAGE.ENABLED) {
+    const currentLevel = CONFIG.BROADCAST_MESSAGE.LEVEL
+    const available = [ 'info', 'warning', 'error' ]
+
+    if (available.includes(currentLevel) === false) {
+      return 'Broadcast message level should be ' + available.join(' or ') + ' instead of ' + currentLevel
+    }
   }
 
   return null
index 6932b41e14517a9fdfe68b163112868e4379ca94..e2920ce9e07d7a41d054dad91defcb040fcde164 100644 (file)
@@ -6,6 +6,7 @@ import { buildPath, parseBytes, parseDurationToMs, root } from '../helpers/core-
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
 import * as bytes from 'bytes'
 import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
+import { BroadcastMessageLevel } from '@shared/models/server'
 
 // Use a variable to reload the configuration if we need
 let config: IConfig = require('config')
@@ -285,6 +286,12 @@ const CONFIG = {
   },
   THEME: {
     get DEFAULT () { return config.get<string>('theme.default') }
+  },
+  BROADCAST_MESSAGE: {
+    get ENABLED () { return config.get<boolean>('broadcast_message.enabled') },
+    get MESSAGE () { return config.get<string>('broadcast_message.message') },
+    get LEVEL () { return config.get<BroadcastMessageLevel>('broadcast_message.level') },
+    get DISMISSABLE () { return config.get<boolean>('broadcast_message.dismissable') }
   }
 }
 
index dfa549e763dc6508a8da2527fecc1981b2e6498d..6905ac762defeb34348aa595e548463a159d88b7 100644 (file)
@@ -55,6 +55,11 @@ const customConfigUpdateValidator = [
 
   body('theme.default').custom(v => isThemeNameValid(v) && isThemeRegistered(v)).withMessage('Should have a valid theme'),
 
+  body('broadcastMessage.enabled').isBoolean().withMessage('Should have a valid broadcast message enabled boolean'),
+  body('broadcastMessage.message').exists().withMessage('Should have a valid broadcast message'),
+  body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
+  body('broadcastMessage.dismissable').exists().withMessage('Should have a valid broadcast dismissable boolean'),
+
   (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
 
index f1a79806bb3b835c8813c62e3c96c9c481a201d3..7c96fa762b7c784535a840a8ec15cf46b8ef4bc8 100644 (file)
@@ -133,6 +133,12 @@ describe('Test config API validators', function () {
           indexUrl: 'https://index.example.com'
         }
       }
+    },
+    broadcastMessage: {
+      enabled: true,
+      dismissable: true,
+      message: 'super message',
+      level: 'warning'
     }
   }
 
index 8580835d689394da1b55bb675ebcc1b408c5a392..d18a930823e85daf8ad0909b857c5780a8a71dbb 100644 (file)
@@ -87,6 +87,11 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
   expect(data.followings.instance.autoFollowBack.enabled).to.be.false
   expect(data.followings.instance.autoFollowIndex.enabled).to.be.false
   expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('')
+
+  expect(data.broadcastMessage.enabled).to.be.false
+  expect(data.broadcastMessage.level).to.equal('info')
+  expect(data.broadcastMessage.message).to.equal('')
+  expect(data.broadcastMessage.dismissable).to.be.false
 }
 
 function checkUpdatedConfig (data: CustomConfig) {
@@ -155,6 +160,11 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.followings.instance.autoFollowBack.enabled).to.be.true
   expect(data.followings.instance.autoFollowIndex.enabled).to.be.true
   expect(data.followings.instance.autoFollowIndex.indexUrl).to.equal('https://updated.example.com')
+
+  expect(data.broadcastMessage.enabled).to.be.true
+  expect(data.broadcastMessage.level).to.equal('error')
+  expect(data.broadcastMessage.message).to.equal('super bad message')
+  expect(data.broadcastMessage.dismissable).to.be.true
 }
 
 describe('Test config', function () {
@@ -324,6 +334,12 @@ describe('Test config', function () {
             indexUrl: 'https://updated.example.com'
           }
         }
+      },
+      broadcastMessage: {
+        enabled: true,
+        level: 'error',
+        message: 'super bad message',
+        dismissable: true
       }
     }
     await updateCustomConfig(server.url, server.accessToken, newCustomConfig)
index 743d10316b734639e41c2653aa150882eca2502f..98cd435f692481838f3b6378349e078176547d7c 100644 (file)
@@ -159,6 +159,12 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
           enabled: false
         }
       }
+    },
+    broadcastMessage: {
+      enabled: true,
+      level: 'warning',
+      message: 'hello',
+      dismissable: true
     }
   }
 
diff --git a/shared/models/server/broadcast-message-level.type.ts b/shared/models/server/broadcast-message-level.type.ts
new file mode 100644 (file)
index 0000000..bf43e18
--- /dev/null
@@ -0,0 +1 @@
+export type BroadcastMessageLevel = 'info' | 'warning' | 'error'
index 07e17bda2b973120a886c8a32f3e6b5da1d15d20..851bf1854d31a33748dc9acd68609b7fcd2807cd 100644 (file)
@@ -1,4 +1,5 @@
 import { NSFWPolicyType } from '../videos/nsfw-policy.type'
+import { BroadcastMessageLevel } from './broadcast-message-level.type'
 
 export interface CustomConfig {
   instance: {
@@ -131,4 +132,11 @@ export interface CustomConfig {
       }
     }
   }
+
+  broadcastMessage: {
+    enabled: boolean
+    message: string
+    level: BroadcastMessageLevel
+    dismissable: boolean
+  }
 }
index b0afb2c667fb925955441c542660e6e0087d2ea6..2bb443d46a950a00a308109de5b8eebdb3fe40f2 100644 (file)
@@ -1,4 +1,5 @@
 export * from './about.model'
+export * from './broadcast-message-level.type'
 export * from './contact-form.model'
 export * from './custom-config.model'
 export * from './debug.model'
index a1f9b3b5de0e5384e38b9b88b2e399ce2399579a..9c903b7ee324b518b92dda315b93083e616eb300 100644 (file)
@@ -1,5 +1,6 @@
-import { NSFWPolicyType } from '../videos/nsfw-policy.type'
 import { ClientScript } from '../plugins/plugin-package-json.model'
+import { NSFWPolicyType } from '../videos/nsfw-policy.type'
+import { BroadcastMessageLevel } from './broadcast-message-level.type'
 
 export interface ServerConfigPlugin {
   name: string
@@ -161,4 +162,11 @@ export interface ServerConfig {
       }
     }
   }
+
+  broadcastMessage: {
+    enabled: boolean
+    message: string
+    level: BroadcastMessageLevel
+    dismissable: boolean
+  }
 }