From 8d43b7ae60e21e7afcc7bad8fcb4cef55e3b7e03 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sat, 21 Sep 2019 16:03:17 +0200 Subject: [PATCH 01/20] Stricter TypeScript checks & JS to TS migration --- frontend/src/@types/index.d.ts | 54 ++- frontend/src/app/CakeRequest.ts | 54 +++ frontend/src/app/ContentTimer.ts | 36 ++ frontend/src/app/app.js | 150 -------- frontend/src/app/app.ts | 122 +++++++ frontend/src/app/core.js | 5 - frontend/src/app/faviconBadge.ts | 45 +-- frontend/src/app/pageAutoreload.ts | 2 +- frontend/src/app/vent.js | 12 - frontend/src/app/vent.ts | 12 + frontend/src/collections/postings.js | 7 - frontend/src/collections/postings.ts | 6 + frontend/src/collections/threadlines.js | 6 - frontend/src/collections/threadlines.ts | 6 + frontend/src/exports.js | 1 + frontend/src/index.js | 4 +- frontend/src/lib/backbone/jsonApi.ts | 8 +- frontend/src/lib/saito/CakeFormErrorView.ts | 2 +- frontend/src/lib/saito/InitFromDom.ts | 2 +- frontend/src/lib/saito/backbone.cakeRest.js | 24 -- frontend/src/lib/saito/backbone.cakeRest.ts | 39 +++ frontend/src/lib/saito/global.ts | 8 + frontend/src/lib/saito/localStorageHelper.js | 2 +- frontend/src/lib/saito/markup.media.ts | 52 ++- frontend/src/lib/saito/templateHelpers.js | 2 +- frontend/src/models/PostingMdl.ts | 2 +- frontend/src/models/app.js | 47 --- frontend/src/models/app.ts | 47 +++ frontend/src/models/appSetting.js | 4 - frontend/src/models/appStatus.js | 134 -------- frontend/src/models/appStatus.ts | 123 +++++++ frontend/src/models/currentUser.js | 37 -- frontend/src/models/currentUser.ts | 36 ++ frontend/src/models/threadline.js | 29 -- frontend/src/models/threadline.ts | 27 ++ frontend/src/modules/answering/Draft.ts | 19 +- frontend/src/modules/answering/Meta.ts | 11 +- frontend/src/modules/answering/answering.ts | 11 +- .../modules/answering/buttons/CiteBtnVw.ts | 4 +- .../answering/buttons/EditCountdownBtnView.ts | 12 +- .../modules/answering/editor/Menu/LinkView.ts | 2 +- .../answering/editor/Menu/MediaInsertView.ts | 2 +- .../modules/answering/editor/Menu/Smilies.ts | 2 +- .../MenuButton/AbstractMenuButtonView.ts | 2 +- .../answering/editor/MenuButtonBarView.ts | 2 +- .../modules/answering/models/AnswerModel.ts | 11 +- .../modules/answering/models/PreviewModel.ts | 4 +- .../src/modules/answering/views/PreviewVw.ts | 2 - .../modules/answering/views/SubjectInputVw.ts | 22 +- .../src/modules/bookmarks/bookmarksModule.ts | 4 +- .../bookmarks/collections/bookmarksCl.ts | 2 +- .../bookmarks/views/bookmarkCommentVw.ts | 4 +- .../modules/bookmarks/views/bookmarkItemVw.ts | 2 +- .../src/modules/modalDialog/modalDialog.js | 70 ---- .../src/modules/modalDialog/modalDialog.ts | 75 ++++ .../notification/html5-notification.js | 57 ---- .../notification/html5-notification.ts | 59 ++++ .../src/modules/notification/notification.ts | 4 +- frontend/src/modules/posting/Geshi.ts | 17 +- .../modules/posting/models/PostingModel.ts | 2 +- .../src/modules/posting/postingContent.ts | 4 +- frontend/src/modules/posting/postingLayout.ts | 13 +- .../src/modules/posting/postingRichtext.ts | 2 +- .../modules/posting/postingRichtextEmbed.ts | 4 +- frontend/src/modules/slidetabs/slidetab.ts | 2 +- frontend/src/modules/thread/thread.ts | 6 +- .../modules/uploader/collections/uploads.ts | 2 +- .../modules/uploader/views/uploaderAddVw.ts | 24 +- .../uploader/views/uploaderCollectionVw.ts | 6 +- .../uploader/views/uploaderItemFooterVw.ts | 4 +- frontend/src/modules/user/userVw.ts | 4 +- frontend/src/views/PostingSliderView.ts | 20 +- frontend/src/views/ThreadLineView.ts | 21 +- frontend/src/views/app.js | 300 ---------------- frontend/src/views/app.ts | 322 ++++++++++++++++++ frontend/src/views/helps.ts | 19 +- frontend/src/views/postingAction.js | 88 ----- frontend/src/views/postingAction.ts | 90 +++++ frontend/src/views/postingActionBookmark.js | 73 ---- frontend/src/views/postingActionBookmark.ts | 73 ++++ frontend/src/views/postingActionDelete.js | 56 --- frontend/src/views/postingActionDelete.ts | 60 ++++ frontend/src/views/postingActionSolves.js | 87 ----- frontend/src/views/postingActionSolves.ts | 90 +++++ frontend/src/views/thread.js | 113 ------ frontend/src/views/thread.ts | 129 +++++++ .../bookmarks/views/bookmarkItemVwSpec.js | 1 + frontend/test/runner.js | 4 +- frontend/test/views/AppViewSpec.js | 40 ++- frontend/test/views/HelpsSpec.js | 4 +- ...erViewSpec.js => PostingSliderViewSpec.js} | 4 +- .../json/{thread_line.ctp => threadline.ctp} | 0 src/View/Helper/JsDataHelper.php | 2 - .../Controller/EntriesControllerTest.php | 9 +- tsconfig.json | 1 + 95 files changed, 1684 insertions(+), 1542 deletions(-) create mode 100644 frontend/src/app/CakeRequest.ts create mode 100644 frontend/src/app/ContentTimer.ts delete mode 100644 frontend/src/app/app.js create mode 100644 frontend/src/app/app.ts delete mode 100644 frontend/src/app/core.js delete mode 100644 frontend/src/app/vent.js create mode 100644 frontend/src/app/vent.ts delete mode 100644 frontend/src/collections/postings.js create mode 100644 frontend/src/collections/postings.ts delete mode 100644 frontend/src/collections/threadlines.js create mode 100644 frontend/src/collections/threadlines.ts delete mode 100644 frontend/src/lib/saito/backbone.cakeRest.js create mode 100644 frontend/src/lib/saito/backbone.cakeRest.ts create mode 100644 frontend/src/lib/saito/global.ts delete mode 100644 frontend/src/models/app.js create mode 100644 frontend/src/models/app.ts delete mode 100644 frontend/src/models/appSetting.js delete mode 100644 frontend/src/models/appStatus.js create mode 100644 frontend/src/models/appStatus.ts delete mode 100644 frontend/src/models/currentUser.js create mode 100644 frontend/src/models/currentUser.ts delete mode 100644 frontend/src/models/threadline.js create mode 100644 frontend/src/models/threadline.ts delete mode 100644 frontend/src/modules/modalDialog/modalDialog.js create mode 100644 frontend/src/modules/modalDialog/modalDialog.ts delete mode 100644 frontend/src/modules/notification/html5-notification.js create mode 100644 frontend/src/modules/notification/html5-notification.ts delete mode 100644 frontend/src/views/app.js create mode 100644 frontend/src/views/app.ts delete mode 100644 frontend/src/views/postingAction.js create mode 100644 frontend/src/views/postingAction.ts delete mode 100644 frontend/src/views/postingActionBookmark.js create mode 100644 frontend/src/views/postingActionBookmark.ts delete mode 100644 frontend/src/views/postingActionDelete.js create mode 100644 frontend/src/views/postingActionDelete.ts delete mode 100644 frontend/src/views/postingActionSolves.js create mode 100644 frontend/src/views/postingActionSolves.ts delete mode 100644 frontend/src/views/thread.js create mode 100644 frontend/src/views/thread.ts rename frontend/test/views/{PostingLiserViewSpec.js => PostingSliderViewSpec.js} (90%) rename src/Template/Entries/json/{thread_line.ctp => threadline.ctp} (100%) diff --git a/frontend/src/@types/index.d.ts b/frontend/src/@types/index.d.ts index 2f84f9695..3be13b4e6 100644 --- a/frontend/src/@types/index.d.ts +++ b/frontend/src/@types/index.d.ts @@ -1,11 +1,56 @@ +declare module TinyTimer { + interface TinyTimerCallbackArgs { + /** Seconds */ + s: number, + /** Minutes */ + m: number, + /** Hours */ + h: number, + /** Days */ + d: number, + /** Total seconds */ + S: number, + /** Total minutes */ + M: number, + /** Total hours */ + H: number, + /** Total days */ + D: number, + /** Text representation */ + text: string, + } + + interface TinyTimerOptions { + format: string, + from?: Date | string, + onEnd?: (args: TinyTimerCallbackArgs) => {}, + onTick?: (args: TinyTimerCallbackArgs) => {}, + to: Date | string, + } +} + +declare namespace JQuery { + interface jqXHR { + // Official but missing in official jQuery definitions + crossDomain: boolean; + } +} + interface JQueryStatic { i18n: any; + isReady: boolean; } interface JQuery { scrollIntoView(method: string): any; - textrange(method: string|object, arg1?: number|string, arg2?: number|string, arg3?: number|string): any; - tinyTimer(options: object): void; + textrange(method: string | object, arg1?: number | string, arg2?: number | string, arg3?: number | string): { position: number, start: number, end: number, length: number, text: string } + tinyTimer(options: TinyTimer.TinyTimerOptions): void; +} + +declare namespace Marionette { + interface Application { + onStart(app: any, options: any): void; + } } declare module '*.html' { @@ -55,6 +100,11 @@ declare module 'moment' { export default moment; } +/** + * Helper declaration for .js files and TS strict + */ +declare module 'views/app'; + /** * Browser-vendor specific properties on the global document object */ diff --git a/frontend/src/app/CakeRequest.ts b/frontend/src/app/CakeRequest.ts new file mode 100644 index 000000000..8b1af9650 --- /dev/null +++ b/frontend/src/app/CakeRequest.ts @@ -0,0 +1,54 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +interface ICakeRequest { + action?: string; + controller?: string; + csrf?: { + header: string, + token: string, + }; + isMobile?: boolean; +} + +class CakeRequest { + private request!: ICakeRequest; + + /** + * Setter + * + * @param request Cake Request + */ + public set(request: ICakeRequest) { + this.request = request; + } + + /** + * Get the current CakePHP route action + */ + public getAction(): string | undefined { + return this.request ? this.request.action : undefined; + } + + /** + * Get the current CakePHP route controller + */ + public getController(): string | undefined { + return this.request ? this.request.controller : undefined; + } + + public getCsrf(): { header: string, token: string } | undefined { + return this.request ? this.request.csrf : undefined; + } + + public isMobile(): boolean | undefined { + return this.request ? this.request.isMobile : undefined; + } +} + +export default CakeRequest; diff --git a/frontend/src/app/ContentTimer.ts b/frontend/src/app/ContentTimer.ts new file mode 100644 index 000000000..3c62cc679 --- /dev/null +++ b/frontend/src/app/ContentTimer.ts @@ -0,0 +1,36 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +/** + * Global content timer + */ +class ContentTimer { + private timeoutId: number | undefined; + + public start(): this { + this.cancel(); + this.timeoutId = window.setTimeout(() => this.show(), 5000); + + return this; + } + + public cancel() { + if (typeof this.timeoutId === 'number') { + window.clearTimeout(this.timeoutId); + delete this.timeoutId; + } + } + + private show() { + const content = $('#content').css('visibility', 'visible'); + // console.warn('DOM ready timed out: Show content fallback used.'); + delete this.timeoutId; + } +} + +export default ContentTimer; diff --git a/frontend/src/app/app.js b/frontend/src/app/app.js deleted file mode 100644 index 17b9e9358..000000000 --- a/frontend/src/app/app.js +++ /dev/null @@ -1,150 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import App from 'models/app'; -import EventBus from 'app/vent'; - -import Application from 'app/core'; -import AppView from 'views/app'; -import Html5NotificationModule from 'modules/notification/html5-notification'; -import 'app/faviconBadge.ts'; -import 'app/pageAutoreload.ts'; - -import 'lib/jquery.i18n/jquery.i18n.extend'; -import 'lib/saito/backbone.initHelper'; - -/** - * Redirect helper - * - * @param {string} destination - */ -window.redirect = function (destination) { - document.location.replace(destination); -}; - -/** - * Global content timer - */ -var contentTimer = { - show: function () { - $('#content').css('visibility', 'visible'); - console.warn('DOM ready timed out: show content fallback used.'); - delete this.timeoutID; - }, - - setup: function () { - this.cancel(); - var self = this; - this.timeoutID = window.setTimeout(function () { - self.show(); - }, 5000); - }, - - cancel: function () { - if (typeof this.timeoutID === "number") { - window.clearTimeout(this.timeoutID); - delete this.timeoutID; - } - } -}; - -contentTimer.setup(); - - - -var whenReady = function (callback) { - if ($.isReady) { - callback(); - } else { - $(document).ready(callback); - } -}; - -var app = { - fireOnPageCallbacks: function (allCallbacks) { - var callbacks = allCallbacks.afterAppInit; - _.each(callbacks, function (fct) { - fct(); - }); - - EventBus.vent.on('isAppVisible', _.once(function (status) { - var callbacks = allCallbacks.afterViewInit; - _.each(callbacks, function (fct) { - fct(); - }); - })); - }, - - configureAjax: function ($, App) { - // prevent caching of ajax results - $.ajaxSetup({ cache: false }); - - //// set CSRF-token - $.ajaxPrefilter(function (options, _, xhr) { - if (xhr.crossDomain) { - return; - } - xhr.setRequestHeader(App.request.csrf.header, App.request.csrf.token); - }); - - //// set JWT-token - const jwtCookie = document.cookie.match(/Saito-jwt=([^\s;]*)/) - if (!jwtCookie) { - return; - } - App.settings.set('jwt', jwtCookie[1]); - - $.ajaxPrefilter(function (options, _, xhr) { - if (xhr.crossDomain) { - return; - } - xhr.setRequestHeader('Authorization', 'bearer ' + App.settings.get('jwt')); - }); - }, - - bootstrapApp: function (event, options) { - let appView, - appReady; - - App.settings.set(options.SaitoApp.app.settings); - - $.ajax({ - cache: true, - dataType: 'json', - mimeType: 'application/json', - success: (data) => { - $.i18n.setDictionary(data); - App.currentUser.set(options.SaitoApp.currentUser); - App.request = options.SaitoApp.request; - - app.configureAjax($, App); - - Html5NotificationModule.start(); - - var callbacks = options.SaitoApp.callbacks.beforeAppInit; - _.each(callbacks, (fct) => { fct(); }); - - appReady = function () { - app.fireOnPageCallbacks(options.SaitoApp.callbacks); - appView = new AppView({ el: 'body' }); - appView.initFromDom({ - SaitoApp: options.SaitoApp, - contentTimer: contentTimer - }); - }; - whenReady(appReady); - }, - url: options.SaitoApp.assets.lang, - }); - } -}; - -Application.on('start', app.bootstrapApp); - -EventBus.vent.reply('webroot', function () { - return App.settings.get('webroot'); -}); -EventBus.vent.reply('apiroot', function () { - return App.settings.get('apiroot'); -}); - -export default Application; diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts new file mode 100644 index 000000000..05e4bfe87 --- /dev/null +++ b/frontend/src/app/app.ts @@ -0,0 +1,122 @@ +import EventBus from 'app/vent'; +import $ from 'jquery'; +import App from 'models/app'; +import _ from 'underscore'; + +import 'app/faviconBadge'; +import 'app/pageAutoreload'; +import 'modules/notification/html5-notification'; +import AppView from 'views/app'; +import ContentTimer from './ContentTimer'; + +import { Application } from 'backbone.marionette'; +import 'lib/jquery.i18n/jquery.i18n.extend'; +import 'lib/saito/backbone.initHelper'; + +interface ISaitoCallbacks { + beforeAppInit: CallableFunction[]; + afterAppInit: CallableFunction[]; + afterViewInit: CallableFunction[]; +} + +interface ISaitoAppParams { + app: { + settings: any, + }; + callbacks: ISaitoCallbacks; + currentUser: any; + request: any; + assets: { + lang: string, + }; +} + +class Bootstrap { + public bootstrap(SaitoApp: ISaitoAppParams) { + const contentTimer = (new ContentTimer()).start(); + + EventBus.vent.reply('webroot', () => App.settings.get('webroot')); + EventBus.vent.reply('apiroot', () => App.settings.get('apiroot')); + + App.settings.set(SaitoApp.app.settings); + + $.ajax({ + cache: true, + dataType: 'json', + mimeType: 'application/json', + success: (data) => { + $.i18n.setDictionary(data); + App.currentUser.set(SaitoApp.currentUser); + App.request.set(SaitoApp.request); + + this.configureAjax(); + + const callbacks = SaitoApp.callbacks.beforeAppInit; + _.each(callbacks, (fct) => fct()); + + const appReady = () => { + this.fireOnPageCallbacks(SaitoApp.callbacks); + const appView = new AppView({ el: 'body' }); + appView.initFromDom({ SaitoApp, contentTimer }); + }; + this.whenReady(appReady); + }, + url: SaitoApp.assets.lang, + }); + } + + private whenReady(callback: () => any) { + if ($.isReady) { + callback(); + } else { + $(document).ready(callback); + } + } + + private fireOnPageCallbacks(allCallbacks: ISaitoCallbacks) { + _.each(allCallbacks.afterAppInit, (fct) => fct()); + + EventBus.vent.on('isAppVisible', _.once(() => { + _.each(allCallbacks.afterViewInit, (fct) => fct()); + })); + } + + private configureAjax() { + // prevent caching of ajax results + $.ajaxSetup({ cache: false }); + + /// set CSRF-token + $.ajaxPrefilter((options, originalOptions, xhr) => { + if (xhr.crossDomain) { + return; + } + const csrf = App.request.getCsrf(); + if (!csrf) { + throw new Error(); + } + xhr.setRequestHeader(csrf.header, csrf.token); + }); + + /// set JWT-token + const jwtCookie = document.cookie.match(/Saito-jwt=([^\s;]*)/); + if (!jwtCookie) { + return; + } + App.settings.set('jwt', jwtCookie[1]); + + $.ajaxPrefilter((options, originalOptions, xhr) => { + if (xhr.crossDomain) { + return; + } + xhr.setRequestHeader('Authorization', 'bearer ' + App.settings.get('jwt')); + }); + } +} + +const AppInstance = new Application({ channelName: 'app', region: '' }); + +AppInstance.on('start', (event: Event, options: {SaitoApp: ISaitoAppParams} ) => { + new Bootstrap().bootstrap(options.SaitoApp); +}); + +export default AppInstance; diff --git a/frontend/src/app/core.js b/frontend/src/app/core.js deleted file mode 100644 index 07b2d9650..000000000 --- a/frontend/src/app/core.js +++ /dev/null @@ -1,5 +0,0 @@ -import Marionette from 'backbone.marionette'; - -export default new Marionette.Application({ - channelName: 'app', -}); diff --git a/frontend/src/app/faviconBadge.ts b/frontend/src/app/faviconBadge.ts index c61590fbf..54f3aeebd 100644 --- a/frontend/src/app/faviconBadge.ts +++ b/frontend/src/app/faviconBadge.ts @@ -7,7 +7,7 @@ */ import Marionette from 'backbone.marionette'; -import App from 'models/app.js'; +import App from 'models/app'; // tslint:disable-next-line const Favico = require('favico.js'); @@ -31,27 +31,28 @@ class Favicon extends Marionette.Object { type: 'rectangle', }); - /// checkup browser support for hidden tab - let hidden: string; - let visibilityChange: string; - if (typeof document.hidden !== 'undefined') { - hidden = 'hidden'; - visibilityChange = 'visibilitychange'; - } else if (typeof document.msHidden !== 'undefined') { - hidden = 'msHidden'; - visibilityChange = 'msvisibilitychange'; - } else if (typeof document.webkitHidden !== 'undefined') { - hidden = 'webkitHidden'; - visibilityChange = 'webkitvisibilitychange'; - } + let visibilityChange: string | null = null; - /// browser can't detect a hidden tab - if (hidden === undefined) { - return; - } + const isHiddenFct = (): boolean | undefined => { + /// checkup browser support for hidden tab + let hidden: boolean | undefined; + if (typeof document.hidden !== 'undefined') { + hidden = document.hidden; + visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + hidden = document.msHidden; + visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + hidden = document.msHidden; + visibilityChange = 'webkitvisibilitychange'; + } + + return hidden; + }; - /// tab isn't hidden - if (!document[hidden]) { + /// browser can't detect a hidden tab or tab isn't hidden + const isHidden = isHiddenFct(); + if (isHidden === undefined || isHidden === false) { return; } @@ -60,11 +61,11 @@ class Favicon extends Marionette.Object { /// remove badge on page activation const handleVisibilityChange = () => { - if (!document[hidden]) { + if (!isHiddenFct()) { favicon.reset(); } }; - if (typeof document.addEventListener !== 'undefined') { + if (typeof document.addEventListener !== 'undefined' && visibilityChange) { document.addEventListener(visibilityChange, handleVisibilityChange, false); } } diff --git a/frontend/src/app/pageAutoreload.ts b/frontend/src/app/pageAutoreload.ts index db627ca09..b84c3c034 100644 --- a/frontend/src/app/pageAutoreload.ts +++ b/frontend/src/app/pageAutoreload.ts @@ -7,7 +7,7 @@ */ import Marionette from 'backbone.marionette'; -import App from 'models/app.js'; +import App from 'models/app'; /** * Sets up and manages autoreloading the current page. diff --git a/frontend/src/app/vent.js b/frontend/src/app/vent.js deleted file mode 100644 index af124ae87..000000000 --- a/frontend/src/app/vent.js +++ /dev/null @@ -1,12 +0,0 @@ -import Marionette from 'backbone.marionette'; -import Radio from 'backbone.radio' - -const eventBus = function () { - this.vent = Radio.channel('app'); - this.request = function () { - var args = Array.prototype.slice.apply(arguments); - return this.vent.request.apply(this.reqres, args); - }; -}; - -export default new eventBus(); diff --git a/frontend/src/app/vent.ts b/frontend/src/app/vent.ts new file mode 100644 index 000000000..92abc2e5e --- /dev/null +++ b/frontend/src/app/vent.ts @@ -0,0 +1,12 @@ +import Radio, { Channel } from 'backbone.radio'; + +class EventBus { + public vent: Channel; + + public constructor() { + this.vent = Radio.channel('app'); + } + +} + +export default new EventBus(); diff --git a/frontend/src/collections/postings.js b/frontend/src/collections/postings.js deleted file mode 100644 index af2d89d9e..000000000 --- a/frontend/src/collections/postings.js +++ /dev/null @@ -1,7 +0,0 @@ -import _ from 'underscore'; -import Backbone from 'backbone'; -import { PostingModel } from 'modules/posting/models/PostingModel'; - -export default Backbone.Collection.extend({ - model: PostingModel -}); diff --git a/frontend/src/collections/postings.ts b/frontend/src/collections/postings.ts new file mode 100644 index 000000000..36394a289 --- /dev/null +++ b/frontend/src/collections/postings.ts @@ -0,0 +1,6 @@ +import { Collection } from 'backbone'; +import { PostingModel } from 'modules/posting/models/PostingModel'; + +export default class extends Collection { + public model = PostingModel; +} diff --git a/frontend/src/collections/threadlines.js b/frontend/src/collections/threadlines.js deleted file mode 100644 index 58ccf435d..000000000 --- a/frontend/src/collections/threadlines.js +++ /dev/null @@ -1,6 +0,0 @@ -import Backbone from 'backbone'; -import ThreadLineModel from 'models/threadline'; - -export default Backbone.Collection.extend({ - model: ThreadLineModel, -}); diff --git a/frontend/src/collections/threadlines.ts b/frontend/src/collections/threadlines.ts new file mode 100644 index 000000000..b1bee81a3 --- /dev/null +++ b/frontend/src/collections/threadlines.ts @@ -0,0 +1,6 @@ +import { Collection } from 'backbone'; +import ThreadLineModel from '../models/threadline'; + +export default class extends Collection { + public model = ThreadLineModel; +} diff --git a/frontend/src/exports.js b/frontend/src/exports.js index 074947061..09c05d7db 100644 --- a/frontend/src/exports.js +++ b/frontend/src/exports.js @@ -3,6 +3,7 @@ import $ from 'jquery'; import _ from 'underscore'; import Bootstrap from 'bootstrap'; import Marionette from 'backbone.marionette'; +import 'lib/saito/global'; window._ = _; window.$ = window.jQuery = $; diff --git a/frontend/src/index.js b/frontend/src/index.js index db1837fa9..6093df20f 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -2,8 +2,8 @@ import 'lib/saito/underscore.extend'; import 'lib/saito/backbone.modelHelper'; import 'exports'; -import App from 'app/app'; +import Application from 'app/app'; // __webpack_public_path__ = SaitoApp.app.settings.webroot + 'dist/'; -window.Application = App; +window.Application = Application; diff --git a/frontend/src/lib/backbone/jsonApi.ts b/frontend/src/lib/backbone/jsonApi.ts index 81b61e67b..2f1c8b917 100644 --- a/frontend/src/lib/backbone/jsonApi.ts +++ b/frontend/src/lib/backbone/jsonApi.ts @@ -1,9 +1,9 @@ import EventBus from 'app/vent'; import * as Bb from 'backbone'; -class JsonApiModel extends Bb.Model { +abstract class JsonApiModel extends Bb.Model { /** Saito URL resource identifier */ - protected saitoUrl: string; + protected abstract saitoUrl: string; /** Bb URL property */ public urlRoot = () => { @@ -35,9 +35,9 @@ class JsonApiModel extends Bb.Model { } } -class JsonApiCollection extends Bb.Collection { +abstract class JsonApiCollection extends Bb.Collection { /** Saito URL resource identifier */ - protected saitoUrl: string; + protected abstract saitoUrl: string; /** Bb URL property */ public url = () => EventBus.vent.request('apiroot') + this.saitoUrl; diff --git a/frontend/src/lib/saito/CakeFormErrorView.ts b/frontend/src/lib/saito/CakeFormErrorView.ts index afec0ac6e..06f5b43c5 100644 --- a/frontend/src/lib/saito/CakeFormErrorView.ts +++ b/frontend/src/lib/saito/CakeFormErrorView.ts @@ -80,7 +80,7 @@ export default class CakeFormErrorView extends View { * @param element HTML input element the error message belongs to. */ private findDedicatedElement(element: JQuery): JQuery | false { - let dedicatedElement: JQuery; + let dedicatedElement: JQuery | null = null; let level: number = 0; let parent = element; // We assume that the dedicated element isn't miles up in the DOM tree. diff --git a/frontend/src/lib/saito/InitFromDom.ts b/frontend/src/lib/saito/InitFromDom.ts index 602cdb34b..aa38c7459 100644 --- a/frontend/src/lib/saito/InitFromDom.ts +++ b/frontend/src/lib/saito/InitFromDom.ts @@ -11,7 +11,7 @@ class InitFromDom { public static initCollectionFromDom( element: string, clt: Collection, - view: { new(options: any) } ) { + view: new(options: any) => void ) { const createElement = (collection: Collection, id: string, el: JQuery) => { collection.add({ id }); const a = new view({ diff --git a/frontend/src/lib/saito/backbone.cakeRest.js b/frontend/src/lib/saito/backbone.cakeRest.js deleted file mode 100644 index 0b434dc11..000000000 --- a/frontend/src/lib/saito/backbone.cakeRest.js +++ /dev/null @@ -1,24 +0,0 @@ -import Backbone from 'backbone'; - -export default { - - methodToCakePhpUrl: { - 'read': 'view', - 'create': 'add', - 'update': 'edit', - 'delete': 'delete' - }, - - sync: function (method, model, options) { - this.urlRoot = this.webroot; - options = options || {}; - options.url = this.urlRoot + model.methodToCakePhpUrl[method.toLowerCase()]; - if (!this.isNew()) { - options.url = - options.url + - (options.url.charAt(options.url.length - 1) === '/' ? '' : '/') + - this.id; - } - Backbone.sync(method, model, options); - } -}; diff --git a/frontend/src/lib/saito/backbone.cakeRest.ts b/frontend/src/lib/saito/backbone.cakeRest.ts new file mode 100644 index 000000000..eea0a30ff --- /dev/null +++ b/frontend/src/lib/saito/backbone.cakeRest.ts @@ -0,0 +1,39 @@ +import Backbone, { Model } from 'backbone'; +import { defaults } from 'underscore'; + +interface ICakeRest { + read: string; + create: string; + update: string; + delete: string; +} + +export default abstract class CakeRestModel extends Model { + public methodToCakePhpUrl!: ICakeRest; + + public webroot!: string; + + public initialize(attributes: any, options: any) { + this.methodToCakePhpUrl = { + create: 'add', + delete: 'delete', + read: 'view', + update: 'edit', + }; + } + + public sync(method: string, model: Model, options: any = {}): JQueryXHR { + this.urlRoot = this.webroot; + options = options || {}; + const key: keyof ICakeRest = method.toLocaleLowerCase() as keyof ICakeRest; + options.url = this.urlRoot + this.methodToCakePhpUrl[key]; + if (!this.isNew()) { + options.url = + options.url + + (options.url.charAt(options.url.length - 1) === '/' ? '' : '/') + + this.id; + } + + return Backbone.sync(method, model, options); + } +} diff --git a/frontend/src/lib/saito/global.ts b/frontend/src/lib/saito/global.ts new file mode 100644 index 000000000..5f9f03f47 --- /dev/null +++ b/frontend/src/lib/saito/global.ts @@ -0,0 +1,8 @@ +/** + * Redirect helper + * + * @param destination + */ +window.redirect = (destination: string) => { + document.location.replace(destination); +}; diff --git a/frontend/src/lib/saito/localStorageHelper.js b/frontend/src/lib/saito/localStorageHelper.js index 9ab35a0d4..9bd7e5ee8 100644 --- a/frontend/src/lib/saito/localStorageHelper.js +++ b/frontend/src/lib/saito/localStorageHelper.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import EventBus from 'app/vent'; +import EventBus from 'app/vent.ts'; var LocalStorageHelper = function () { }; diff --git a/frontend/src/lib/saito/markup.media.ts b/frontend/src/lib/saito/markup.media.ts index d0c2fe962..8d22c1d37 100644 --- a/frontend/src/lib/saito/markup.media.ts +++ b/frontend/src/lib/saito/markup.media.ts @@ -24,7 +24,7 @@ abstract class PreFilter implements IPreFilter { * @param {object} attr - iframe-tag attributes * @returns {string} */ - protected createIframe(attr: object): string { + protected createIframe(attr: _.Dictionary): string { const defaults = { allowfullscreen: 'allowfullscreen', frameborder: 0, @@ -33,7 +33,7 @@ abstract class PreFilter implements IPreFilter { }; _.defaults(attr, defaults); - const reducer = (memo, value, key) => { + const reducer = (memo: string, value: string, key: string) => { return memo + key + '="' + value + '" '; }; let attributes = _.reduce(attr, reducer, ''); @@ -49,50 +49,45 @@ class DropboxPreFilter extends PreFilter { * * @see https://www.dropbox.com/help/201/en */ - public cleanUp(text) { + public cleanUp(text: string): string { return text.replace(/https:\/\/www\.dropbox\.com\//, 'https://dl.dropbox.com/'); } } class YoutubePreFilter extends PreFilter { - /** - * Convert dropbox HTML-page URL to actual file URL - * - * @see https://www.dropbox.com/help/201/en - */ - public cleanUp(text) { + public cleanUp(text: string): string { let url: string = text; - let videoId: string; if (/http/.test(text) === false) { url = 'http://' + text; } - let regex = /(http|https):\/\/(\w+:?\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/i; + const regex = /(http|https):\/\/(\w+:?\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/i; if (!regex.test(url)) { return text; } - const domain: string = url.match(/(https?:\/\/)?(www\.)?(.[^\/:]+)/i).pop(); + let domainRegex: RegExp | undefined; + const matches = url.match(/(https?:\/\/)?(www\.)?(.[^\/:]+)/i); + const domain = matches ? matches.pop() : null; switch (domain) { case 'youtu.be': - regex = /youtu.be\/(.*?)(&.*)?$/; - if (regex.test(url)) { - videoId = url.match(regex)[1]; - } + domainRegex = /youtu.be\/(.*?)(&.*)?$/; break; case 'youtube.com': - regex = /v=(.*?)(&.*)?$/; - if (regex.test(url)) { - videoId = url.match(regex)[1]; - } + domainRegex = /v=(.*?)(&.*)?$/; break; } - if (videoId !== undefined) { - text = this.createIframe({ - src: '//www.youtube-nocookie.com/embed/' + videoId, - }); + if (domainRegex !== undefined) { + if (domainRegex.test(url)) { + const mt = url.match(domainRegex); + if (mt) { + text = this.createIframe({ + src: '//www.youtube-nocookie.com/embed/' + mt[1], + }); + } + } } return text; @@ -163,9 +158,12 @@ class MarkupMultimedia { } private videoIframe(text: string): string { - let inner = /.*?<\/iframe>/i.exec(text)[1]; - inner = inner.replace(/["']/g, ''); - return '[iframe' + inner + '][/iframe]'; + const inner = /.*?<\/iframe>/i.exec(text); + if (!inner) { + return text; + } + const innerText = inner[1].replace(/["']/g, ''); + return '[iframe' + innerText + '][/iframe]'; } private embed(text: string): string { diff --git a/frontend/src/lib/saito/templateHelpers.js b/frontend/src/lib/saito/templateHelpers.js index 5c4098d84..b06025350 100644 --- a/frontend/src/lib/saito/templateHelpers.js +++ b/frontend/src/lib/saito/templateHelpers.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import _ from 'underscore' -import Vent from 'app/vent'; +import Vent from 'app/vent.ts'; import moment from 'moment'; import 'lib/jquery.i18n/jquery.i18n.extend'; diff --git a/frontend/src/models/PostingMdl.ts b/frontend/src/models/PostingMdl.ts index 404e56617..0a2494195 100644 --- a/frontend/src/models/PostingMdl.ts +++ b/frontend/src/models/PostingMdl.ts @@ -9,7 +9,7 @@ import { JsonApiModel } from 'lib/backbone/jsonApi'; import * as _ from 'underscore'; -export default class PostingModel extends JsonApiModel { +export default abstract class PostingModel extends JsonApiModel { /** * Constructor * diff --git a/frontend/src/models/app.js b/frontend/src/models/app.js deleted file mode 100644 index 32f4122a5..000000000 --- a/frontend/src/models/app.js +++ /dev/null @@ -1,47 +0,0 @@ -import Backbone from 'backbone'; -import Vent from 'app/vent'; -import AppSettingModel from 'models/appSetting'; -import AppStatusModel from 'models/appStatus'; -import CurrentUserModel from 'models/currentUser'; - -const AppModel = Backbone.Model.extend({ - - /** - * global event handler for the app - */ - eventBus: null, - - /** - * CakePHP app settings - */ - settings: null, - - /** - * Current app status from server - */ - status: null, - - /** - * CurrentUser - */ - currentUser: null, - - /** - * Request info from CakePHP - */ - request: { - action: null, - controller: null, - }, - - initialize: function () { - this.eventBus = Vent.vent; - this.settings = new AppSettingModel(); - this.status = new AppStatusModel({}, { settings: this.settings }); - this.currentUser = new CurrentUserModel(); - } -}); - -const instance = new AppModel(); - -export default instance; diff --git a/frontend/src/models/app.ts b/frontend/src/models/app.ts new file mode 100644 index 000000000..a5d773a94 --- /dev/null +++ b/frontend/src/models/app.ts @@ -0,0 +1,47 @@ +import EventBus from 'app/vent'; +import { Model } from 'backbone'; +import { Channel } from 'backbone.radio'; +import AppStatusModel from 'models/appStatus'; +import CurrentUserModel from 'models/currentUser'; +import CakeRequest from '../app/CakeRequest'; + +class AppModel extends Model { + /** + * global event handler for the app + */ + public eventBus: Channel; + + /** + * CakePHP app settings + */ + public settings: Model; + + /** + * Current app status from server + */ + public status: AppStatusModel; + + /** + * CurrentUser + */ + public currentUser: CurrentUserModel; + + /** + * Request info from CakePHP + */ + public request: CakeRequest; + + public constructor(options: any = {}) { + super(options); + this.eventBus = EventBus.vent; + this.request = new CakeRequest(); + this.settings = new Model(); + this.status = new AppStatusModel({}, { settings: this.settings }); + this.currentUser = new CurrentUserModel(); + } + +} + +const instance = new AppModel(); + +export default instance; diff --git a/frontend/src/models/appSetting.js b/frontend/src/models/appSetting.js deleted file mode 100644 index 6e00ebeeb..000000000 --- a/frontend/src/models/appSetting.js +++ /dev/null @@ -1,4 +0,0 @@ -import Backbone from 'backbone'; - -export default Backbone.Model.extend({}); - diff --git a/frontend/src/models/appStatus.js b/frontend/src/models/appStatus.js deleted file mode 100644 index 50e0245b1..000000000 --- a/frontend/src/models/appStatus.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Saito - The Threaded Web Forum - * - * @copyright Copyright (c) the Saito Project Developers - * @link https://github.com/Schlaefer/Saito - * @license http://opensource.org/licenses/MIT - */ - -import _ from 'underscore'; -import Backbone from 'backbone'; -import cakeRest from 'lib/saito/backbone.cakeRest'; -import EventBus from 'app/vent'; - -const AppStatusModel = Backbone.Model.extend({ - - stream: null, - - initialize: function(attributes, options) { - this.settings = options.settings; - this.methodToCakePhpUrl = _.clone(this.methodToCakePhpUrl); - this.methodToCakePhpUrl.read = 'status/'; - }, - - start: function(immediate = true) { - this._setWebroot(this.settings.get('webroot')); - // Don't use SSE by default on unknown server-configs - /* - if (!!window.EventSource) { - this._eventStream(); - return; - } - */ - // slow polling just to keep the user online - this._poll(90000, 180000, immediate); - }, - - _setWebroot: function(webroot) { - this.webroot = webroot + 'status/'; - }, - - /** - * Request status by server-sent events - * - * @private - */ - _eventStream: function() { - this.stream = new EventSource(this.webroot + this.methodToCakePhpUrl.read); - this.stream.addEventListener('message', _.bind(function(e) { - /* @todo - if (e.origin != 'http://example.com') { - alert('Origin was not http://example.com'); - return; - } - */ - var data = JSON.parse(e.data); - this.set(data); - }, this), false); - }, - - /** - * Requests status by polling with classic HTTP request. - * - * Adjust to sane values taking UserOnlineTable::setOnline() into account, so - * that users wont get set offline. Default current default values were great - * for a shoutbox like feature with immediate and reasonbly fast polling. - * - * The time between requests increases if the data from the server is - * unchanged. - * - * @param {int} refreshTimeBase - minimum and start time between request in ms - * @param {int} refreshTimeMax - maximum time between requests in ms - * @param {bool} immediate - first request immediately or after refreshTimeBase - * @private - */ - _poll: function(refreshTimeBase = 10000, refreshTimeMax = 90000, immediate = true) { - var resetRefreshTime, - updateAppStatus, - setTimer, - timerId, - stopTimer, - refreshTimeAct; - - stopTimer = function() { - if (timerId !== undefined) { - window.clearTimeout(timerId); - } - }; - - resetRefreshTime = function() { - stopTimer(); - refreshTimeAct = refreshTimeBase; - }; - - setTimer = function() { - timerId = window.setTimeout( - updateAppStatus, - refreshTimeAct - ); - }; - - updateAppStatus = _.bind(function() { - setTimer(); - this.fetch({ - success: function() { - refreshTimeAct = Math.floor( - refreshTimeAct * (1 + refreshTimeAct / 40000) - ); - if (refreshTimeAct > refreshTimeMax) { - refreshTimeAct = refreshTimeMax; - } - }, - error: stopTimer - }); - }, this); - - this.listenTo(this, 'change', function() { - resetRefreshTime(); - setTimer(); - } - ); - - if (immediate) { - updateAppStatus(); - } - - resetRefreshTime(); - setTimer(); - } - -}); - -_.extend(AppStatusModel.prototype, cakeRest); - -export default AppStatusModel; diff --git a/frontend/src/models/appStatus.ts b/frontend/src/models/appStatus.ts new file mode 100644 index 000000000..0fa6c5596 --- /dev/null +++ b/frontend/src/models/appStatus.ts @@ -0,0 +1,123 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import CakeRestModel from 'lib/saito/backbone.cakeRest'; +import _ from 'underscore'; + +class AppStatusModel extends CakeRestModel { + private stream!: EventSource; + + private settings: any; + + public initialize(attributes: any, options: any) { + super.initialize(attributes, options); + this.settings = options.settings; + this.methodToCakePhpUrl.read = 'status/'; + } + + public start(immediate = true) { + this.setWebroot(this.settings.get('webroot')); + // Don't use SSE by default on unknown server-configs + /* + if (!!window.EventSource) { + this._eventStream(); + return; + } + */ + // slow polling just to keep the user online + this._poll(90000, 180000, immediate); + } + + public setWebroot(webroot: string) { + this.webroot = webroot + 'status/'; + } + + /** + * Request status by server-sent events + */ + private eventStream() { + this.stream = new EventSource(this.webroot + this.methodToCakePhpUrl.read); + this.stream.addEventListener('message', (e) => { + /* @todo + if (e.origin != 'http://example.com') { + alert('Origin was not http://example.com'); + return; + } + */ + const data = JSON.parse(e.data); + this.set(data); + }, false); + } + + /** + * Requests status by polling with classic HTTP request. + * + * Adjust to sane values taking UserOnlineTable::setOnline() into account, so + * that users wont get set offline. Default current default values were great + * for a shoutbox like feature with immediate and reasonbly fast polling. + * + * The time between requests increases if the data from the server is + * unchanged. + * + * @param {int} refreshTimeBase - minimum and start time between request in ms + * @param {int} refreshTimeMax - maximum time between requests in ms + * @param {bool} immediate - first request immediately or after refreshTimeBase + */ + private _poll(refreshTimeBase = 10000, refreshTimeMax = 90000, immediate = true) { + let timerId: number; + let refreshTimeAct: number; + + const stopTimer = () => { + if (timerId !== undefined) { + window.clearTimeout(timerId); + } + }; + + const resetRefreshTime = () => { + stopTimer(); + refreshTimeAct = refreshTimeBase; + }; + + const setTimer = () => { + timerId = window.setTimeout( + updateAppStatus, + refreshTimeAct, + ); + }; + + const updateAppStatus = () => { + setTimer(); + this.fetch({ + success() { + refreshTimeAct = Math.floor( + refreshTimeAct * (1 + refreshTimeAct / 40000), + ); + if (refreshTimeAct > refreshTimeMax) { + refreshTimeAct = refreshTimeMax; + } + }, + error: stopTimer, + }); + }; + + this.listenTo(this, 'change', () => { + resetRefreshTime(); + setTimer(); + }, + ); + + if (immediate) { + updateAppStatus(); + } + + resetRefreshTime(); + setTimer(); + } +} + +export default AppStatusModel; diff --git a/frontend/src/models/currentUser.js b/frontend/src/models/currentUser.js deleted file mode 100644 index 8f767303f..000000000 --- a/frontend/src/models/currentUser.js +++ /dev/null @@ -1,37 +0,0 @@ -import _ from 'underscore'; -import Bb from 'backbone'; -import Bookmarks from 'modules/bookmarks/collections/bookmarksCl.ts'; - -export default Bb.Model.extend({ - bookmarks: null, - - /** - * Gets users bookmarks. - * - * Fetches the bookmarks from the server - * - * @param {object} options - * - {callback} success - * - {callback} error - * @returns {Backbone.Collection} bookmarks collection - * @public - */ - getBookmarks: function (options) { - _.defaults(options, { success: null, error: null }); - if (!this.bookmarks) { - this.bookmarks = new Bookmarks(); - this.bookmarks.fetch({ - success: options.success, - error: options.error, - }); - } else { - options.success.call(options.context, this.bookmarks, null, options); - } - return this.bookmarks; - }, - - isLoggedIn: function () { - return this.get('id') > 0; - } - -}); diff --git a/frontend/src/models/currentUser.ts b/frontend/src/models/currentUser.ts new file mode 100644 index 000000000..6ba6f4376 --- /dev/null +++ b/frontend/src/models/currentUser.ts @@ -0,0 +1,36 @@ +import { Model } from 'backbone'; +import _ from 'underscore'; +import BookmarksCl from '../modules/bookmarks/collections/bookmarksCl'; + +export default class extends Model { + private bookmarks!: BookmarksCl; + + /** + * Gets users bookmarks. + * + * Fetches the bookmarks from the server + * + * @param {object} options + * - {callback} success + * - {callback} error + * @returns {Backbone.Collection} bookmarks collection + */ + public getBookmarks(options: any) { + _.defaults(options, { success: null, error: null }); + if (!this.bookmarks) { + this.bookmarks = new BookmarksCl(); + this.bookmarks.fetch({ + error: options.error, + success: options.success, + }); + } else { + options.success.call(options.context, this.bookmarks, null, options); + } + return this.bookmarks; + } + + public isLoggedIn() { + return this.get('id') > 0; + } + +} diff --git a/frontend/src/models/threadline.js b/frontend/src/models/threadline.js deleted file mode 100644 index 8a8d3f9bd..000000000 --- a/frontend/src/models/threadline.js +++ /dev/null @@ -1,29 +0,0 @@ -import _ from 'underscore'; -import Backbone from 'backbone'; -import App from 'models/app'; -import cakeRest from 'lib/saito/backbone.cakeRest'; - -const ThreadLineModel = Backbone.Model.extend({ - - defaults: { - isInlineOpened: false, - shouldScrollOnInlineOpen: true, - isAlwaysShownInline: false, - isNewToUser: false, - posting: '', - html: '' - }, - - initialize: function () { - this.webroot = App.settings.get('webroot') + 'entries/'; - this.methodToCakePhpUrl = _.clone(this.methodToCakePhpUrl); - this.methodToCakePhpUrl.read = 'threadLine/'; - - this.set('isAlwaysShownInline', App.currentUser.get('user_show_inline') || false); - } - -}); - -_.extend(ThreadLineModel.prototype, cakeRest); - -export default ThreadLineModel; diff --git a/frontend/src/models/threadline.ts b/frontend/src/models/threadline.ts new file mode 100644 index 000000000..6c46e502c --- /dev/null +++ b/frontend/src/models/threadline.ts @@ -0,0 +1,27 @@ +import CakeRestModel from 'lib/saito/backbone.cakeRest'; +import App from 'models/app'; +import _ from 'underscore'; + +class ThreadLineModel extends CakeRestModel { + public constructor(options: any = {}) { + _.defaults(options, { + html: '', + isAlwaysShownInline: false, + isInlineOpened: false, + isNewToUser: false, + posting: '', + shouldScrollOnInlineOpen: true, + }); + super(options); + } + + public initialize(attributes: any, options: any) { + super.initialize(attributes, options); + this.webroot = App.settings.get('webroot') + 'entries/'; + this.methodToCakePhpUrl.read = 'threadline/'; + + this.set('isAlwaysShownInline', App.currentUser.get('user_show_inline') || false); + } +} + +export default ThreadLineModel; diff --git a/frontend/src/modules/answering/Draft.ts b/frontend/src/modules/answering/Draft.ts index 6d074f9ce..cb70fe077 100644 --- a/frontend/src/modules/answering/Draft.ts +++ b/frontend/src/modules/answering/Draft.ts @@ -19,11 +19,11 @@ interface ILongTimers { early: number; long: number; } class LongTimer { protected fct: () => void; - protected lastRun: number; + protected lastRun: number | null = null; - protected running: boolean; + protected running: boolean = false; - protected timerId: number; + protected timerId: number | undefined; protected timers: ILongTimers; @@ -80,14 +80,7 @@ class LongTimer { * Model for drafts */ class DraftModel extends AnswerModel { - /** - * Ma initializer - * - * @param options options - */ - public initialize(options) { - this.saitoUrl = 'drafts/'; - } + protected saitoUrl: string = 'drafts/'; } type DraftTimers = { debounce: number } & ILongTimers; @@ -97,9 +90,9 @@ type DraftTimers = { debounce: number } & ILongTimers; */ export default class DraftView extends View { /** Holds the main timer */ - public longTimer: LongTimer; + public longTimer!: LongTimer; /** Short timer to debounce the main timer */ - public shortTimer: () => void; + public shortTimer!: () => void; /** * Enables or disables the the sending of drafts. * diff --git a/frontend/src/modules/answering/Meta.ts b/frontend/src/modules/answering/Meta.ts index 41cd75d2c..757621481 100644 --- a/frontend/src/modules/answering/Meta.ts +++ b/frontend/src/modules/answering/Meta.ts @@ -33,16 +33,9 @@ interface IAnswerMetaData { } class MetaModel extends JsonApiModel { - public attributes: IAnswerMetaData; + public attributes!: IAnswerMetaData; - /** - * Ma initializer - * - * @param options options - */ - public initialize(options: object = {}) { - this.saitoUrl = 'postingmeta/'; - } + protected saitoUrl = 'postingmeta/'; } export { MetaModel }; diff --git a/frontend/src/modules/answering/answering.ts b/frontend/src/modules/answering/answering.ts index 7aceec626..58eedbc8c 100644 --- a/frontend/src/modules/answering/answering.ts +++ b/frontend/src/modules/answering/answering.ts @@ -27,7 +27,7 @@ import PreviewView from './views/PreviewVw'; import SubjectInputVw from './views/SubjectInputVw'; export default class AnsweringView extends View { - private errorVw: View; + private errorVw!: View; private loaded: boolean; @@ -115,14 +115,15 @@ export default class AnsweringView extends View { last: '.last', }, }); + super(options); - } - public initialize(options) { this.loaded = false; this.sendInProgress = false; - this.metaModel = this.getOption('meta'); + this.metaModel = options.meta; + } + public initialize(options: any) { /// init Cake Form Error View this.errorVw = new CakeFormErrorView({ el: this.$el }); } @@ -310,7 +311,7 @@ export default class AnsweringView extends View { * * @param errors errors object with validation errors from server */ - private onAnswerValidationError(errors?) { + private onAnswerValidationError(errors?: any) { this.errorVw.collection.reset(errors); this.errorVw.render(); diff --git a/frontend/src/modules/answering/buttons/CiteBtnVw.ts b/frontend/src/modules/answering/buttons/CiteBtnVw.ts index ef0dd9415..406e12e00 100644 --- a/frontend/src/modules/answering/buttons/CiteBtnVw.ts +++ b/frontend/src/modules/answering/buttons/CiteBtnVw.ts @@ -37,7 +37,9 @@ export default class CiteBtn extends View { // Without defering a click on a selection which deselects (and should therefore be empty) // still holds the previously selected text. _.defer(() => { - let text = window.getSelection().toString(); + const selection = window.getSelection(); + let text = selection ? selection.toString() : ''; + if (text !== '') { text = this.model.get('quoteSymbol') + ' ' + text; } else { diff --git a/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts b/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts index 57ceb5daf..de7951c57 100644 --- a/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts +++ b/frontend/src/modules/answering/buttons/EditCountdownBtnView.ts @@ -18,11 +18,11 @@ export default class EditCountdownView extends View { /** * time in seconds how long the timer should count down */ - private editEnd: number; + private editEnd!: number; - private buttonText: string; + private buttonText!: string; - private $countdownDummy: JQuery; + private $countdownDummy!: JQuery; private doneAction: string = 'remove'; @@ -36,7 +36,7 @@ export default class EditCountdownView extends View { * @param options * - startTime: Date - start time */ - public initialize(options) { + public initialize(options: any) { this.editEnd = moment(options.startTime).unix() + (App.settings.get('editPeriod') * 60); // this.editEnd = moment().unix() + 5 ; // debug @@ -52,11 +52,11 @@ export default class EditCountdownView extends View { this._start(); } - private _setButtonText(timeText) { + private _setButtonText(timeText: string) { this.$el.text(this.buttonText + ' ' + timeText); } - private _onTick(remaining) { + private _onTick(remaining: TinyTimer.TinyTimerCallbackArgs) { if (remaining.m > 1 || (remaining.m === 1 && remaining.s > 30)) { remaining.m = remaining.m + 1; this._setButtonText('(' + remaining.m + ' min)'); diff --git a/frontend/src/modules/answering/editor/Menu/LinkView.ts b/frontend/src/modules/answering/editor/Menu/LinkView.ts index b9f9633a3..78abe5328 100644 --- a/frontend/src/modules/answering/editor/Menu/LinkView.ts +++ b/frontend/src/modules/answering/editor/Menu/LinkView.ts @@ -26,7 +26,7 @@ class LinkView extends View { this.showDialog(); } - public onKeypress(event) { + public onKeypress(event: KeyboardEvent) { if (event.keyCode === 13) { this.insert(); } diff --git a/frontend/src/modules/answering/editor/Menu/MediaInsertView.ts b/frontend/src/modules/answering/editor/Menu/MediaInsertView.ts index 1ec965ea5..1de63bc26 100644 --- a/frontend/src/modules/answering/editor/Menu/MediaInsertView.ts +++ b/frontend/src/modules/answering/editor/Menu/MediaInsertView.ts @@ -26,7 +26,7 @@ class MediaInsertView extends View { this._showDialog(); } - private _insert(event) { + private _insert(event: Event) { event.preventDefault(); const markupMedia = new MarkupMultimedia(); diff --git a/frontend/src/modules/answering/editor/Menu/Smilies.ts b/frontend/src/modules/answering/editor/Menu/Smilies.ts index 62679009c..c9c9ae595 100644 --- a/frontend/src/modules/answering/editor/Menu/Smilies.ts +++ b/frontend/src/modules/answering/editor/Menu/Smilies.ts @@ -4,7 +4,7 @@ import App from 'models/app'; import * as _ from 'underscore'; class SmiliesCollection extends Collection { - public modelId(attributes) { + public modelId(attributes: any) { // Collection is filled with all codes for all smilies. // "icon" is unique for smilies on all codes so only one smiley // per code is put into the collection. diff --git a/frontend/src/modules/answering/editor/MenuButton/AbstractMenuButtonView.ts b/frontend/src/modules/answering/editor/MenuButton/AbstractMenuButtonView.ts index acca79d52..0fad16802 100644 --- a/frontend/src/modules/answering/editor/MenuButton/AbstractMenuButtonView.ts +++ b/frontend/src/modules/answering/editor/MenuButton/AbstractMenuButtonView.ts @@ -30,7 +30,7 @@ abstract class AbstractMenuButtonView extends View { `); } - protected abstract handleButton(); + protected abstract handleButton(): void; } export { AbstractMenuButtonView }; diff --git a/frontend/src/modules/answering/editor/MenuButtonBarView.ts b/frontend/src/modules/answering/editor/MenuButtonBarView.ts index 708766f4c..f37bc3d61 100644 --- a/frontend/src/modules/answering/editor/MenuButtonBarView.ts +++ b/frontend/src/modules/answering/editor/MenuButtonBarView.ts @@ -21,7 +21,7 @@ enum MenuButtonType { class MenuButtonBarView extends CollectionView> { public constructor(options: any = {}) { _.defaults(options, { - childView: (model) => { + childView: (model: Model) => { const type = model.get('type'); switch (type) { case MenuButtonType.enclose: diff --git a/frontend/src/modules/answering/models/AnswerModel.ts b/frontend/src/modules/answering/models/AnswerModel.ts index fe6f21609..051aaa26c 100644 --- a/frontend/src/modules/answering/models/AnswerModel.ts +++ b/frontend/src/modules/answering/models/AnswerModel.ts @@ -6,20 +6,11 @@ * @license http://opensource.org/licenses/MIT */ -import { ModelSaveOptions } from 'backbone'; import PostingModel from 'models/PostingMdl'; -import { defaults } from 'underscore'; /** * Stores all data required to send a new posting to the server */ export default class AnswerModel extends PostingModel { - /** - * Ma initializer - * - * @param options options - */ - public initialize(options) { - this.saitoUrl = 'postings/'; - } + protected saitoUrl = 'postings/'; } diff --git a/frontend/src/modules/answering/models/PreviewModel.ts b/frontend/src/modules/answering/models/PreviewModel.ts index dc61c51f8..3e7867efb 100644 --- a/frontend/src/modules/answering/models/PreviewModel.ts +++ b/frontend/src/modules/answering/models/PreviewModel.ts @@ -13,6 +13,7 @@ import AnswerModel from './AnswerModel'; * Stores all data required to send a new posting to the server */ export default class PreviewModel extends AnswerModel { + protected saitoUrl: string; /** * Constructor * @@ -22,10 +23,9 @@ export default class PreviewModel extends AnswerModel { _defaults(defaults, { html: undefined, }); + super(defaults, options); - } - public initialize(options) { this.saitoUrl = 'preview/preview'; } } diff --git a/frontend/src/modules/answering/views/PreviewVw.ts b/frontend/src/modules/answering/views/PreviewVw.ts index 2b2a9a3a3..4b863fad8 100644 --- a/frontend/src/modules/answering/views/PreviewVw.ts +++ b/frontend/src/modules/answering/views/PreviewVw.ts @@ -14,8 +14,6 @@ import AnswerModel from '../models/AnswerModel'; import PreviewModel from '../models/PreviewModel'; export default class PreviewView extends View { - protected template; - constructor(options: any = {}) { options = _.extend(options, { className: 'preview-wrapper', diff --git a/frontend/src/modules/answering/views/SubjectInputVw.ts b/frontend/src/modules/answering/views/SubjectInputVw.ts index 4fa85c50b..80587ab2a 100644 --- a/frontend/src/modules/answering/views/SubjectInputVw.ts +++ b/frontend/src/modules/answering/views/SubjectInputVw.ts @@ -7,7 +7,7 @@ */ import { Model } from 'backbone'; -import { View } from 'backbone.marionette'; +import { View, ViewOptions } from 'backbone.marionette'; import * as _ from 'underscore'; class SubjectInputModel extends Model { @@ -49,10 +49,17 @@ enum ProgressBarState { full = 'bg-danger', } +interface ISubjectOptions extends ViewOptions { + /** Subject max length */ + max?: number; + /** Placeholder for the subject */ + placeholder: string; +} + export default class SubjectInputView extends View { - private stateModel: SubjectInputModel; + private stateModel!: SubjectInputModel; - public constructor(options: any = {}) { + public constructor(options: ISubjectOptions) { _.defaults(options, { className: 'postingform-subject-wrapper form-group', events: { @@ -93,10 +100,11 @@ export default class SubjectInputView extends View { progressBar: '.js-progress', }, }); + super(options); } - public initialize(options) { + public initialize(options: ISubjectOptions) { this.stateModel = new SubjectInputModel(); if (options.max) { this.stateModel.set('max', options.max); @@ -154,7 +162,7 @@ export default class SubjectInputView extends View { this.setProgress(cssClass); } - private handleKeypress(event) { + private handleKeypress(event: KeyboardEvent) { if (event.keyCode === 13) { event.preventDefault(); this.trigger('answer:send:submit'); @@ -176,7 +184,9 @@ export default class SubjectInputView extends View { private setProgress(cssClass: ProgressBarState) { const $progress = this.getUI('progressBar'); Object.keys(ProgressBarState).forEach((key) => { - $progress.removeClass(ProgressBarState[key]); + // @td bogus + const k = key as unknown as number; + $progress.removeClass(ProgressBarState[k]); }); $progress.addClass(cssClass); } diff --git a/frontend/src/modules/bookmarks/bookmarksModule.ts b/frontend/src/modules/bookmarks/bookmarksModule.ts index c068572e5..439d563d3 100644 --- a/frontend/src/modules/bookmarks/bookmarksModule.ts +++ b/frontend/src/modules/bookmarks/bookmarksModule.ts @@ -1,9 +1,9 @@ -import * as Bb from 'backbone'; import * as Mn from 'backbone.marionette'; import * as $ from 'jquery'; import App from 'models/app'; import * as _ from 'underscore'; import { SpinnerView } from 'views/SpinnerView'; +import BookmarksCl from './collections/bookmarksCl'; import BookmarksView from './views/bookmarksVw'; export default class extends Mn.View { @@ -40,7 +40,7 @@ export default class extends Mn.View { }; App.eventBus.trigger('notification', notification); }, - success: (collection) => { + success: (collection: BookmarksCl) => { const clV = new BookmarksView({ collection }); this.showChildView('rgBookmarks', clV); }, diff --git a/frontend/src/modules/bookmarks/collections/bookmarksCl.ts b/frontend/src/modules/bookmarks/collections/bookmarksCl.ts index 40a7c19c8..aa4832cf6 100644 --- a/frontend/src/modules/bookmarks/collections/bookmarksCl.ts +++ b/frontend/src/modules/bookmarks/collections/bookmarksCl.ts @@ -8,7 +8,7 @@ export default class extends JsonApiCollection { protected saitoUrl = 'bookmarks/'; /** Bb comparator */ - public comparator = (model) => { + public comparator = (model: BookmarkModel) => { return -1 * model.get('id'); } } diff --git a/frontend/src/modules/bookmarks/views/bookmarkCommentVw.ts b/frontend/src/modules/bookmarks/views/bookmarkCommentVw.ts index a76797df3..d1079f135 100644 --- a/frontend/src/modules/bookmarks/views/bookmarkCommentVw.ts +++ b/frontend/src/modules/bookmarks/views/bookmarkCommentVw.ts @@ -6,7 +6,7 @@ import * as Tpl from '../templates/bookmarkCommentTpl.html'; * Comment as input */ export class CommentInputView extends View { - constructor(options) { + constructor(options: any) { options.template = Tpl; options.className = 'm-1'; options.ui = { @@ -20,7 +20,7 @@ export class CommentInputView extends View { public onRender() { this.getUI('text').focus(); } - protected handleKeypress(event) { + protected handleKeypress(event: Event) { event.preventDefault(); this.model.set('comment', this.getUI('text').val()); } diff --git a/frontend/src/modules/bookmarks/views/bookmarkItemVw.ts b/frontend/src/modules/bookmarks/views/bookmarkItemVw.ts index 02a7b6494..cdb2f3dc1 100644 --- a/frontend/src/modules/bookmarks/views/bookmarkItemVw.ts +++ b/frontend/src/modules/bookmarks/views/bookmarkItemVw.ts @@ -10,7 +10,7 @@ import { CommentInputView } from './bookmarkCommentVw'; * Comment as text */ class CommentTextView extends Mn.View { - constructor(options) { + constructor(options: any) { options.template = _.template('<%- comment %>'); options.className = 'm-1'; super(options); diff --git a/frontend/src/modules/modalDialog/modalDialog.js b/frontend/src/modules/modalDialog/modalDialog.js deleted file mode 100644 index bf9ee4968..000000000 --- a/frontend/src/modules/modalDialog/modalDialog.js +++ /dev/null @@ -1,70 +0,0 @@ -import _ from 'underscore'; -import App from 'models/app'; -import Backbone from 'backbone'; -import Marionette from 'backbone.marionette'; -import Tpl from 'modules/modalDialog/templates/modalDialog.html'; - -const dialog = Marionette.View.extend({ - // el: '#saito-modal-dialog', - - defaults: { - width: 'normal', - }, - - template: Tpl, - - regions: { - content: '#saito-modal-dialog-content', - }, - - initialize: function () { - this.model = new Backbone.Model({ title: '' }); - }, - - /** - * Shows modal dialog with content - * - * @param {Marionette.View} content - * @param {Object} - */ - show: function (content, options) { - options = _.defaults(options, this.defaults); - this.model.set('title', options['title'] || ''); - this.render(); - - // puts content into dialog - this.showChildView('content', content); - - this.setWidth(options.width); - - // shows BS dialog - this.$el.parent().on('shown.bs.modal', () => { - App.eventBus.trigger('app:modal:shown'); - this.triggerMethod('shown'); - }); - this.$el.parent().modal('show'); - }, - - hide: function () { - this.$el.parent().modal('hide'); - }, - - setWidth: function (width) { - switch (width) { - case 'max': - this.$('.modal-dialog').css('max-width', '95%'); - break; - default: - this.$('.modal-dialog').css('max-width', ''); - } - }, - - invalidInput() { - this.$el.addClass('animation shake'); - _.delay(() => { - this.$el.removeClass('animation shake', 1000); - }); - } -}); - -export default new dialog(); diff --git a/frontend/src/modules/modalDialog/modalDialog.ts b/frontend/src/modules/modalDialog/modalDialog.ts new file mode 100644 index 000000000..72b4644b4 --- /dev/null +++ b/frontend/src/modules/modalDialog/modalDialog.ts @@ -0,0 +1,75 @@ +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import App from 'models/app'; +import Tpl from 'modules/modalDialog/templates/modalDialog.html'; +import _ from 'underscore'; + +class ModalDialogView extends View { + // el: '#saito-modal-dialog', + + private defaults: object; + + public constructor(options: any = {}) { + _.defaults(options, { + regions: { + content: '#saito-modal-dialog-content', + }, + template: Tpl, + }); + super(options); + this.defaults = { + width: 'normal', + }; + } + + public initialize() { + this.model = new Model({ title: '' }); + } + + /** + * Shows modal dialog with content + * + * @param {Marionette.View} content + * @param {Object} + */ + public show(content: View, options: any) { + options = _.defaults(options, this.defaults); + this.model.set('title', options.title || ''); + this.render(); + + // puts content into dialog + this.showChildView('content', content); + + this.setWidth(options.width); + + // shows BS dialog + this.$el.parent().on('shown.bs.modal', () => { + App.eventBus.trigger('app:modal:shown'); + this.triggerMethod('shown'); + }); + this.$el.parent().modal('show'); + } + + public hide() { + this.$el.parent().modal('hide'); + } + + public invalidInput() { + this.$el.addClass('animation shake'); + _.delay(() => { + this.$el.removeClass('animation shake', 1000); + }); + } + + private setWidth(width: string) { + switch (width) { + case 'max': + this.$('.modal-dialog').css('max-width', '95%'); + break; + default: + this.$('.modal-dialog').css('max-width', ''); + } + } +} + +export default new ModalDialogView(); diff --git a/frontend/src/modules/notification/html5-notification.js b/frontend/src/modules/notification/html5-notification.js deleted file mode 100644 index 56ac6667a..000000000 --- a/frontend/src/modules/notification/html5-notification.js +++ /dev/null @@ -1,57 +0,0 @@ -import App from 'models/app'; - -export default { - /** - * hides notification after this seconds - */ - _hideAfter: 10, - - start: function () { - App.eventBus.reply('app:html5-notification:activate', this._activate, this); - App.eventBus.on('app:html5-notification:available', this._isEnabled, this); - App.eventBus.on('html5-notification', this.notification); - }, - - notification: function (data) { - var _isAppHidden = !App.eventBus.request('isAppVisible'); - data = _.defaults(data, { - icon: App.settings.get('notificationIcon'), - always: false - }); - - if (data.always || _isAppHidden) { - var notification = new window.Notification(data.title, { - icon: data.icon, - body: data.message - }); - - // prevents chrome to keep the notification on screen endlessly - var isChrome = navigator.userAgent.toLowerCase().indexOf('chrome') > -1; - if (isChrome) { - setTimeout(function () { - notification.close(); - }, this._hideAfter * 1000); - } - } - }, - - _activate: function () { - // Chrome does not support window.Notification.permission as of Chrome 30 - if ("permission" in window.Notification && window.Notification.permission !== 'granted') { - window.Notification.requestPermission(); - return; - } else { - window.Notification.requestPermission(); - } - - }, - - _isEnabled: function () { - if ("Notification" in window) { - return true; - } else { - return false; - } - } - -}; diff --git a/frontend/src/modules/notification/html5-notification.ts b/frontend/src/modules/notification/html5-notification.ts new file mode 100644 index 000000000..30f340287 --- /dev/null +++ b/frontend/src/modules/notification/html5-notification.ts @@ -0,0 +1,59 @@ +import App from 'models/app'; +import _ from 'underscore'; + +class Html5Notification { + /** + * hides notification after this seconds + */ + private hideAfter: number = 10; + + public constructor() { + App.eventBus.reply('app:html5-notification:activate', this.activate, this); + App.eventBus.on('app:html5-notification:available', this.isEnabled, this); + App.eventBus.on('html5-notification', this.notification); + } + + public notification(data: {always: boolean, icon: string, message: string, title: string}) { + const isAppHidden = !App.eventBus.request('isAppVisible'); + data = _.defaults(data, { + always: false, + icon: App.settings.get('notificationIcon'), + }); + + if (data.always || isAppHidden) { + const notification = new Notification(data.title, { + body: data.message, + icon: data.icon, + }); + + // prevents chrome to keep the notification on screen endlessly + const isChrome = navigator.userAgent.toLowerCase().indexOf('chrome') > -1; + if (isChrome) { + setTimeout(() => notification.close(), this.hideAfter * 1000); + } + } + } + + private activate() { + // Chrome does not support window.Notification.permission as of Chrome 30 + if ('permission' in Notification && Notification.permission !== 'granted') { + Notification.requestPermission(); + return; + } else { + Notification.requestPermission(); + } + + } + + private isEnabled() { + if ('Notification' in window) { + return true; + } else { + return false; + } + } +} + +const instance = new Html5Notification(); + +export default instance; diff --git a/frontend/src/modules/notification/notification.ts b/frontend/src/modules/notification/notification.ts index 45036c730..67727c6ee 100644 --- a/frontend/src/modules/notification/notification.ts +++ b/frontend/src/modules/notification/notification.ts @@ -121,7 +121,7 @@ export default class NotificationsView extends Mn.View { */ private showMessages(message: INotification | INotification[]) { if (Array.isArray(message)) { - _.each(message, function(msg) { + _.each(message, (msg) => { this.showMessages(msg); }, this); @@ -144,7 +144,7 @@ export default class NotificationsView extends Mn.View { text: $.i18n.__(message.message.trim()), title: message.title || '', }; - const type: string = message.type; + const type = message.type; switch (type) { case 'success': diff --git a/frontend/src/modules/posting/Geshi.ts b/frontend/src/modules/posting/Geshi.ts index 107d57433..1ac21a4d6 100644 --- a/frontend/src/modules/posting/Geshi.ts +++ b/frontend/src/modules/posting/Geshi.ts @@ -12,15 +12,16 @@ class GeshiModel extends Model { } class GeshiView extends View { - public block: JQuery; - public htmlText; - public plainText; + public block!: JQuery; + public htmlText!: string | undefined; + public plainText!: string | undefined; public constructor(options: any = {}) { _.defaults(options, { events: { 'click .geshi-plain-text': 'togglePlaintext', }, + model: new GeshiModel(), template: _.noop, }); super(options); @@ -29,8 +30,6 @@ class GeshiView extends View { public initialize() { this.model = new GeshiModel(); this.block = this.$('.geshi-plain-text').next(); - this.plainText = false; - this.htmlText = false; this.setPlaintextButton(); @@ -49,13 +48,13 @@ class GeshiView extends View { this.$('.geshi-plain-text').html(''); } - private togglePlaintext(event) { + private togglePlaintext(event: Event) { event.preventDefault(); this.model.set('isPlaintext', !this.model.get('isPlaintext')); } private extractPlaintext() { - if (this.plainText !== false) { + if (this.plainText !== undefined) { return; } this.htmlText = this.block.html(); @@ -68,9 +67,9 @@ class GeshiView extends View { } private renderText() { - if (this.model.get('isPlaintext')) { + if (this.model.get('isPlaintext') && this.plainText) { this.block.text(this.plainText).wrapInner('
');
-        } else {
+        } else if (this.htmlText) {
             this.block.html(this.htmlText);
         }
     }
diff --git a/frontend/src/modules/posting/models/PostingModel.ts b/frontend/src/modules/posting/models/PostingModel.ts
index 407402b3a..6638173ae 100644
--- a/frontend/src/modules/posting/models/PostingModel.ts
+++ b/frontend/src/modules/posting/models/PostingModel.ts
@@ -22,7 +22,7 @@ class PostingModel extends Model {
         this.listenTo(this, 'change:isSolves', this.syncSolved);
     }
 
-    public fetchHtml(options) {
+    public fetchHtml(options: any) {
         $.ajax({
             dataType: 'html',
             success: (data) => {
diff --git a/frontend/src/modules/posting/postingContent.ts b/frontend/src/modules/posting/postingContent.ts
index 24662ac25..1fee8f751 100644
--- a/frontend/src/modules/posting/postingContent.ts
+++ b/frontend/src/modules/posting/postingContent.ts
@@ -1,9 +1,9 @@
-import { Model } from 'backbone';
 import { View } from 'backbone.marionette';
 import * as _ from 'underscore';
+import { PostingModel } from './models/PostingModel';
 import { PostingRichtextView } from './postingRichtext';
 
-class PostingContentView extends View {
+class PostingContentView extends View {
     public constructor(options: any = {}) {
         _.defaults(options, {
             regions: {
diff --git a/frontend/src/modules/posting/postingLayout.ts b/frontend/src/modules/posting/postingLayout.ts
index 6d4010eab..79f247b57 100644
--- a/frontend/src/modules/posting/postingLayout.ts
+++ b/frontend/src/modules/posting/postingLayout.ts
@@ -1,5 +1,6 @@
 import EventBus from 'app/vent';
 import { View, ViewOptions } from 'backbone.marionette';
+import ThreadLineModel from 'models/threadline';
 import { PostingModel } from 'modules/posting/models/PostingModel';
 import * as _ from 'underscore';
 import ActionView from 'views/postingAction';
@@ -7,22 +8,22 @@ import SliderView from 'views/PostingSliderView';
 import { PostingContentView } from './postingContent';
 
 interface IPostingLayoutViewOptions extends ViewOptions {
-    parentThreadline: PostingModel;
+    model: PostingModel;
+    parentThreadline?: ThreadLineModel | null;
 }
 
 class PostingLayoutView extends View {
-    private parentThreadline: PostingModel;
+    private parentThreadline: ThreadLineModel | null;
 
     public constructor(options: IPostingLayoutViewOptions) {
         _.defaults(options, {
-            parentThreadline: false,
+            parentThreadline: null,
             template: _.noop,
         });
+
         super(options);
-    }
 
-    public initialize(options) {
-        this.parentThreadline = options.parentThreadline;
+        this.parentThreadline = options.parentThreadline || null;
     }
 
     public onRender() {
diff --git a/frontend/src/modules/posting/postingRichtext.ts b/frontend/src/modules/posting/postingRichtext.ts
index b617dd40a..48b5a7a94 100644
--- a/frontend/src/modules/posting/postingRichtext.ts
+++ b/frontend/src/modules/posting/postingRichtext.ts
@@ -33,7 +33,7 @@ class PostingRichtextView extends View {
             return;
         }
         elements.each((key, element) => {
-            const id = element.getAttribute('id');
+            const id = element.getAttribute('id') as string;
             const data = $(element).data('embed');
 
             this.addRegion(id, { el: '#' + id, replaceElement: true });
diff --git a/frontend/src/modules/posting/postingRichtextEmbed.ts b/frontend/src/modules/posting/postingRichtextEmbed.ts
index fdecaf1b9..32df18a29 100644
--- a/frontend/src/modules/posting/postingRichtextEmbed.ts
+++ b/frontend/src/modules/posting/postingRichtextEmbed.ts
@@ -23,13 +23,13 @@ class PostingRichtextEmbedView extends View {
     public onRender() {
         const html = this.model.get('html');
         if (html) {
-            //// append included script tags so that they are executed
+            /// append included script tags so that they are executed
             const scriptTags = html.match(/([\s\S]*?)<\/script>/g);
             this.$el.html(html);
             _.each(scriptTags, (scriptTag: string) => {
                 // find src-attribute in script-tag
                 const src = scriptTag.match(//);
-                if (!(1 in src)) {
+                if (!src) {
                     return;
                 }
                 const executedScriptTag = document.createElement('script');
diff --git a/frontend/src/modules/slidetabs/slidetab.ts b/frontend/src/modules/slidetabs/slidetab.ts
index 86bb196a1..353be14c4 100644
--- a/frontend/src/modules/slidetabs/slidetab.ts
+++ b/frontend/src/modules/slidetabs/slidetab.ts
@@ -32,7 +32,7 @@ class SlidetabCollection extends Collection {
 }
 
 class SlidetabView extends View {
-    public constructor(options) {
+    public constructor(options: any) {
         _.defaults(options, {
             modelEvents: {
                 'change:isOpen': 'toggleSlidetab',
diff --git a/frontend/src/modules/thread/thread.ts b/frontend/src/modules/thread/thread.ts
index bcd74d65f..efa2fccf5 100644
--- a/frontend/src/modules/thread/thread.ts
+++ b/frontend/src/modules/thread/thread.ts
@@ -8,9 +8,9 @@ import 'lib/saito/localStorageHelper';
 import App from 'models/app';
 
 class ThreadModel extends Model {
-    protected threadlines;
+    public threadlines!: ThreadLinesCollection;
 
-    public constructor(attributes, options) {
+    public constructor(attributes?: any, options?: any) {
         _.defaults(options, {
             defaults: {
                 isThreadCollapsed: false,
@@ -42,4 +42,4 @@ class ThreadCollection extends Collection {
 
 }
 
-export { ThreadCollection };
+export { ThreadCollection, ThreadModel };
diff --git a/frontend/src/modules/uploader/collections/uploads.ts b/frontend/src/modules/uploader/collections/uploads.ts
index 2129340a7..534d4def4 100644
--- a/frontend/src/modules/uploader/collections/uploads.ts
+++ b/frontend/src/modules/uploader/collections/uploads.ts
@@ -11,7 +11,7 @@ export default class extends JsonApiCollection {
     protected saitoUrl = 'uploads/';
 
     /** Bb comparator */
-    public comparator = (model) => {
+    public comparator = (model: UploadsModel) => {
         // sort by latest first (negate ID for DESC)
         return -1 * model.get('id');
     }
diff --git a/frontend/src/modules/uploader/views/uploaderAddVw.ts b/frontend/src/modules/uploader/views/uploaderAddVw.ts
index 3f545e6de..0221764f6 100644
--- a/frontend/src/modules/uploader/views/uploaderAddVw.ts
+++ b/frontend/src/modules/uploader/views/uploaderAddVw.ts
@@ -33,7 +33,7 @@ class UploaderAddVw extends View {
         this.initDropUploader();
     }
 
-    private uploadManual(event) {
+    private uploadManual(event: Event) {
         event.preventDefault();
 
         const formData = new FormData();
@@ -49,7 +49,7 @@ class UploaderAddVw extends View {
     /**
      * Sends form-data via ajax
      */
-    private send(formData) {
+    private send(formData: FormData) {
         this.showChildView('spinner', new SpinnerView());
 
         const xhr = new XMLHttpRequest();
@@ -70,7 +70,7 @@ class UploaderAddVw extends View {
             this.render();
 
             if (('' + xhr.status)[0] !== '2') {
-                let msg = null;
+                let msg;
                 try {
                     msg = JSON.parse(xhr.responseText).errors[0].title;
                 } catch (e) {
@@ -101,24 +101,26 @@ class UploaderAddVw extends View {
         this.getUI('heading').html($.i18n.__('upl.new.title'));
     }
 
-    private handleDrop(event) {
+    private handleDrop(event: JQueryEventObject) {
         this.handleDragLeave(event);
-        const files = event.originalEvent.dataTransfer.files;
+        const orgEvent = event.originalEvent as DragEvent;
+        if (!orgEvent.dataTransfer) {
+            return;
+        }
+
+        const files = orgEvent.dataTransfer.files;
         const formData = new FormData();
-        formData.append(
-            'upload[0][file]',
-            files[0],
-        );
+        formData.append('upload[0][file]', files[0]);
 
         this.send(formData);
     }
 
-    private handleDragOver(event) {
+    private handleDragOver(event: Event) {
         event.preventDefault();
         this.getUI('indicator').removeClass('fadeOut').addClass('fadeIn');
     }
 
-    private handleDragLeave(event) {
+    private handleDragLeave(event: Event) {
         event.preventDefault();
         this.getUI('indicator').removeClass('fadeIn').addClass('fadeOut');
     }
diff --git a/frontend/src/modules/uploader/views/uploaderCollectionVw.ts b/frontend/src/modules/uploader/views/uploaderCollectionVw.ts
index 1049711a3..3215fa493 100644
--- a/frontend/src/modules/uploader/views/uploaderCollectionVw.ts
+++ b/frontend/src/modules/uploader/views/uploaderCollectionVw.ts
@@ -8,8 +8,10 @@ import { NoContentView as EmptyView } from 'views/NoContentView';
 import UploaderItemVw from './uploaderItemVw';
 
 class UploaderClVw extends CollectionView, Collection> {
+    private blazy!: BlazyInstance;
 
     private throttledLoader: any;
+
     public constructor(options: any = {}) {
         _.defaults(options, {
             childView: UploaderItemVw,
@@ -39,7 +41,7 @@ class UploaderClVw extends CollectionView, Collection> {
      */
     public initLazyLoading() {
         if (!this.throttledLoader) {
-            this.throttledLoader = _.throttle(_.bind(function() {
+            this.throttledLoader = _.throttle(() => {
                 // Uploader is displayed in modal dialog which isn't fully shown yet.
                 // Blazy doesn't see those images and wont load them.
                 const isVisisble = $('.imageUploader:visible').length > 0;
@@ -61,7 +63,7 @@ class UploaderClVw extends CollectionView, Collection> {
                         $(el).parent().parent().find('.image-uploader-spinner').remove();
                     },
                 });
-            }, this), 300);
+            }, 300);
         }
 
         this.throttledLoader();
diff --git a/frontend/src/modules/uploader/views/uploaderItemFooterVw.ts b/frontend/src/modules/uploader/views/uploaderItemFooterVw.ts
index 77b3ca49a..0a3d208aa 100644
--- a/frontend/src/modules/uploader/views/uploaderItemFooterVw.ts
+++ b/frontend/src/modules/uploader/views/uploaderItemFooterVw.ts
@@ -34,11 +34,11 @@ class UploaderItemFooterVw extends View {
     /**
      * deletes upload
      */
-    private handleDelete(event) {
+    private handleDelete(event: Event) {
         event.preventDefault();
 
         this.model.destroy({
-            error: (model, response) => {
+            error: (model, response: any) => {
                 const msg = response.responseJSON.errors[0];
                 App.eventBus.trigger('notification', { message: msg, type: 'error' });
             },
diff --git a/frontend/src/modules/user/userVw.ts b/frontend/src/modules/user/userVw.ts
index 3c8e4e3f5..ffc62ca4a 100644
--- a/frontend/src/modules/user/userVw.ts
+++ b/frontend/src/modules/user/userVw.ts
@@ -63,14 +63,14 @@ export default class extends Mn.View {
         };
     }
 
-    private handleBtnBookmarks(event) {
+    private handleBtnBookmarks() {
         if (!this.getRegion('rgBookmarks').hasView()) {
             this.showChildView('rgBookmarks', new BookmarksVw());
         }
         this.scrollToTop();
     }
 
-    private handleBtnUploads(event) {
+    private handleBtnUploads() {
         if (!this.getRegion('rgUploads').hasView()) {
             this.showChildView('rgUploads', new UploaderVw());
         }
diff --git a/frontend/src/views/PostingSliderView.ts b/frontend/src/views/PostingSliderView.ts
index 83484742e..503a36166 100644
--- a/frontend/src/views/PostingSliderView.ts
+++ b/frontend/src/views/PostingSliderView.ts
@@ -12,7 +12,7 @@ import App from 'models/app';
 import AnsweringView from 'modules/answering/answering';
 import AnswerModel from 'modules/answering/models/AnswerModel';
 import * as _ from 'underscore';
-import { SpinnerView } from 'views/SpinnerView';
+import { PostingModel } from '../modules/posting/models/PostingModel';
 
 /**
  * Slider beneath a posting which holds the answering form
@@ -20,7 +20,7 @@ import { SpinnerView } from 'views/SpinnerView';
 export default class Marionette extends View {
     public answeringForm: boolean;
 
-    public parentThreadline;
+    public parentThreadline: PostingModel | null;
 
     public constructor(options: any = {}) {
         _.defaults(options, {
@@ -40,13 +40,14 @@ export default class Marionette extends View {
                 btnClose: '.js-btnAnsweringClose',
             },
         });
+
         super(options);
-    }
 
-    public initialize(options) {
         this.answeringForm = false;
         this.parentThreadline = options.parentThreadline || null;
+    }
 
+    public initialize(options: any) {
         this.listenTo(this.model, 'change:isAnsweringFormShown', this.toggleAnsweringForm);
     }
 
@@ -71,18 +72,13 @@ export default class Marionette extends View {
             this.model.set({ isAnsweringFormShown: false });
 
             this.parentThreadline.set('isInlineOpened', false);
-            App.eventBus.trigger('newEntry', {
-                id,
-                isNewToUser: true,
-                pid: model.get('pid'),
-                tid: model.get('tid'),
-            });
+            App.eventBus.trigger('newEntry', model);
 
             return;
         }
 
         /// redirect
-        let action: string = App.request.action;
+        let action =  App.request.getAction();
 
         switch (action) {
             case ('mix'):
@@ -136,7 +132,7 @@ export default class Marionette extends View {
 
     private hideAllAnsweringForms() {
         // we have #id problems with more than one markItUp on a page
-        this.collection.forEach(function(posting) {
+        this.collection.forEach((posting) => {
             if (posting.get('id') !== this.model.get('id')) {
                 posting.set('isAnsweringFormShown', false);
             }
diff --git a/frontend/src/views/ThreadLineView.ts b/frontend/src/views/ThreadLineView.ts
index b790ce5a9..776225cc6 100644
--- a/frontend/src/views/ThreadLineView.ts
+++ b/frontend/src/views/ThreadLineView.ts
@@ -1,5 +1,5 @@
 import { View } from 'backbone.marionette';
-import * as PostingCollection from 'collections/postings';
+import PostingCollection from 'collections/postings';
 import $ from 'jquery';
 import 'lib/saito/jquery.scrollIntoView';
 import App from 'models/app';
@@ -10,12 +10,12 @@ import threadlineSpinnerTpl from 'templates/threadline-spinner.html';
 import _ from 'underscore';
 import { SpinnerView } from 'views/SpinnerView';
 
-class ThreadLineView extends View {
+class ThreadLineView extends View {
     private postings: PostingCollection;
 
-    private postingModel: PostingModel;
+    private postingModel!: PostingModel;
 
-    private spinnerTpl;
+    private spinnerTpl: string;
 
     public constructor(options: any = {}) {
         _.defaults(options, {
@@ -37,13 +37,12 @@ class ThreadLineView extends View {
             },
         });
         super(options);
-    }
-
-    public initialize(options) {
-        this.postings = options.postings;
 
         this.spinnerTpl = options.spinnerTpl;
+        this.postings = options.postings;
+    }
 
+    public initialize(options: any) {
         this.model = new ThreadLineModel({
             id: options.leafData.id,
             isNewToUser: options.leafData.isNewToUser,
@@ -70,7 +69,7 @@ class ThreadLineView extends View {
         }
     }
 
-    private toggleInlineOpenFromLink(event) {
+    private toggleInlineOpenFromLink(event: Event) {
         if (this.model.get('isAlwaysShownInline')) {
             this.toggleInlineOpen(event);
         }
@@ -79,12 +78,12 @@ class ThreadLineView extends View {
     /**
      * shows and hides the element that contains an inline posting
      */
-    private toggleInlineOpen(event) {
+    private toggleInlineOpen(event: Event) {
         event.preventDefault();
         this.model.toggle('isInlineOpened');
     }
 
-    private _toggleInlineOpened(model, isInlineOpened) {
+    private _toggleInlineOpened(model: PostingModel, isInlineOpened: boolean) {
         if (!isInlineOpened) {
             this._closeInlineView();
             return;
diff --git a/frontend/src/views/app.js b/frontend/src/views/app.js
deleted file mode 100644
index 4f7b63d52..000000000
--- a/frontend/src/views/app.js
+++ /dev/null
@@ -1,300 +0,0 @@
-import $ from 'jquery';
-import _ from 'underscore';
-import Bb from 'backbone';
-import Marionette from 'backbone.marionette';
-import AnsweringView from 'modules/answering/answering.ts';
-import AnswerModel from 'modules/answering/models/AnswerModel.ts';
-import App from 'models/app';
-import CategoryChooserVw from 'views/categoryChooserVw.ts';
-import { SaitoHelpView } from 'views/helps.ts';
-import LoginVw from 'views/loginVw.ts';
-import ModalDialog from 'modules/modalDialog/modalDialog';
-import NotificationView from 'modules/notification/notification.ts';
-import PostingCollection from 'collections/postings';
-import { PostingLayoutView as PostingLayout } from 'modules/posting/postingLayout.ts';
-import { PostingModel } from 'modules/posting/models/PostingModel';
-import { SlidetabsView } from 'modules/slidetabs/slidetabs.ts';
-import { ThreadCollection } from 'modules/thread/thread.ts';
-import ThreadLineCollection from 'collections/threadlines';
-import { ThreadLineView } from 'views/ThreadLineView.ts';
-import ThreadView from 'views/thread';
-import UserVw from 'modules/user/userVw.ts';
-import 'lib/jquery-ui/jquery-ui.custom.min';
-import NavigationBreak from 'app/NavigationBreak';
-
-export default Marionette.View.extend({
-  regions: {
-    modalDialog: '#saito-modal-dialog',
-    slidetabs: '#slidetabs',
-  },
-
-  template: _.noop,
-
-  _domInitializers: {
-    '.js-answer-wrapper': '_initAnsweringNotInlined',
-    '#slidetabs': '_initSlideTabs',
-    '.js-entry-view-core': '_initPostings',
-    '.threadBox': '_initThreadBoxes',
-    '.threadLeaf': '_initThreadLeafs',
-    '.js-rgUser': '_initUser',
-  },
-
-  ui: {
-    'btnLogout': '#js-btnLogout',
-    'btnCategoryChooser': '#btn-category-chooser',
-  },
-
-  events: {
-    'click #showLoginForm': 'showLoginForm',
-    'click .js-scrollToTop': 'scrollToTop',
-    'click #btn-manuallyMarkAsRead': 'manuallyMarkAsRead',
-    'click @ui.btnCategoryChooser': 'toggleCategoryChooser',
-    'click @ui.btnLogout': 'handleLogout',
-  },
-
-  initialize: function () {
-    this._initNotifications();
-    const nv = new NavigationBreak();
-
-    this.threads = new ThreadCollection();
-    if (App.request.controller === 'Entries' && App.request.action === 'index') {
-      this.threads.fetch();
-    }
-    this.postings = new PostingCollection();
-
-    // collection of threadlines not bound to thread (bookmarks, search results …)
-    this.threadLines = new ThreadLineCollection();
-  },
-
-  initFromDom: function (options) {
-    this.showChildView('modalDialog', ModalDialog);
-
-    _.each(this._domInitializers, (initializer, element) => {
-      const $elements = $(element);
-      if ($elements.length > 0) {
-        this[initializer]($elements);
-      }
-    });
-
-    this.initHelp();
-
-    const autoPageReload = App.settings.get('autoPageReload');
-    if (autoPageReload) {
-      App.eventBus.request('app:autoreload:start', autoPageReload);
-      const unread = $('.et-new').length;
-      if (unread) {
-        App.eventBus.request('app:favicon:badge', unread);
-      }
-
-    }
-
-    /*** All elements initialized, show page ***/
-
-    App.status.start(false);
-    this._showPage(options.SaitoApp.timeAppStart, options.contentTimer);
-    App.eventBus.trigger('notification', options.SaitoApp.msg);
-
-    /**
-     * Scroll to thread on entries/index if indicated by URL jump parameter
-     */
-    if (window.location.href.indexOf('jump=') > -1) {
-      var url = window.location.href;
-      var jumpTarget = /[\?\&]jump=(\d+)/.exec(url);
-      try {
-        this.scrollToThread(jumpTarget[1]);
-      }
-      catch (error) {
-      }
-      finally {
-        var newLocation = url.replace(/[\?\&]jump=\d+/, '');
-        window.history.replaceState(null, null, newLocation);
-      }
-    }
-  },
-
-  _initNotifications() {
-      //noinspection JSHint
-      const notificationElement = $('
'); - this.$el.prepend(notificationElement) - new NotificationView({el: notificationElement}).render(); - }, - - _initUser: function (element) { - const id = Number.parseInt(element.data('id')); - const model = new Bb.Model({ id: id }); - const User = new UserVw({ el: element, model: model, }); - User.render(); - }, - - _initSlideTabs: function (element) { - this.showChildView('slidetabs', new SlidetabsView({ el: '#slidetabs' })); - }, - - /** - * init the entries/add form where answering is not appended to a posting - * - * @param element - * @private - */ - _initAnsweringNotInlined: function (element) { - const data = {}; - const id = element.data('edit'); - if (id) { - data.id = parseInt(id, 10); - } - const answeringForm = new AnsweringView({ - el: element, - model: new AnswerModel(data), - }).render(); - - this.listenTo(answeringForm, 'answering:send:success', (model) => { - const root = App.settings.get('webroot'); - window.redirect(root + 'entries/view/' + model.get('id')); - }); - - return answeringForm; // testing - }, - - /** - * Handles user-logout - * - * @private - */ - handleLogout: function (event, element) { - event.preventDefault(); - - // clear JS-storage - App.eventBus.trigger('app:localStorage:clear'); - - // move on to server-logout - _.defer(function () { - const serverLogoutUrl = event.currentTarget.getAttribute('href'); - window.redirect(serverLogoutUrl); - }); - }, - - _initPostings: function (elements) { - _.each(elements, function (element) { - var id, - postingLayout, - postingModel; - - id = parseInt(element.getAttribute('data-id'), 10); - postingModel = new PostingModel({ id: id }); - this.postings.add(postingModel, { silent: true }); - new PostingLayout({ - el: $(element), - model: this.postings.get(id), - collection: this.postings - }).render(); - }, this); - }, - - _initThreadBoxes: function (elements) { - _.each(elements, (element) => { - var threadView, threadId; - - threadId = parseInt($(element).attr('data-id'), 10); - if (!this.threads.get(threadId)) { - this.threads.add([ - { - id: threadId, - isThreadCollapsed: App.request.controller === 'entries' && App.request.action === 'index' && App.currentUser.get('user_show_thread_collapsed') - } - ], { silent: true }); - } - threadView = new ThreadView({ - el: $(element), - postings: this.postings, - model: this.threads.get(threadId) - }); - }); - - }, - - _initThreadLeafs: function (elements) { - _.each(elements, (element) => { - var leafData = JSON.parse(element.getAttribute('data-leaf')); - // 'new' is 'isNewToUser' in leaf model; also 'new' is JS-keyword - leafData.isNewToUser = leafData['new']; - delete (leafData['new']); - - var threadsCollection = this.threads.get(leafData.tid); - var threadlineCollection; - if (threadsCollection) { - // leafData belongs to complete thread on page - threadlineCollection = threadsCollection.threadlines; - } else { - // leafData is not shown in its complete thread context (e.g. bookmark) - threadlineCollection = this.threadLines; - } - - var threadLineView = new ThreadLineView({ - el: $(element), - leafData: leafData, - postings: this.postings, - collection: threadlineCollection - }); - }, this); - }, - - _showPage: function (startTime, timer) { - var triggerVisible = function () { - App.eventBus.trigger('isAppVisible', true); - }; - - if (App.request.isMobile || (new Date().getTime() - startTime) > 1500) { - $('#content').css('visibility', 'visible'); - triggerVisible(); - } else { - $('#content') - .css({ visibility: 'visible', opacity: 0 }) - .animate( - { opacity: 1 }, - { - duration: 150, - /* - easing: 'easeInOutQuart', - */ - complete: triggerVisible - }); - } - timer.cancel(); - }, - - toggleCategoryChooser: function (event, ui) { - const categoryChooser = new CategoryChooserVw(); - categoryChooser.model.set('isOpen', !categoryChooser.model.get('isOpen')); - }, - - initHelp: function (element_n) { - new SaitoHelpView({ - el: '#shp-show', - elementName: '.shp', - webroot: App.settings.get('webroot') - }).render(); - }, - - scrollToThread: function (tid) { - $('.threadBox[data-id=' + tid + ']')[0].scrollIntoView('top'); - }, - - showLoginForm: function (event) { - event.preventDefault(); - const title = event.currentTarget.title; - ModalDialog.once('shown', () => { this.$('#tf-login-username').focus(); }); - ModalDialog.show(new LoginVw(), { title: title }); - }, - - scrollToTop: function (event) { - event.preventDefault(); - window.scrollTo({ behavior: 'smooth', top: 0 }); - }, - - manuallyMarkAsRead: function (event) { - if (event) { - event.preventDefault(); - } - window.redirect(App.settings.get('webroot') + 'entries/update'); - }, -}); diff --git a/frontend/src/views/app.ts b/frontend/src/views/app.ts new file mode 100644 index 000000000..ef9bb5f4c --- /dev/null +++ b/frontend/src/views/app.ts @@ -0,0 +1,322 @@ +import NavigationBreak from 'app/NavigationBreak'; +import Bb, { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import PostingCollection from 'collections/postings'; +import ThreadLineCollection from 'collections/threadlines'; +import $ from 'jquery'; +import 'lib/jquery-ui/jquery-ui.custom.min'; +import App from 'models/app'; +import AnsweringView from 'modules/answering/answering'; +import AnswerModel from 'modules/answering/models/AnswerModel'; +import ModalDialog from 'modules/modalDialog/modalDialog'; +import NotificationView from 'modules/notification/notification'; +import { PostingModel } from 'modules/posting/models/PostingModel'; +import { PostingLayoutView as PostingLayout } from 'modules/posting/postingLayout'; +import { SlidetabsView } from 'modules/slidetabs/slidetabs'; +import { ThreadCollection } from 'modules/thread/thread'; +import UserVw from 'modules/user/userVw'; +import _ from 'underscore'; +import CategoryChooserVw from 'views/categoryChooserVw'; +import { SaitoHelpView } from 'views/helps'; +import LoginVw from 'views/loginVw'; +import ThreadView from 'views/thread'; +import { ThreadLineView } from 'views/ThreadLineView'; +import ContentTimer from '../app/ContentTimer'; + +class AppView extends View { + private domInitializers: Array<{ el: string, clb: keyof AppView }>; + + private threads!: ThreadCollection; + + private postings!: PostingCollection; + + private threadLines!: ThreadLineCollection; + + public constructor(options: any = {}) { + _.defaults(options, { + events: { + 'click #btn-manuallyMarkAsRead': 'manuallyMarkAsRead', + 'click #showLoginForm': 'showLoginForm', + 'click .js-scrollToTop': 'scrollToTop', + 'click @ui.btnCategoryChooser': 'toggleCategoryChooser', + 'click @ui.btnLogout': 'handleLogout', + }, + regions: { + modalDialog: '#saito-modal-dialog', + slidetabs: '#slidetabs', + }, + template: _.noop, + ui: { + btnCategoryChooser: '#btn-category-chooser', + btnLogout: '#js-btnLogout', + }, + }); + + super(options); + + this.domInitializers = [ + { el: '.js-answer-wrapper', clb: '_initAnsweringNotInlined' }, + { el: '#slidetabs', clb: '_initSlideTabs' }, + { el: '.js-entry-view-core', clb: '_initPostings' }, + { el: '.threadBox', clb: '_initThreadBoxes' }, + { el: '.threadLeaf', clb: '_initThreadLeafs' }, + { el: '.js-rgUser', clb: '_initUser' }, + ]; + } + + public initialize() { + this._initNotifications(); + const nv = new NavigationBreak(); + + this.threads = new ThreadCollection(); + if (App.request.getController() === 'Entries' && App.request.getAction() === 'index') { + this.threads.fetch(); + } + this.postings = new PostingCollection(); + + // collection of threadlines not bound to thread (bookmarks, search results …) + this.threadLines = new ThreadLineCollection(); + } + + public initFromDom(options: { contentTimer: ContentTimer, SaitoApp: any }) { + this.showChildView('modalDialog', ModalDialog); + + for (const item of this.domInitializers) { + const $elements = $(item.el); + if ($elements.length > 0) { + this[item.clb]($elements); + } + } + + this.initHelp(); + + const autoPageReload = App.settings.get('autoPageReload'); + if (autoPageReload) { + App.eventBus.request('app:autoreload:start', autoPageReload); + const unread = $('.et-new').length; + if (unread) { + App.eventBus.request('app:favicon:badge', unread); + } + } + + /*** All elements initialized, show page ***/ + + App.status.start(false); + this._showPage(options.SaitoApp.timeAppStart, options.contentTimer); + App.eventBus.trigger('notification', options.SaitoApp.msg); + + /** + * Scroll to thread on entries/index if indicated by URL jump parameter + */ + if (window.location.href.indexOf('jump=') > -1) { + const url = window.location.href; + const jumpTarget = /[\?\&]jump=(\d+)/.exec(url); + if (!jumpTarget) { + return; + } + try { + this.scrollToThread(parseInt(jumpTarget[1], 10)); + } catch (error) { + // do nothing + } finally { + const newLocation = url.replace(/[\?\&]jump=\d+/, ''); + window.history.replaceState(null, '', newLocation); + } + } + } + + public _initUser(element: JQuery) { + const id = Number.parseInt(element.data('id'), 10); + const model = new Bb.Model({ id }); + const User = new UserVw({ el: element, model }); + User.render(); + } + + public _initSlideTabs(element: JQuery) { + this.showChildView('slidetabs', new SlidetabsView({ el: '#slidetabs' })); + } + + /** + * init the entries/add form where answering is not appended to a posting + * + * @param element + * @private + */ + public _initAnsweringNotInlined(element: JQuery) { + const data: any = {}; + const id = element.data('edit'); + if (id) { + data.id = parseInt(id, 10); + } + const answeringForm = new AnsweringView({ + el: element, + model: new AnswerModel(data), + }).render(); + + this.listenTo(answeringForm, 'answering:send:success', (model) => { + const root = App.settings.get('webroot'); + window.redirect(root + 'entries/view/' + model.get('id')); + }); + + return answeringForm; // testing + } + + public _initPostings(elements: JQuery) { + _.each(elements, (element) => { + const dataId = element.getAttribute('data-id'); + if (!dataId) { + throw new Error(); + } + const id = parseInt(dataId, 10); + const postingModel = new PostingModel({ id }); + this.postings.add(postingModel, { silent: true }); + new PostingLayout({ + collection: this.postings, + el: $(element), + model: postingModel, + }).render(); + }); + } + + public _initThreadBoxes(elements: JQuery) { + _.each(elements, (element) => { + const threadIdData = $(element).attr('data-id'); + if (!threadIdData) { + throw new Error(); + } + const threadId = parseInt(threadIdData, 10); + + if (!this.threads.get(threadId)) { + this.threads.add([ + { + id: threadId, + isThreadCollapsed: App.request.getController() === 'entries' + && App.request.getAction() === 'index' + && App.currentUser.get('user_show_thread_collapsed'), + }, + ], { silent: true }); + } + const threadView = new ThreadView({ + el: $(element), + model: this.threads.get(threadId), + postings: this.postings, + }); + }); + + } + + public _initThreadLeafs(elements: JQuery) { + _.each(elements, (element) => { + const leafData = JSON.parse(element.getAttribute('data-leaf') as string); + // 'new' is 'isNewToUser' in leaf model; also 'new' is JS-keyword + leafData.isNewToUser = leafData.new; + delete (leafData.new); + + const threadsCollection = this.threads.get(leafData.tid); + let threadlineCollection; + if (threadsCollection) { + // leafData belongs to complete thread on page + threadlineCollection = threadsCollection.threadlines; + } else { + // leafData is not shown in its complete thread context (e.g. bookmark) + threadlineCollection = this.threadLines; + } + + const threadLineView = new ThreadLineView({ + collection: threadlineCollection, + el: $(element), + leafData, + postings: this.postings, + }); + }); + } + + private _initNotifications() { + //noinspection JSHint + const notificationElement = $('
'); + this.$el.prepend(notificationElement); + new NotificationView({ el: notificationElement }).render(); + } + + /** + * Handles user-logout + * + * @private + */ + private handleLogout(event: JQueryEventObject, element: JQuery) { + event.preventDefault(); + + // clear JS-storage + App.eventBus.trigger('app:localStorage:clear'); + + // move on to server-logout + _.defer(() => { + const serverLogoutUrl = event.currentTarget.getAttribute('href'); + if (!serverLogoutUrl) { + throw new Error(); + } + window.redirect(serverLogoutUrl); + }); + } + + private _showPage(startTime: number, timer: ContentTimer) { + const triggerVisible = () => { + App.eventBus.trigger('isAppVisible', true); + }; + + if (App.request.isMobile || (new Date().getTime() - startTime) > 1500) { + $('#content').css('visibility', 'visible'); + triggerVisible(); + } else { + $('#content') + .css({ visibility: 'visible', opacity: 0 }) + .animate( + { opacity: 1 }, + { + complete: triggerVisible, + // easing: 'easeInOutQuart', + duration: 150, + }, + ); + } + timer.cancel(); + } + + private toggleCategoryChooser() { + const categoryChooser = new CategoryChooserVw(); + categoryChooser.model.set('isOpen', !categoryChooser.model.get('isOpen')); + } + + private initHelp() { + new SaitoHelpView({ + el: '#shp-show', + elementName: '.shp', + webroot: App.settings.get('webroot'), + }).render(); + } + + private scrollToThread(tid: number) { + const box = $('.threadBox[data-id=' + tid + ']')[0].scrollIntoView(true); + } + + private showLoginForm(event: JQueryEventObject) { + event.preventDefault(); + const title = (event.currentTarget as HTMLLinkElement).title; + ModalDialog.once('shown', () => { this.$('#tf-login-username').focus(); }); + ModalDialog.show(new LoginVw(), { title }); + } + + private scrollToTop(event: Event) { + event.preventDefault(); + window.scrollTo({ behavior: 'smooth', top: 0 }); + } + + private manuallyMarkAsRead(event: Event) { + if (event) { + event.preventDefault(); + } + window.redirect(App.settings.get('webroot') + 'entries/update'); + } +} + +export default AppView; diff --git a/frontend/src/views/helps.ts b/frontend/src/views/helps.ts index 4544a94f4..68e06a3a8 100644 --- a/frontend/src/views/helps.ts +++ b/frontend/src/views/helps.ts @@ -11,12 +11,7 @@ class SaitoHelpView extends View { private popups: any[]; // cache for DOM-elements - private elements; - - /** handler string for a element with popup (.shp) */ - private elementName: string; - - private webroot: string; + private elements: JQuery | null; public constructor(options: any = {}) { _.defaults(options, { @@ -32,13 +27,13 @@ class SaitoHelpView extends View { `), }); super(options); - } - public initialize(options) { this.isHelpShown = false; this.popups = []; this.elements = null; + } + public initialize() { this.listenTo(App.eventBus, 'change:DOM', this.onDomChange); } @@ -71,19 +66,15 @@ class SaitoHelpView extends View { if (this.elements === null) { this.elements = $(this.getOption('scope')) .find(this.getOption('elementName')) - .filter(this.isVisible); + .filter((index: number, element: Element) => $(element).filter(':visible').length > 0); } return this.elements; } - private isVisible(index, element): boolean { - return $(element).filter(':visible').length > 0; - } - private show(): this { this.isHelpShown = true; if (!this.isHelpOnPage()) { - return; + return this; } if (this.popups.length === 0) { diff --git a/frontend/src/views/postingAction.js b/frontend/src/views/postingAction.js deleted file mode 100644 index 822ce78b5..000000000 --- a/frontend/src/views/postingAction.js +++ /dev/null @@ -1,88 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import Marionette from 'backbone.marionette'; -import App from 'models/app'; -import BmBtn from 'views/postingActionBookmark'; -import DelModal from 'views/postingActionDelete'; -import SolvesBtn from 'views/postingActionSolves'; -import EditCountdownView from 'modules/answering/buttons/EditCountdownBtnView'; - -export default Marionette.View.extend({ - - ui: { - 'btnDelete': '.js-delete', - 'btnFixed': '.js-btn-toggle-fixed', - 'btnLocked': '.js-btn-toggle-locked', - }, - - events: { - 'click @ui.btnDelete': 'onBtnDelete', - 'click .js-btn-setAnsweringForm': 'onBtnAnswer', - 'click @ui.btnFixed': 'onToggleFixed', - 'click @ui.btnLocked': 'onToggleLocked' - }, - - _jsButtons: [BmBtn, SolvesBtn], - - initialize: function () { - this._initFormElements(); - this.listenTo(this.model, 'change:isAnsweringFormShown', this._toggleAnsweringForm); - }, - - _initFormElements: function () { - _.each(this._jsButtons, (View) => { - this.$el.append(new View({ model: this.model }).$el); - }); - var _$editButton = this.$('.js-btn-edit'); - if (_$editButton.length > 0) { - var editCountdown = new EditCountdownView({ - startTime: this.model.get('time'), - el: _$editButton, - }); - } - }, - - onBtnAnswer: function (event) { - event.preventDefault(); - this.model.set('isAnsweringFormShown', true); - }, - - /** - * Delete posting button click - */ - onBtnDelete: function (event) { - var diag = new DelModal({ model: this.model }).render(); - event.preventDefault(); - }, - - onToggleFixed: function (event) { - event.preventDefault(); - this._sendToggle('fixed'); - }, - - onToggleLocked: function (event) { - event.preventDefault(); - this._sendToggle('locked'); - }, - - // @todo move into model - _sendToggle: function (key) { - const id = this.model.get('id'); - const webroot = App.settings.get('webroot'); - const url = webroot + 'entries/ajaxToggle/' + id + '/' + key; - - $.ajax({ url: url, buffer: false }) - .done(function (data) { - window.location.reload(true); - }); - }, - - _toggleAnsweringForm: function () { - if (this.model.get('isAnsweringFormShown')) { - this.$el.slideUp('fast'); - } else { - this.$el.slideDown('fast'); - } - } - -}); diff --git a/frontend/src/views/postingAction.ts b/frontend/src/views/postingAction.ts new file mode 100644 index 000000000..9cf975d62 --- /dev/null +++ b/frontend/src/views/postingAction.ts @@ -0,0 +1,90 @@ +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import $ from 'jquery'; +import App from 'models/app'; +import EditCountdownView from 'modules/answering/buttons/EditCountdownBtnView'; +import _ from 'underscore'; +import BmBtn from 'views/postingActionBookmark'; +import DelModal from 'views/postingActionDelete'; +import SolvesBtn from 'views/postingActionSolves'; + +export default class extends View { + private jsButtons: any[]; + + public constructor(options: any = {}) { + _.defaults(options, { + events: { + 'click .js-btn-setAnsweringForm': 'onBtnAnswer', + 'click @ui.btnDelete': 'onBtnDelete', + 'click @ui.btnFixed': 'onToggleFixed', + 'click @ui.btnLocked': 'onToggleLocked', + }, + ui: { + btnDelete: '.js-delete', + btnFixed: '.js-btn-toggle-fixed', + btnLocked: '.js-btn-toggle-locked', + }, + }); + + super(options); + + this.jsButtons = [BmBtn, SolvesBtn]; + this._initFormElements(); + this.listenTo(this.model, 'change:isAnsweringFormShown', this._toggleAnsweringForm); + } + + private _initFormElements() { + _.each(this.jsButtons, (ElementView) => { + this.$el.append(new ElementView({ model: this.model }).$el); + }); + const $editButton = this.$('.js-btn-edit'); + if ($editButton.length > 0) { + const editCountdown = new EditCountdownView({ + el: $editButton, + startTime: this.model.get('time'), + }); + } + } + + private onBtnAnswer(event: Event) { + event.preventDefault(); + this.model.set('isAnsweringFormShown', true); + } + + /** + * Delete posting button click + */ + private onBtnDelete(event: Event) { + const diag = new DelModal({ model: this.model }).render(); + event.preventDefault(); + } + + private onToggleFixed(event: Event) { + event.preventDefault(); + this._sendToggle('fixed'); + } + + private onToggleLocked(event: Event) { + event.preventDefault(); + this._sendToggle('locked'); + } + + // @todo move into model + private _sendToggle(key: string) { + const id = this.model.get('id'); + const webroot = App.settings.get('webroot'); + const url = webroot + 'entries/ajaxToggle/' + id + '/' + key; + + $.ajax({ url, cache: false }) + .done(() => { window.location.reload(true); }); + } + + private _toggleAnsweringForm() { + if (this.model.get('isAnsweringFormShown')) { + this.$el.slideUp('fast'); + } else { + this.$el.slideDown('fast'); + } + } + +} diff --git a/frontend/src/views/postingActionBookmark.js b/frontend/src/views/postingActionBookmark.js deleted file mode 100644 index f9d87ad04..000000000 --- a/frontend/src/views/postingActionBookmark.js +++ /dev/null @@ -1,73 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import Marionette from 'backbone.marionette'; -import App from 'models/app'; -import BookmarkModel from 'modules/bookmarks/models/bookmark'; - -export default Marionette.View.extend({ - - tagName: 'btn', - - className: 'btn btn-link', - - template: _.template(''), - - events: { - 'click': 'handleClick', - }, - - modelEvents: { - 'change:isBookmarked': '_toggle' - }, - - initialize: function () { - if (!this._shouldRender()) { - return; - } - this.$el.attr('href', '#'); - this.render(); - }, - - _shouldRender: function () { - if (!App.currentUser.isLoggedIn()) { - return false; - } - return true; - }, - - handleClick: function () { - const success = (bookmarks) => { - if (this.model.get('isBookmarked')) { - const model = bookmarks.findWhere({ 'entry_id': this.model.get('id') }); - model.destroy(); - this.model.set('isBookmarked', false); - } else { - bookmarks.create({ - 'entry_id': this.model.get('id'), - 'user_id': App.currentUser.get('id'), - }) - this.model.set('isBookmarked', true); - } - - }; - App.currentUser.getBookmarks({ success: success }); - }, - - _toggle: function () { - var _$icon = this.$('i'); - if (this.model.get('isBookmarked')) { - _$icon.removeClass('fa-bookmark-o'); - _$icon.addClass('fa-bookmark'); - this.$el.attr('title', $.i18n.__('bmk.isBookmarked')); - } else { - _$icon.removeClass('fa-bookmark'); - _$icon.addClass('fa-bookmark-o'); - this.$el.attr('title', $.i18n.__('bmk.doBookmark')); - } - }, - - onRender: function () { - this._toggle(); - } - -}); diff --git a/frontend/src/views/postingActionBookmark.ts b/frontend/src/views/postingActionBookmark.ts new file mode 100644 index 000000000..b92ea05b3 --- /dev/null +++ b/frontend/src/views/postingActionBookmark.ts @@ -0,0 +1,73 @@ +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import $ from 'jquery'; +import App from 'models/app'; +import _ from 'underscore'; +import BookmarksCl from '../modules/bookmarks/collections/bookmarksCl'; + +export default class extends View { + public constructor(options: any = {}) { + _.defaults(options, { + className: 'btn btn-link', + events: { + click: 'handleClick', + }, + modelEvents: { + 'change:isBookmarked': '_toggle', + }, + tagName: 'btn', + template: _.template(''), + }); + super(options); + } + + public initialize() { + if (!this._shouldRender()) { + return; + } + this.$el.attr('href', '#'); + this.render(); + } + + public onRender() { + this._toggle(); + } + + private _shouldRender() { + if (!App.currentUser.isLoggedIn()) { + return false; + } + return true; + } + + private handleClick() { + const success = (bookmarks: BookmarksCl) => { + if (this.model.get('isBookmarked')) { + const model = bookmarks.findWhere({ entry_id: this.model.get('id') }); + model.destroy(); + this.model.set('isBookmarked', false); + } else { + bookmarks.create({ + entry_id: this.model.get('id'), + user_id: App.currentUser.get('id'), + }); + this.model.set('isBookmarked', true); + } + + }; + App.currentUser.getBookmarks({ success }); + } + + private _toggle() { + const $icon = this.$('i'); + if (this.model.get('isBookmarked')) { + $icon.removeClass('fa-bookmark-o'); + $icon.addClass('fa-bookmark'); + this.$el.attr('title', $.i18n.__('bmk.isBookmarked')); + } else { + $icon.removeClass('fa-bookmark'); + $icon.addClass('fa-bookmark-o'); + this.$el.attr('title', $.i18n.__('bmk.doBookmark')); + } + } +} diff --git a/frontend/src/views/postingActionDelete.js b/frontend/src/views/postingActionDelete.js deleted file mode 100644 index 1ba2cc504..000000000 --- a/frontend/src/views/postingActionDelete.js +++ /dev/null @@ -1,56 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import Marionette from 'backbone.marionette'; -import App from 'models/app'; -import ModalDialog from 'modules/modalDialog/modalDialog'; - -/** - * Dialog for deleteing a posting. - */ -export default Marionette.View.extend({ - ui: { - abort: '.js-abort', - submit: '.js-delete' - }, - - events: { - 'click @ui.abort': '_onAbort', - 'click @ui.submit': '_onSubmit' - }, - - template: _.template(` -
-
-

- <%- $.i18n.__('tree.delete.confirm') %> -

-
- -
- `), - - _onAbort: function (event) { - event.preventDefault(); - ModalDialog.hide(); - }, - - _onSubmit: function (event) { - var id, url; - event.preventDefault(); - id = this.model.get('id'); - url = App.settings.get('webroot') + 'entries/delete/' + id; - window.location = url; - }, - - onBeforeClose: function () { - this.destroy(); - }, - - onRender: function () { - ModalDialog.show(this, { title: $.i18n.__('posting.delete.title') }); - } -}); diff --git a/frontend/src/views/postingActionDelete.ts b/frontend/src/views/postingActionDelete.ts new file mode 100644 index 000000000..8b5514b47 --- /dev/null +++ b/frontend/src/views/postingActionDelete.ts @@ -0,0 +1,60 @@ +import { Model } from 'backbone'; +import { View } from 'backbone.marionette'; +import $ from 'jquery'; +import App from 'models/app'; +import ModalDialog from 'modules/modalDialog/modalDialog'; +import _ from 'underscore'; + +/** + * Dialog for deleteing a posting. + */ +export default class extends View { + public constructor(options: any = {}) { + _.defaults(options, { + events: { + 'click @ui.abort': '_onAbort', + 'click @ui.submit': '_onSubmit', + }, + template: _.template(` +
+
+

+ <%- $.i18n.__('tree.delete.confirm') %> +

+
+ +
+ `), + ui: { + abort: '.js-abort', + submit: '.js-delete', + }, + + }); + super(options); + } + + public onRender() { + ModalDialog.show(this, { title: $.i18n.__('posting.delete.title') }); + } + + private _onAbort(event: Event) { + event.preventDefault(); + ModalDialog.hide(); + } + + private _onSubmit(event: Event) { + event.preventDefault(); + const id = this.model.get('id'); + const url = App.settings.get('webroot') + 'entries/delete/' + id; + window.redirect(url); + } + + private onBeforeClose() { + this.destroy(); + } +} diff --git a/frontend/src/views/postingActionSolves.js b/frontend/src/views/postingActionSolves.js deleted file mode 100644 index 2088e7a88..000000000 --- a/frontend/src/views/postingActionSolves.js +++ /dev/null @@ -1,87 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import Marionette from 'backbone.marionette'; -import App from 'models/app'; - -export default Marionette.View.extend({ - - tagName: 'a', - - className: 'btn btn-link btn-solves', - - template: _.template(''), - - events: { - "click": '_onClick' - }, - - modelEvents: { - 'change:isSolves': '_toggle' - }, - - initialize: function () { - if (!this._shouldRender()) { - return; - } - this.$el.attr({ - href: '#', - title: $.i18n.__('posting.helpful') - }); - this.render(); - }, - - _shouldRender: function () { - if (!App.currentUser.isLoggedIn()) { - return false; - } - if (this.model.isRoot()) { - return false; - } - if (this.model.get('rootEntryUserId') !== App.currentUser.get('id')) { - return false; - } - return true; - }, - - _onClick: function (event) { - event.preventDefault(); - this.model.toggle('isSolves'); - }, - - _toggle: function () { - var _$icon = this.$('i'), - _isSolves = this.model.get('isSolves'), - _html = ''; - - if (_isSolves) { - _$icon.addClass('solves-isSolved'); - _$icon.removeClass('fa-badge-solves-o'); - _$icon.addClass('fa-badge-solves'); - _html = this.$el.html(); - _html = $(_html).removeClass('fa-lg'); - } else { - _$icon.removeClass('fa-badge-solves'); - _$icon.addClass('fa-badge-solves-o'); - _$icon.removeClass('solves-isSolved'); - } - this._toggleGlobal(_html); - }, - - /** - * Sets other badges on the page, prominently in thread-line. - * - * @todo should be handled as state by global model for the entry - * - * @param html - * @private - */ - _toggleGlobal: function (html) { - var _$globalIconHook = $('.solves.' + this.model.get('id')); - _$globalIconHook.html(html); - }, - - onRender: function () { - this._toggle(); - } - -}); diff --git a/frontend/src/views/postingActionSolves.ts b/frontend/src/views/postingActionSolves.ts new file mode 100644 index 000000000..7aaf9c8c7 --- /dev/null +++ b/frontend/src/views/postingActionSolves.ts @@ -0,0 +1,90 @@ +import { View } from 'backbone.marionette'; +import $ from 'jquery'; +import App from 'models/app'; +import _ from 'underscore'; +import PostingModel from '../models/PostingMdl'; + +export default class extends View { + + public constructor(options: any = {}) { + _.defaults(options, { + + className: 'btn btn-link btn-solves', + + events: { + click: '_onClick', + }, + + modelEvents: { + 'change:isSolves': '_toggle', + }, + tagName: 'a', + template: _.template(''), + }); + super(options); + } + + public initialize() { + if (!this._shouldRender()) { + return; + } + this.$el.attr({ + href: '#', + title: $.i18n.__('posting.helpful'), + }); + this.render(); + } + + public onRender() { + this._toggle(); + } + + private _shouldRender() { + if (!App.currentUser.isLoggedIn()) { + return false; + } + if (this.model.isRoot()) { + return false; + } + if (this.model.get('rootEntryUserId') !== App.currentUser.get('id')) { + return false; + } + return true; + } + + private _onClick(event: Event) { + event.preventDefault(); + this.model.toggle('isSolves'); + } + + private _toggle() { + const $icon = this.$('i'); + const isSolves = this.model.get('isSolves'); + let html = ''; + + if (isSolves) { + $icon.addClass('solves-isSolved'); + $icon.removeClass('fa-badge-solves-o'); + $icon.addClass('fa-badge-solves'); + html = this.$el.html(); + $(html).removeClass('fa-lg'); + } else { + $icon.removeClass('fa-badge-solves'); + $icon.addClass('fa-badge-solves-o'); + $icon.removeClass('solves-isSolved'); + } + this._toggleGlobal(html); + } + + /** + * Sets other badges on the page, prominently in thread-line. + * + * @todo should be handled as state by global model for the entry + * + * @param html + */ + private _toggleGlobal(html: string) { + const $globalIconHook = $('.solves.' + this.model.get('id')); + $globalIconHook.html(html); + } +} diff --git a/frontend/src/views/thread.js b/frontend/src/views/thread.js deleted file mode 100644 index aaf1c1543..000000000 --- a/frontend/src/views/thread.js +++ /dev/null @@ -1,113 +0,0 @@ -import $ from 'jquery'; -import _ from 'underscore'; -import Backbone from 'backbone'; -import App from 'models/app'; -import { ThreadLineView } from 'views/ThreadLineView'; - -export default Backbone.View.extend({ - - className: 'threadBox', - - events: { - 'click .btn-threadCollapse': 'collapseThread' - }, - - initialize: function (options) { - this.postings = options.postings; - - this.$rootUl = this.$('ul.root'); - this.$subThreadRootIl = $(this.$rootUl.find('li:not(:first-child)')[0]); - - if (this.model.get('isThreadCollapsed')) { - this.hide(); - } else { - this.show(); - } - - if (!App.eventBus.request('app:localStorage:available')) { - this._hideCollapseButton(); - } - - this.listenTo(App.eventBus, 'newEntry', this._showNewThreadLine); - this.listenTo(this.model, 'change:isThreadCollapsed', this.toggleCollapseThread); - }, - - _showNewThreadLine: function (options) { - var threadLine; - // only append to the id it belongs to - if (options.tid !== this.model.get('id')) { - return; - } - threadLine = new ThreadLineView({ - leafData: options, - collection: this.model.threadlines, - postings: this.postings - }); - this._appendThreadlineToThread(options.pid, threadLine.render().$el); - }, - - _appendThreadlineToThread: function (pid, $el) { - var parent, - existingSubthread; - parent = this.$('.threadLeaf[data-id="' + pid + '"]'); - existingSubthread = (parent.next().not('.js_threadline').find('ul:first')); - if (existingSubthread.length === 0) { - $el.wrap('
    ') - .parent() - .wrap('
  • ') - .parent() - .insertAfter(parent); - } else { - existingSubthread.append($el); - } - }, - - _hideCollapseButton: function () { - this.$('.btn-threadCollapse').css('visibility', 'hidden'); - }, - - collapseThread: function (event) { - event.preventDefault(); - this.model.toggle('isThreadCollapsed'); - this.model.save(); - }, - - toggleCollapseThread: function (model, isThreadCollapsed) { - if (isThreadCollapsed) { - this.slideUp(); - } else { - this.slideDown(); - } - }, - - slideUp: function () { - this.$subThreadRootIl.slideUp(300); - this.markHidden(); - }, - - slideDown: function () { - this.$subThreadRootIl.slideDown(300); - this.markShown(); - // $(this.el).find('.ico-threadOpen').removeClass('ico-threadOpen').addClass('ico-threadCollapse'); - // $(this.el).find('.btn-threadCollapse').html(this.l18n_threadCollapse); - }, - - hide: function () { - this.$subThreadRootIl.hide(); - this.markHidden(); - }, - - show: function () { - this.$subThreadRootIl.show(); - this.markShown(); - }, - - markShown: function () { - $(this.el).find('.fa-thread-closed').removeClass('fa-thread-closed').addClass('fa-thread-open'); - }, - - markHidden: function () { - $(this.el).find('.fa-thread-open').removeClass('fa-thread-open').addClass('fa-thread-closed'); - } - -}); diff --git a/frontend/src/views/thread.ts b/frontend/src/views/thread.ts new file mode 100644 index 000000000..c799f33a4 --- /dev/null +++ b/frontend/src/views/thread.ts @@ -0,0 +1,129 @@ +import { View } from 'backbone.marionette'; +import PostingCollection from 'collections/postings'; +import $ from 'jquery'; +import App from 'models/app'; +import _ from 'underscore'; +import { ThreadLineView } from 'views/ThreadLineView'; +import AnswerModel from '../modules/answering/models/AnswerModel'; +import { ThreadModel } from '../modules/thread/thread'; + +export default class extends View { + private $rootUl!: JQuery; + + private $subThreadRootIl!: JQuery; + + private postings!: PostingCollection; + + public constructor(options: any = {}) { + _.defaults(options, { + className: 'threadBox', + events: { + 'click .btn-threadCollapse': 'collapseThread', + }, + }); + super(options); + } + + public initialize(options: any) { + this.postings = options.postings; + + this.$rootUl = this.$('ul.root'); + this.$subThreadRootIl = $(this.$rootUl.find('li:not(:first-child)')[0]); + + if (this.model.get('isThreadCollapsed')) { + this.hide(); + } else { + this.show(); + } + + if (!App.eventBus.request('app:localStorage:available')) { + this._hideCollapseButton(); + } + + this.listenTo(App.eventBus, 'newEntry', this._showNewThreadLine); + this.listenTo(this.model, 'change:isThreadCollapsed', this.toggleCollapseThread); + } + + private _showNewThreadLine(model: AnswerModel) { + let threadLine; + // only append to the id it belongs to + if (model.get('tid') !== this.model.get('id')) { + return; + } + + const leafData = { + id: model.get('id'), + isNewToUser: true, + }; + + threadLine = new ThreadLineView({ + collection: this.model.threadlines, + leafData, + postings: this.postings, + }); + this._appendThreadlineToThread(model.get('pid') + '', threadLine.render().$el); + } + + private _appendThreadlineToThread(pid: string, $el: JQuery) { + const parent = this.$('.threadLeaf[data-id="' + pid + '"]'); + const existingSubthread = (parent.next().not('.js_threadline').find('ul:first')); + if (existingSubthread.length === 0) { + $el.wrap('
      ') + .parent() + .wrap('
    • ') + .parent() + .insertAfter(parent); + } else { + existingSubthread.append($el); + } + } + + private _hideCollapseButton() { + this.$('.btn-threadCollapse').css('visibility', 'hidden'); + } + + private collapseThread(event: Event) { + event.preventDefault(); + this.model.toggle('isThreadCollapsed'); + this.model.save(); + } + + private toggleCollapseThread(model: ThreadModel, isThreadCollapsed: boolean) { + if (isThreadCollapsed) { + this.slideUp(); + } else { + this.slideDown(); + } + } + + private slideUp() { + this.$subThreadRootIl.slideUp(300); + this.markHidden(); + } + + private slideDown() { + this.$subThreadRootIl.slideDown(300); + this.markShown(); + // $(this.el).find('.ico-threadOpen').removeClass('ico-threadOpen').addClass('ico-threadCollapse'); + // $(this.el).find('.btn-threadCollapse').html(this.l18n_threadCollapse); + } + + private hide() { + this.$subThreadRootIl.hide(); + this.markHidden(); + } + + private show() { + this.$subThreadRootIl.show(); + this.markShown(); + } + + private markShown() { + $(this.el).find('.fa-thread-closed').removeClass('fa-thread-closed').addClass('fa-thread-open'); + } + + private markHidden() { + $(this.el).find('.fa-thread-open').removeClass('fa-thread-open').addClass('fa-thread-closed'); + } + +} diff --git a/frontend/test/modules/bookmarks/views/bookmarkItemVwSpec.js b/frontend/test/modules/bookmarks/views/bookmarkItemVwSpec.js index 0bf996eb7..6163528eb 100644 --- a/frontend/test/modules/bookmarks/views/bookmarkItemVwSpec.js +++ b/frontend/test/modules/bookmarks/views/bookmarkItemVwSpec.js @@ -1,6 +1,7 @@ import Bb from 'backbone'; import Model from 'modules/bookmarks/models/bookmark'; import View from 'modules/bookmarks/views/bookmarkItemVw'; +import App from 'models/app'; describe('bookmarks', function () { describe('single bookmark', function () { diff --git a/frontend/test/runner.js b/frontend/test/runner.js index 2fda4f52e..6ac067685 100644 --- a/frontend/test/runner.js +++ b/frontend/test/runner.js @@ -1,10 +1,10 @@ import $ from 'jquery'; +import 'exports'; import 'lib/jquery.i18n/jquery.i18n.extend.js'; -import Bootstrap from 'bootstrap'; import 'lib/saito/backbone.modelHelper'; import 'lib/saito/underscore.extend'; import App from 'models/app'; -import EventBus from 'app/vent'; +import EventBus from 'app/vent.ts'; $.fx.off = true; window.$ = $; diff --git a/frontend/test/views/AppViewSpec.js b/frontend/test/views/AppViewSpec.js index e1dfe7630..74883bb4b 100644 --- a/frontend/test/views/AppViewSpec.js +++ b/frontend/test/views/AppViewSpec.js @@ -3,8 +3,8 @@ import View from 'views/app'; import AnswerModel from 'modules/answering/models/AnswerModel.ts'; describe("App", function () { - describe("View", function () { + let view; beforeEach(function (done) { var SaitoApp = { @@ -18,42 +18,40 @@ describe("App", function () { } }; - App.request = SaitoApp.request; - this.view = new View(); + App.request.set(SaitoApp.request); + view = new View(); done(); - }); it('manually mark as read should call entries/update', function () { spyOn(window, 'redirect'); - this.view.manuallyMarkAsRead(); + view.manuallyMarkAsRead(); expect(window.redirect).toHaveBeenCalledWith( '/test/root/entries/update' ); }); - }); - describe('initialize answer view from DOM', () => { - it('redirects on successful answer', () => { - spyOn($, 'ajax'); // suppress ajax calls - spyOn(window, 'redirect'); + describe('initialize answer view from DOM', () => { + it('redirects on successful answer', () => { + spyOn($, 'ajax'); // suppress ajax calls + spyOn(window, 'redirect'); + + const answeringView = view._initAnsweringNotInlined($('
      ')); - const view = new View(); - const answeringView = view._initAnsweringNotInlined($('
      ')); + const model = new AnswerModel({'id': 9}); + answeringView.trigger('answering:send:success', model); - const model = new AnswerModel({'id': 9}); - answeringView.trigger('answering:send:success', model); + expect(window.redirect).toHaveBeenCalledWith('/test/root/entries/view/9'); + }); - expect(window.redirect).toHaveBeenCalledWith('/test/root/entries/view/9'); + it('inits model-ID from DOM on edit', () => { + spyOn($, 'ajax'); // suppress ajax calls + const answeringView = view._initAnsweringNotInlined($('
      ')); + expect(answeringView.model.get('id')).toBe(9); + }) }); - it('inits model-ID from DOM on edit', () => { - spyOn($, 'ajax'); // suppress ajax calls - const view = new View(); - const answeringView = view._initAnsweringNotInlined($('
      ')); - expect(answeringView.model.get('id')).toBe(9); - }) }); }); diff --git a/frontend/test/views/HelpsSpec.js b/frontend/test/views/HelpsSpec.js index a33c0ed81..1bf82fdc7 100644 --- a/frontend/test/views/HelpsSpec.js +++ b/frontend/test/views/HelpsSpec.js @@ -22,7 +22,7 @@ describe('Saito help popup', function () { describe('if help is not required', function () { it('shows no help button', function () { - spyOn(view, 'isVisible').and.returnValue(false); + $('.shp').hide(); view.render(); expect(view.$el).not.toHaveClass('is-active'); }); @@ -30,13 +30,11 @@ describe('Saito help popup', function () { describe('if help is required', function () { it('shows help button on page', function () { - spyOn(view, 'isVisible').and.returnValue(true); view.render(); expect(view.$el).toHaveClass('is-active'); }); it('shows popup on click', function () { - spyOn(view, 'isVisible').and.returnValue(true); view.render(); expect($('i.fa-question-circle')).not.toExist(); view.$el.click(); diff --git a/frontend/test/views/PostingLiserViewSpec.js b/frontend/test/views/PostingSliderViewSpec.js similarity index 90% rename from frontend/test/views/PostingLiserViewSpec.js rename to frontend/test/views/PostingSliderViewSpec.js index 5cc5741b0..92c2c4048 100644 --- a/frontend/test/views/PostingLiserViewSpec.js +++ b/frontend/test/views/PostingSliderViewSpec.js @@ -11,7 +11,7 @@ describe('posting slider', () => { const view = new PostingSliderView({model}); const answerModel = new AnswerModel({id: 20}); - App.request.action = 'non-specific-action-triggering-default-route'; + App.request.set({action: 'non-specific-action-triggering-default-route'}); spyOn(window, 'redirect'); view.triggerMethod('childview:answering:send:success', answerModel); @@ -24,7 +24,7 @@ describe('posting slider', () => { const view = new PostingSliderView({model}); const answerModel = new AnswerModel({id: 20}); - App.request.action = 'mix'; + App.request.set({action: 'mix'}); spyOn(window, 'redirect'); view.triggerMethod('childview:answering:send:success', answerModel); diff --git a/src/Template/Entries/json/thread_line.ctp b/src/Template/Entries/json/threadline.ctp similarity index 100% rename from src/Template/Entries/json/thread_line.ctp rename to src/Template/Entries/json/threadline.ctp diff --git a/src/View/Helper/JsDataHelper.php b/src/View/Helper/JsDataHelper.php index 0c7433354..2df5f2c1b 100644 --- a/src/View/Helper/JsDataHelper.php +++ b/src/View/Helper/JsDataHelper.php @@ -16,7 +16,6 @@ use Cake\Http\ServerRequest; use Cake\View\Helper\UrlHelper; use Cake\View\View; -use Saito\JsData\JsData; use Saito\JsData\Notifications; use Saito\User\ForumsUserInterface; @@ -76,7 +75,6 @@ public function getAppJs(View $View, ForumsUserInterface $CurrentUser) 'action' => $request->getParam('action'), 'controller' => $request->getParam('controller'), 'isMobile' => $request->is('mobile'), - 'isPreview' => $request->is('preview'), 'csrf' => $this->_getCsrf($View) ], 'currentUser' => [ diff --git a/tests/TestCase/Controller/EntriesControllerTest.php b/tests/TestCase/Controller/EntriesControllerTest.php index 1713a6c06..ad8b9f7f2 100755 --- a/tests/TestCase/Controller/EntriesControllerTest.php +++ b/tests/TestCase/Controller/EntriesControllerTest.php @@ -754,21 +754,24 @@ public function testSourceSuccess() public function testThreadLineAnon() { - $this->get('/entries/threadLine/6.json'); + $this->_setJson(); + $this->get('/entries/threadline/6'); $this->assertRedirectContains('/login'); } public function testThreadLineForbidden() { $this->_loginUser(3); - $this->get('/entries/threadLine/6.json'); + $this->_setJson(); + $this->get('/entries/threadline/6'); $this->assertRedirectContains('/login'); } public function testThreadLineSucces() { $this->_loginUser(1); - $this->get('/entries/threadLine/6.json'); + $this->_setJson(); + $this->get('/entries/threadline/6'); $this->assertNoRedirect(); $expected = 'Third Thread First_Subject'; $this->assertResponseContains($expected); diff --git a/tsconfig.json b/tsconfig.json index ce9ded6ed..045c9d10e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ // "baseUrl": "frontend/src", "baseUrl": ".", "allowSyntheticDefaultImports": true, // for moment.js + "strict": true, }, "include": [ "frontend/src/**/*", From dbffb0cfcacad338142a25f07f23d4f40e31649a Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sun, 29 Sep 2019 09:51:41 +0200 Subject: [PATCH 02/20] Fix error message on help-pages language redirect --- plugins/SaitoHelp/src/Controller/SaitoHelpsController.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php b/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php index b8adcd1cb..30b37e123 100644 --- a/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php +++ b/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php @@ -18,6 +18,7 @@ use Cake\Event\Event; use Cake\Filesystem\File; use Cake\Filesystem\Folder; +use Cake\Http\Response; use Cake\ORM\Entity; use SaitoHelp\Model\Table\SaitoHelpTable; @@ -44,7 +45,7 @@ public function languageRedirect($id) * * @param string $lang language * @param string $id help page ID - * @return void + * @return Response|Null */ public function view($lang, $id) { @@ -52,15 +53,14 @@ public function view($lang, $id) // try fallback to english default language if (!$help && $lang !== 'en') { - $this->redirect("/help/en/$id"); + return $this->redirect("/help/en/$id"); } if ($help) { $this->set('help', $help); } else { $this->Flash->set(__('sh.nf'), ['element' => 'error']); - $this->redirect('/'); - return; + return $this->redirect('/'); } $isCore = !strpos($id, '.'); From a2339a568b7f1aaad017cfad717b93793553c4ca Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Mon, 7 Oct 2019 15:31:04 +0200 Subject: [PATCH 03/20] Create code coverage report --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4381b7c82..bd9290c73 100644 --- a/composer.json +++ b/composer.json @@ -98,8 +98,9 @@ "cs-check": "phpcs --runtime-set ignore_warnings_on_exit true", "cs-fix": "phpcbf > /dev/null || true", - "phpstan": "vendor/bin/phpstan analyse --ansi", "check": ["@cs-fix", "@cs-check"], + "phpstan": "vendor/bin/phpstan analyse --ansi", + "coverage": "unset XDEBUG_CONFIG; export COMPOSER_PROCESS_TIMEOUT=900; composer phpunit -- --coverage-html docs/local/", "phpunit-stop": "phpunit --colors=always --stderr --stop-on-error --stop-on-failure", "phpunit": "phpunit --colors=always", From 28dbd2664028ec088dd03cf1b9161e89d3ef6b3b Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Mon, 7 Oct 2019 15:37:57 +0200 Subject: [PATCH 04/20] Improves error message display in production --- src/Controller/AppController.php | 4 +- src/Controller/Component/ThemesComponent.php | 11 ++---- src/Controller/ErrorController.php | 39 ++++++++++++++++--- .../User/CurrentUser/CurrentUserFactory.php | 2 +- src/Template/Element/layout/html_header.ctp | 2 +- src/Template/Element/layout/script_tags.ctp | 4 +- src/Template/Error/error400.ctp | 2 - src/Template/Error/error500.ctp | 2 - .../Component/ThemesComponentTest.php | 9 ++--- 9 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index e94a1d6f9..7d0b9eeaa 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -125,7 +125,7 @@ public function beforeFilter(Event $event) { Stopwatch::start('App->beforeFilter()'); - $this->Themes->set(); + $this->Themes->set($this->CurrentUser); // disable forum with admin pref if (Configure::read('Saito.Settings.forum_disabled') && $this->request->getParam('action') !== 'login' && @@ -183,7 +183,7 @@ protected function _setConfigurationFromGetParams() //= change theme on the fly with ?theme= $theme = $this->request->getQuery('theme'); if ($theme) { - $this->Themes->set($theme); + $this->Themes->set($this->CurrentUser, $theme); } //= activate stopwatch diff --git a/src/Controller/Component/ThemesComponent.php b/src/Controller/Component/ThemesComponent.php index 5991cee1e..67a3c1e01 100644 --- a/src/Controller/Component/ThemesComponent.php +++ b/src/Controller/Component/ThemesComponent.php @@ -39,15 +39,12 @@ public function initialize(array $config) /** * Sets theme * + * @param CurrentUserInterface $user current user * @param string $theme theme to set * @return void */ - public function set($theme = null): void + public function set(CurrentUserInterface $user, $theme = null): void { - /** @var AppController */ - $controller = $this->getController(); - $user = $controller->CurrentUser; - if ($theme === null) { $theme = $this->getThemeForUser($user); } else { @@ -57,7 +54,7 @@ public function set($theme = null): void } /** - * Set used theme to default theme. + * Applies the global default theme as activate theme. * * @return void */ @@ -92,7 +89,7 @@ public function getThemeForUser(CurrentUserInterface $user): string } /** - * Gets all available themes for user + * Gets all available themes for user. * * @param CurrentUserInterface $user current user * @return array diff --git a/src/Controller/ErrorController.php b/src/Controller/ErrorController.php index 27e8079a4..06b398b77 100644 --- a/src/Controller/ErrorController.php +++ b/src/Controller/ErrorController.php @@ -12,23 +12,50 @@ namespace App\Controller; -use App\Controller\AppController; +use App\Controller\Component\ThemesComponent; +use App\Controller\Component\TitleComponent; +use Cake\Controller\Controller; +use Cake\Core\Configure; use Cake\Event\Event; +use Saito\User\CurrentUser\CurrentUserFactory; /** - * Custom Error Controller + * Custom Error Controller to render errors in default theme for production. * - * ErrorController extends AppController so the variables for the default-theme - * are populated and the default theme can be used to show errors. + * @property ThemesComponent $Themes + * @property TitleComponent $Title */ -class ErrorController extends AppController +class ErrorController extends Controller { + /** + * {@inheritDoc} + */ + public function initialize() + { + parent::initialize(); + + $this->loadComponent('Themes', Configure::read('Saito.themes')); + // Populate forum title for display in layout + $this->loadComponent('Title'); + } + /** * {@inheritDoc} */ public function beforeRender(Event $event) { parent::beforeRender($event); - $this->viewBuilder()->setTemplatePath('Error'); + + if (!Configure::read('debug')) { + // Pickup custom errorX00.ctp layout files. + $this->viewBuilder()->setTemplatePath('Error'); + + // Set stripped down CurrentUser so calls to it in (default) layout + // .ctp(s) don't fail. + $CurrentUser = CurrentUserFactory::createDummy(); + $this->set('CurrentUser', $CurrentUser); + + $this->Themes->setDefault(); + } } } diff --git a/src/Lib/Saito/User/CurrentUser/CurrentUserFactory.php b/src/Lib/Saito/User/CurrentUser/CurrentUserFactory.php index 70a9ae316..f78c5d171 100644 --- a/src/Lib/Saito/User/CurrentUser/CurrentUserFactory.php +++ b/src/Lib/Saito/User/CurrentUser/CurrentUserFactory.php @@ -80,7 +80,7 @@ public static function createVisitor(Controller $controller, ?array $config = [] } /** - * Creates user without persistence (bots, testing) + * Creates user without persistence (bots, internal error-pages, testing) * * @param array|null $config user configuration (usually empty) * @return CurrentUserInterface diff --git a/src/Template/Element/layout/html_header.ctp b/src/Template/Element/layout/html_header.ctp index 25695737a..d22a22e09 100644 --- a/src/Template/Element/layout/html_header.ctp +++ b/src/Template/Element/layout/html_header.ctp @@ -4,7 +4,7 @@ echo $this->Html->charset(); echo $this->fetch('meta'); echo $this->fetch('css'); -if (isset($CurrentUser) && $CurrentUser->isLoggedIn()) : +if ($CurrentUser->isLoggedIn()) : echo $this->User->generateCss($CurrentUser->getSettings()); endif; diff --git a/src/Template/Element/layout/script_tags.ctp b/src/Template/Element/layout/script_tags.ctp index bdc11eaf1..62067e459 100644 --- a/src/Template/Element/layout/script_tags.ctp +++ b/src/Template/Element/layout/script_tags.ctp @@ -5,9 +5,7 @@ $this->Flash->render(); // this should go into View/Users/login.ctp again $this->Flash->render('auth', ['element' => 'Flash/warning']); -if (isset($CurrentUser)) { - echo $this->Html->scriptBlock($this->JsData->getAppJs($this, $CurrentUser)); -} +echo $this->Html->scriptBlock($this->JsData->getAppJs($this, $CurrentUser)); echo $this->Html->script([ 'vendor.bundle.js', diff --git a/src/Template/Error/error400.ctp b/src/Template/Error/error400.ctp index 6214bcf40..6c8fba75c 100755 --- a/src/Template/Error/error400.ctp +++ b/src/Template/Error/error400.ctp @@ -3,8 +3,6 @@ use Cake\Core\Configure; use Cake\Error\Debugger; use Saito\Exception\SaitoBlackholeException; -$this->layout = 'default'; - if (Configure::read('debug')) : $this->layout = 'dev_error'; diff --git a/src/Template/Error/error500.ctp b/src/Template/Error/error500.ctp index efddc8ad8..8c2b93f0f 100755 --- a/src/Template/Error/error500.ctp +++ b/src/Template/Error/error500.ctp @@ -2,8 +2,6 @@ use Cake\Core\Configure; use Cake\Error\Debugger; -$this->layout = 'default'; - if (Configure::read('debug')) : $this->layout = 'dev_error'; diff --git a/tests/TestCase/Controller/Component/ThemesComponentTest.php b/tests/TestCase/Controller/Component/ThemesComponentTest.php index 30bbb9974..5c09d9bf5 100644 --- a/tests/TestCase/Controller/Component/ThemesComponentTest.php +++ b/tests/TestCase/Controller/Component/ThemesComponentTest.php @@ -57,10 +57,9 @@ public function testApplyDefaultTheme() $this->component->setConfig($config); $user = CurrentUserFactory::createDummy(); - $this->controller->CurrentUser = $user; $this->assertNotEquals('foo', $this->controller->viewBuilder()->getTheme()); - $this->component->set(); + $this->component->set($user); $this->assertEquals('foo', $this->controller->viewBuilder()->getTheme()); } @@ -70,10 +69,9 @@ public function testSetCustomThemeAndDefaultSet() $this->component->setConfig($config); $user = CurrentUserFactory::createDummy(['id' => 1, 'user_theme' => 'bar']); - $this->controller->CurrentUser = $user; // test custom theme applied - $this->component->set(); + $this->component->set($user); $this->assertEquals('bar', $this->controller->viewBuilder()->getTheme()); // test default set @@ -87,9 +85,8 @@ public function testCustomThemeNotAvailable() $this->component->setConfig($config); $user = CurrentUserFactory::createDummy(['id' => '1', 'user_theme' => 'bar']); - $this->controller->CurrentUser = $user; - $this->component->set(); + $this->component->set($user); $this->assertEquals('foo', $this->controller->viewBuilder()->getTheme()); } } From 34e43d4a271e57fd3acb60089054edec99326923 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sun, 29 Sep 2019 09:15:50 +0200 Subject: [PATCH 05/20] Working: Session, Cookie, Form --- composer.json | 3 +- composer.lock | 60 +++++++- .../src/Controller/PostingsController.php | 2 +- .../src/Controller/SaitoHelpsController.php | 2 +- .../src/Controller/SearchesController.php | 2 +- .../src/Controller/SitemapsController.php | 2 +- src/Application.php | 72 ++++++++-- src/Controller/AppController.php | 6 +- .../Component/AuthUserComponent.php | 135 ++++++++++-------- src/Controller/ContactsController.php | 2 +- src/Controller/EntriesController.php | 4 +- src/Controller/PagesController.php | 2 +- src/Controller/StatusController.php | 2 +- src/Controller/UsersController.php | 23 ++- .../Saito/User/Cookie/CurrentUserCookie.php | 90 ------------ src/Locale/de/default.po | 14 +- src/Locale/en/default.po | 14 +- src/Model/Table/UsersTable.php | 16 --- src/Template/Element/Flash/error.ctp | 8 +- src/Template/Element/Flash/notice.ctp | 8 +- src/Template/Element/Flash/success.ctp | 8 +- src/Template/Element/Flash/warning.ctp | 8 +- src/Template/Element/layout/script_tags.ctp | 3 - 23 files changed, 275 insertions(+), 211 deletions(-) delete mode 100644 src/Lib/Saito/User/Cookie/CurrentUserCookie.php diff --git a/composer.json b/composer.json index bd9290c73..bf3878f4b 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "admad/cakephp-jwt-auth": "^2.3", "claviska/simpleimage": "^3.3", "embed/embed": "^3.3", - "layershifter/tld-extract": "^2.0" + "layershifter/tld-extract": "^2.0", + "cakephp/authentication": "^1.2" }, "require-dev": { "cakephp/bake": "~1.0", diff --git a/composer.lock b/composer.lock index 5591bc916..310aeeb4f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d65dfdbacdba8ea525c34199b6f51d16", + "content-hash": "9ec963bf32ec11efe0e22ceb5f9f79e4", "packages": [ { "name": "admad/cakephp-jwt-auth", @@ -150,6 +150,64 @@ ], "time": "2017-01-20T05:00:11+00:00" }, + { + "name": "cakephp/authentication", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/cakephp/authentication.git", + "reference": "98dce91a4440f2d3930b200537e3e91eb9bcf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/authentication/zipball/98dce91a4440f2d3930b200537e3e91eb9bcf149", + "reference": "98dce91a4440f2d3930b200537e3e91eb9bcf149", + "shasum": "" + }, + "require": { + "cakephp/core": "^3.7", + "psr/http-message": "~1.0", + "zendframework/zend-diactoros": "^1.4.0" + }, + "require-dev": { + "cakephp/cakephp": "^3.7", + "cakephp/cakephp-codesniffer": "^3.0", + "firebase/php-jwt": "~5.0", + "phpunit/phpunit": "^5.7.14|^6.0" + }, + "suggest": { + "cakephp/cakephp": "Install version >=3.5.0 to use \"CookieAuthenticator\".", + "cakephp/orm": "To use \"OrmResolver\" (Not needed separately if using full CakePHP framework).", + "cakephp/utility": "Provides CakePHP security methods. Required for the JWT adapter and Legacy password hasher.", + "ext-ldap": "Make sure this php extension is installed and enabled on your system if you want to use the built-in LDAP adapter for \"LdapIdentifier\".", + "firebase/php-jwt": "If you want to use the JWT adapter add this dependency" + }, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "Authentication\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/authentication/graphs/contributors" + } + ], + "description": "Authentication plugin for CakePHP", + "homepage": "https://cakephp.org", + "keywords": [ + "Authentication", + "auth", + "cakephp", + "middleware" + ], + "time": "2019-09-22T15:25:23+00:00" + }, { "name": "cakephp/cakephp", "version": "3.8.2", diff --git a/plugins/Feeds/src/Controller/PostingsController.php b/plugins/Feeds/src/Controller/PostingsController.php index 83deb6c5d..3bc15e2c0 100644 --- a/plugins/Feeds/src/Controller/PostingsController.php +++ b/plugins/Feeds/src/Controller/PostingsController.php @@ -45,7 +45,7 @@ public function initialize() public function beforeFilter(Event $event) { parent::beforeFilter($event); - $this->Auth->allow(['new', 'threads']); + $this->Authentication->allowUnauthenticated(['new', 'threads']); $this->viewBuilder()->enableAutoLayout(false); $this->viewBuilder()->setTemplate('posting'); } diff --git a/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php b/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php index 30b37e123..a622712db 100644 --- a/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php +++ b/plugins/SaitoHelp/src/Controller/SaitoHelpsController.php @@ -75,7 +75,7 @@ public function view($lang, $id) public function beforeFilter(Event $event) { parent::beforeFilter($event); - $this->Auth->allow(); + $this->Authentication->allowUnauthenticated(['languageRedirect', 'view']); } /** diff --git a/plugins/SaitoSearch/src/Controller/SearchesController.php b/plugins/SaitoSearch/src/Controller/SearchesController.php index 55fde0790..3ee2ff4c9 100644 --- a/plugins/SaitoSearch/src/Controller/SearchesController.php +++ b/plugins/SaitoSearch/src/Controller/SearchesController.php @@ -60,7 +60,7 @@ public function initialize() public function beforeFilter(Event $event) { parent::beforeFilter($event); - $this->Auth->allow('simple'); + $this->Authentication->allowUnauthenticated(['simple']); } /** diff --git a/plugins/Sitemap/src/Controller/SitemapsController.php b/plugins/Sitemap/src/Controller/SitemapsController.php index db5679a20..1cfd89dc3 100644 --- a/plugins/Sitemap/src/Controller/SitemapsController.php +++ b/plugins/Sitemap/src/Controller/SitemapsController.php @@ -37,7 +37,7 @@ class SitemapsController extends AppController public function beforeFilter(Event $event) { parent::beforeFilter($event); - $this->Auth->allow(['index', 'file']); + $this->Authentication->allowUnauthenticated(['index', 'file']); $this->response = $this->response->withDisabledCache(); $this->_Generators = new SitemapCollection($this->generators, $this); } diff --git a/src/Application.php b/src/Application.php index f8d0a007b..bb9bc6714 100755 --- a/src/Application.php +++ b/src/Application.php @@ -18,6 +18,9 @@ namespace App; use App\Middleware\SaitoBootstrapMiddleware; +use Authentication\AuthenticationService; +use Authentication\AuthenticationServiceProviderInterface; +use Authentication\Middleware\AuthenticationMiddleware; use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; @@ -28,6 +31,9 @@ use Cake\Http\Middleware\SecurityHeadersMiddleware; use Cake\Routing\Middleware\AssetMiddleware; use Cake\Routing\Middleware\RoutingMiddleware; +use Cake\Routing\Router; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; use Saito\App\Registry; use Stopwatch\Lib\Stopwatch; @@ -37,7 +43,7 @@ * This defines the bootstrapping logic and middleware layers you * want to use in your application. */ -class Application extends BaseApplication +class Application extends BaseApplication implements AuthenticationServiceProviderInterface { /** * {@inheritDoc} @@ -74,6 +80,7 @@ public function bootstrap() Registry::initialize(); + $this->addPlugin('Authentication'); $this->addPlugin(\Admin\Plugin::class, ['routes' => true]); $this->addPlugin(\Api\Plugin::class, ['bootstrap' => true, 'routes' => true]); $this->addPlugin(\Bookmarks\Plugin::class, ['routes' => true]); @@ -120,16 +127,21 @@ public function middleware($middlewareQueue) // Routes collection cache enabled by default, to disable route caching // pass null as cacheConfig, example: `new RoutingMiddleware($this)` // you might want to disable this cache in case your routing is extremely simple - ->add(new RoutingMiddleware($this, '_cake_routes_')); + ->add(new RoutingMiddleware($this, '_cake_routes_')) - $cookies = new EncryptedCookieMiddleware( - // Names of cookies to protect - [Configure::read('Security.cookieAuthName')], - Configure::read('Security.cookieSalt') - ); - $middlewareQueue->add($cookies); + ->insertAfter(RoutingMiddleware::class, new SaitoBootstrapMiddleware()) + + ->add(new EncryptedCookieMiddleware( + // Names of cookies to protect + [Configure::read('Security.cookieAuthName')], + Configure::read('Security.cookieSalt') + )) - $middlewareQueue->insertAfter(RoutingMiddleware::class, new SaitoBootstrapMiddleware()); + // CakePHP authentication provider + ->insertAfter( + EncryptedCookieMiddleware::class, + new AuthenticationMiddleware($this) + ); $security = (new SecurityHeadersMiddleware()) ->setXFrameOptions(strtolower(Configure::read('Saito.X-Frame-Options'))); @@ -138,6 +150,48 @@ public function middleware($middlewareQueue) return $middlewareQueue; } + /** + * Get authentication service. + * + * Part of AuthenticationServiceProviderInterface. + * + * {@inheritDoc} + */ + public function getAuthenticationService(ServerRequestInterface $request, ResponseInterface $response): AuthenticationService + { + $service = new AuthenticationService([ + 'queryParam' => 'redirect', + 'unauthenticatedRedirect' => '/login', + ]); + + $service->loadIdentifier('Authentication.Password'); + + // Authenticators are checked in order of registration. + // Leave Session first. + $service->loadAuthenticator( + 'Authentication.Session', + [ + // Always check against DB. User-state (type, locked) might have + // changed and must be reflected immediately. + 'identify' => true, + ] + ); + $service->loadAuthenticator( + 'Authentication.Cookie', + [ + 'cookie' => [ + 'expire' => new \DateTimeImmutable('+10 days'), + 'httpOnly' => true, + 'name' => Configure::read('Security.cookieAuthName'), + 'path' => Router::url('/', false), + ] + ] + ); + $service->loadAuthenticator('Authentication.Form'); + + return $service; + } + /** * Load the plugin for Saito's default theme * diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 7d0b9eeaa..dbfc3d3bc 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -21,6 +21,7 @@ use App\Controller\Component\ThemesComponent; use App\Controller\Component\TitleComponent; use App\Model\Table\UsersTable; +use Authentication\Controller\Component\AuthenticationComponent; use Cake\Controller\Controller; use Cake\Core\Configure; use Cake\Core\InstanceConfigTrait; @@ -37,6 +38,7 @@ * * @property ActionAuthorizationComponent $ActionAuthorization * @property AuthUserComponent $AuthUser + * @property AuthenticationComponent $Authentication * @property JsDataComponent $JsData * @property RefererComponent $Referer * @property SaitoEmailComponent $SaitoEmail @@ -102,7 +104,7 @@ public function initialize() // Leave in front to have it available in all Components $this->loadComponent('Detectors.Detectors'); $this->loadComponent('Cookie'); - $this->loadComponent('Auth'); + $this->loadComponent('Authentication.Authentication'); $this->loadComponent('ActionAuthorization'); $this->loadComponent('Security', ['blackHoleCallback' => 'blackhole']); $this->loadComponent('Csrf', ['expiry' => time() + 10800]); @@ -143,7 +145,7 @@ public function beforeFilter(Event $event) // allow sql explain for DebugKit toolbar if ($this->request->getParam('plugin') === 'debug_kit') { - $this->Auth->allow('sql_explain'); + $this->Authentication->allowUnauthenticated(['sql_explain']); } $this->_l10nRenderFile(); diff --git a/src/Controller/Component/AuthUserComponent.php b/src/Controller/Component/AuthUserComponent.php index e2de2256c..466955c8b 100755 --- a/src/Controller/Component/AuthUserComponent.php +++ b/src/Controller/Component/AuthUserComponent.php @@ -13,16 +13,17 @@ namespace App\Controller\Component; use App\Controller\AppController; +use App\Model\Entity\User; use App\Model\Table\UsersTable; +use Authentication\Authenticator\CookieAuthenticator; +use Authentication\Controller\Component\AuthenticationComponent; use Cake\Controller\Component; -use Cake\Controller\Component\AuthComponent; use Cake\Controller\Controller; use Cake\Core\Configure; use Cake\Event\Event; use Cake\ORM\TableRegistry; use Firebase\JWT\JWT; use Saito\App\Registry; -use Saito\User\Cookie\CurrentUserCookie; use Saito\User\Cookie\Storage; use Saito\User\CurrentUser\CurrentUserFactory; use Saito\User\CurrentUser\CurrentUserInterface; @@ -31,7 +32,7 @@ /** * Authenticates the current user and bootstraps the CurrentUser information * - * @property AuthComponent $Auth + * @property AuthenticationComponent $Authentication */ class AuthUserComponent extends Component { @@ -47,7 +48,8 @@ class AuthUserComponent extends Component * * @var array */ - public $components = ['Auth', 'Cron.Cron']; + // TODO Check why Cron is used here + public $components = ['Authentication', 'Cron.Cron']; /** * Current user @@ -74,26 +76,25 @@ public function initialize(array $config) $UsersTable = TableRegistry::getTableLocator()->get('Users'); $this->UsersTable = $UsersTable; - $this->initSessionAuth($this->Auth); - if ($this->isBot()) { $CurrentUser = CurrentUserFactory::createDummy(); } else { - $user = $this->_login(); - $controller = $this->getController(); + $user = $this->authenticate(); $isLoggedIn = !empty($user); + $controller = $this->getController(); + $request = $controller->getRequest(); /// don't auto-login on login related pages $excluded = ['login', 'register']; $useLoggedIn = $isLoggedIn - && !in_array($controller->getRequest()->getParam('action'), $excluded); + && !in_array($request->getParam('action'), $excluded); if ($useLoggedIn) { - $CurrentUser = CurrentUserFactory::createLoggedIn($user); + $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray()); $userId = (string)$CurrentUser->getId(); } else { $CurrentUser = CurrentUserFactory::createVisitor($controller); - $userId = $this->request->getSession()->id(); + $userId = $request->getSession()->id(); } $this->UsersTable->UserOnline->setOnline($userId, $useLoggedIn); @@ -123,23 +124,20 @@ public function isBot() */ public function login(): bool { - // destroy any existing session or auth-data + // destroy any existing session or Authentication-data $this->logout(); - // non-logged in session-id is lost after Auth::setUser() + // non-logged in session-id is lost after Authentication $originalSessionId = session_id(); - $user = $this->_login(); + $user = $this->authenticate(); - if (empty($user)) { + if (!$user) { // login failed return false; } - //// Login succesfull - - $user = $this->UsersTable->get($user['id']); - + $this->Authentication->setIdentity($user); $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray()); $this->setCurrentUser($CurrentUser); @@ -152,66 +150,40 @@ public function login(): bool $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password); } - /// set persistent Cookie - $setCookie = (bool)$this->request->getData('remember_me'); - if ($setCookie) { - (new CurrentUserCookie($this->getController()))->write($this->CurrentUser->getId()); - }; - return true; } /** - * Tries to login the user. + * Tries to authenticate and login the user. * - * @return null|array if user is logged-in null otherwise + * @return null|User User if is logged-in, null otherwise. */ - protected function _login(): ?array + protected function authenticate(): ?User { - // Check if AuthComponent knows user from session-storage (usually - // compare session-cookie) - // Notice: Will hit session storage. Usually files. - $user = $this->Auth->user(); + $result = $this->Authentication->getResult(); - if (!$user) { - // Check if user is authenticated via one of the Authenticators - // (cookie, token, …). - // Notice: Authenticators may hit DB to find user - $user = $this->Auth->identify(); - - if (!empty($user)) { - // set user in session-storage to be available in subsequent requests - // Notice: on write Cake 3 will start a new session (new session-id) - $this->Auth->setUser($user); - } - } - - if (empty($user)) { - // Authentication failed. + $loginFailed = !$result->isValid(); + if ($loginFailed) { return null; } - // Session-data may be outdated. Make sure that user-data is up-to-date: - // user not locked/user-type wasn't changend/… since session-storage was written. - // Notice: is going to hit DB - Stopwatch::start('CurrentUser read user from DB'); - $user = $this->UsersTable - ->find('allowedToLogin') - ->where(['id' => $user['id']]) - ->first(); - Stopwatch::stop('CurrentUser read user from DB'); - - if (empty($user)) { - /// no user allowed to login - // destroy any existing (session) storage information + $user = $result->getData(); + + $allowed = $user['activate_code'] === 0 && $user['user_lock'] === false; + + if (!$allowed) { + /// User isn't allowed to be logged-in + // Destroy any existing (session) storage information. $this->logout(); - // send to logout form for formal logout procedure + // Send to logout-form for formal logout procedure. $this->getController()->redirect(['_name' => 'logout']); return null; } - return $user->toArray(); + $this->refreshAuthenticationProvider($user); + + return $user; } /** @@ -227,7 +199,7 @@ public function logout(): void } $this->setCurrentUser(CurrentUserFactory::createVisitor($this->getController())); } - $this->Auth->logout(); + $this->Authentication->logout(); } /** @@ -274,6 +246,43 @@ public function shutdown(Event $event) $this->setJwtCookie($event->getSubject()); } + /** + * Update persistent authentication providers for regular visitors. + * + * Users who visit somewhat regularly shall not be logged-out. + * + * @param User $user User identity to refresh + * @return void + */ + private function refreshAuthenticationProvider(User $user) + { + // Get current authentication provider + $authenticationProvider = $this->Authentication + ->getAuthenticationService() + ->getAuthenticationProvider(); + + // Persistent login provider is cookie based. Every time that cookie is + // used for a login its expiry is pushed forward. + if ($authenticationProvider instanceof CookieAuthenticator) { + $controller = $this->getController(); + + $cookieKey = $authenticationProvider->getConfig('cookie.name'); + $cookie = $controller->getRequest()->getCookieCollection()->get($cookieKey); + if (empty($cookieKey) || empty($cookie)) { + throw new \RuntimeException( + sprintf('Auth-cookie "%s" not found for refresh.', $cookieKey), + 1569739698 + ); + } + + $expire = $authenticationProvider->getConfig('cookie.expire'); + $refreshedCookie = $cookie->withExpiry($expire); + + $response = $controller->getResponse()->withCookie($refreshedCookie); + $controller->setResponse($response); + } + } + /** * Sets (or deletes) the JS-Web-Token in Cookie for access in front-end * diff --git a/src/Controller/ContactsController.php b/src/Controller/ContactsController.php index 3abff19f2..b38cdf78b 100644 --- a/src/Controller/ContactsController.php +++ b/src/Controller/ContactsController.php @@ -31,7 +31,7 @@ public function beforeFilter(Event $event) { parent::beforeFilter($event); $this->set('showDisclaimer', true); - $this->Auth->allow('owner'); + $this->Authentication->allowUnauthenticated(['owner']); } /** diff --git a/src/Controller/EntriesController.php b/src/Controller/EntriesController.php index 31e889094..6df329851 100644 --- a/src/Controller/EntriesController.php +++ b/src/Controller/EntriesController.php @@ -16,12 +16,10 @@ use App\Controller\Component\MarkAsReadComponent; use App\Controller\Component\RefererComponent; use App\Controller\Component\ThreadsComponent; -use App\Model\Entity\Entry; use App\Model\Table\EntriesTable; use Cake\Core\Configure; use Cake\Event\Event; use Cake\Http\Exception\BadRequestException; -use Cake\Http\Exception\ForbiddenException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Http\Exception\NotFoundException; use Cake\Http\Response; @@ -486,7 +484,7 @@ public function beforeFilter(Event $event) 'unlockedActions', ['solve', 'view'] ); - $this->Auth->allow(['index', 'view', 'mix', 'update']); + $this->Authentication->allowUnauthenticated(['index', 'view', 'mix', 'update']); Stopwatch::stop('Entries->beforeFilter()'); } diff --git a/src/Controller/PagesController.php b/src/Controller/PagesController.php index 71aa0b934..0b318a49e 100755 --- a/src/Controller/PagesController.php +++ b/src/Controller/PagesController.php @@ -39,7 +39,7 @@ public function beforeFilter(Event $event) { parent::beforeFilter($event); $this->set('showDisclaimer', true); - $this->Auth->allow(['display']); + $this->Authentication->allowUnauthenticated(['display']); } /** diff --git a/src/Controller/StatusController.php b/src/Controller/StatusController.php index dd5fca5a8..895414355 100644 --- a/src/Controller/StatusController.php +++ b/src/Controller/StatusController.php @@ -88,6 +88,6 @@ protected function _statusAsJson($data) public function beforeFilter(Event $event) { parent::beforeFilter($event); - $this->components()->unload('Auth'); + $this->components()->unload('Authentication'); } } diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index c1ee0cb9a..56a206d79 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -64,15 +64,24 @@ public function initialize() public function login() { $data = $this->request->getData(); - //= just show form if (empty($data['username'])) { + /// Show form to user. + if ($this->getRequest()->getQuery('redirect', null)) { + $this->Flash->set( + __('user.authe.required.exp'), + ['element' => 'warning', 'params' => ['title' => __('user.authe.required.t')]] + ); + }; + return; } - //= successful login with request data if ($this->AuthUser->login()) { + /// Successful login with request data. if ($this->Referer->wasAction('login')) { - return $this->redirect($this->Auth->redirectUrl()); + // TODO + // return $this->redirect($this->Auth->redirectUrl()); + return $this->redirect('/'); } else { return $this->redirect($this->referer()); } @@ -82,7 +91,7 @@ public function login() $username = $this->request->getData('username'); $readUser = $this->Users->findByUsername($username)->first(); - $message = __('auth_loginerror'); + $message = __('user.authe.e.generic'); if (!empty($readUser)) { $User = $readUser->toSaitoUser(); @@ -114,7 +123,9 @@ public function login() ['msgs' => [$message]] ); - $this->Flash->set($message, ['key' => 'auth']); + $this->Flash->set($message, [ + 'element' => 'error', 'params' => ['title' => __('user.authe.e.t')] + ]); } /** @@ -772,7 +783,7 @@ public function beforeFilter(Event $event) $unlocked = ['slidetabToggle', 'slidetabOrder']; $this->Security->setConfig('unlockedActions', $unlocked); - $this->Auth->allow(['login', 'register', 'rs']); + $this->Authentication->allowUnauthenticated(['login', 'register', 'rs']); $this->modLocking = $this->CurrentUser ->permission('saito.core.user.block'); $this->set('modLocking', $this->modLocking); diff --git a/src/Lib/Saito/User/Cookie/CurrentUserCookie.php b/src/Lib/Saito/User/Cookie/CurrentUserCookie.php deleted file mode 100644 index 7f607b72b..000000000 --- a/src/Lib/Saito/User/Cookie/CurrentUserCookie.php +++ /dev/null @@ -1,90 +0,0 @@ - '+30 days', 'refreshAfter' => '+23 days']; - parent::__construct($controller, $key, $config); - } - - /** - * {@inheritDoc} - */ - public function write($id): void - { - $refreshAfter = Chronos::parse($this->getConfig('refreshAfter')); - $data = ['id' => $id, 'refreshAfter' => $refreshAfter->getTimestamp()]; - parent::write($data); - } - - /** - * Gets cookie values - * - * @return null|array cookie values if found, null otherwise - */ - public function read(): ?array - { - $cookie = parent::read(); - - if (!is_array($cookie) || empty($cookie['id'])) { - if (!is_null($cookie)) { - // cookie couldn't be deciphered correctly and is a meaningless string - parent::delete(); - } - - return null; - } - - $this->refresh($cookie); - unset($cookie['refreshAfter']); - - return $cookie; - } - - /** - * Refreshs the cookie so that regularly visiting users aren't logged-out - * - * Cookie is valid for 30 days and is renewed if used for loggin-in within 7 - * days before expiring. - * - * @param array $cookie cookie-data - * @return void - */ - private function refresh(array $cookie): void - { - if (empty($cookie['refreshAfter'])) { - /// previous forum version with the cookie missing this field - $cookie['refreshAfter'] = 0; - } - - if ((int)$cookie['refreshAfter'] > time()) { - return; - } - - $this->write($cookie['id']); - } -} diff --git a/src/Locale/de/default.po b/src/Locale/de/default.po index 1565a2b9d..58b0afa67 100644 --- a/src/Locale/de/default.po +++ b/src/Locale/de/default.po @@ -285,9 +285,12 @@ msgid "authorization.autherror" msgstr "" "Sie besitzen nicht die notwendingen Rechte, um auf diesen Inhalt zuzugreifen." +msgid "user.authe.required.t" +msgstr "Authentifizierung nötig" + #: src/Controller/Component/AuthUserComponent.php:266 -msgid "authentication.error" -msgstr "Der Zugriff auf diese Funktion ist eingeschränkt!" +msgid "user.authe.required.exp" +msgstr "Der Zugriff auf diese Funktion ist eingeschränkt. Bitte melden Sie sich mit den entsprechenden Rechten an." #: src/Controller/Component/SaitoEmailComponent.php:85 msgid "Copy of your message: \":subject\" to \":recipient-name\"" @@ -375,8 +378,11 @@ msgid "All Categories" msgstr "Alle Kategorien" #: src/Controller/UsersController.php:85 -msgid "auth_loginerror" -msgstr "Fehler beim Login!" +msgid "user.authe.e.generic" +msgstr "Der Name oder das Passwort sind nicht korrekt." + +msgid "user.authe.e.t" +msgstr "Fehler beim Login" #: src/Controller/UsersController.php:91 src/Template/Users/index.ctp:51 #: src/Template/Users/view.ctp:35 diff --git a/src/Locale/en/default.po b/src/Locale/en/default.po index ac37d1fdf..fd4a6e441 100644 --- a/src/Locale/en/default.po +++ b/src/Locale/en/default.po @@ -279,6 +279,13 @@ msgstr "You're not authorized to access this content." msgid "authentication.error" msgstr "Access to this function is restricted!" +msgid "user.authe.required.t" +msgstr "Login required" + +#: src/Controller/Component/AuthUserComponent.php:266 +msgid "user.authe.required.exp" +msgstr "Access to this function is restricted. Please log-in." + #: src/Controller/Component/SaitoEmailComponent.php:85 msgid "Copy of your message: \":subject\" to \":recipient-name\"" msgstr "" @@ -365,8 +372,11 @@ msgid "All Categories" msgstr "" #: src/Controller/UsersController.php:85 -msgid "auth_loginerror" -msgstr "Username or Password wrong." +msgid "user.authe.e.generic" +msgstr "Name or password are wrong." + +msgid "user.authe.e.t" +msgstr "Login error" #: src/Controller/UsersController.php:91 src/Template/Users/index.ctp:51 #: src/Template/Users/view.ctp:35 diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 88c2a0183..26f85738d 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -886,22 +886,6 @@ public function findProfile(Query $query, array $options): Query return $query; } - /** - * Find all users allowed to login - * - * @param Query $query query - * @param array $options options - * @return Query - */ - public function findAllowedToLogin(Query $query, array $options): Query - { - $query - ->find('profile') - ->where(['activate_code' => 0, 'user_lock' => false]); - - return $query; - } - /** * Find all sorted by username * diff --git a/src/Template/Element/Flash/error.ctp b/src/Template/Element/Flash/error.ctp index 157846abf..3f000782c 100755 --- a/src/Template/Element/Flash/error.ctp +++ b/src/Template/Element/Flash/error.ctp @@ -1,2 +1,8 @@ JsData->notifications()->add($message, ['type' => 'error']); +$this->JsData->notifications()->add( + $message, + [ + 'title' => $params['title'] ?? null, + 'type' => 'error', + ] +); diff --git a/src/Template/Element/Flash/notice.ctp b/src/Template/Element/Flash/notice.ctp index 1610c3ce6..89d9dbddf 100755 --- a/src/Template/Element/Flash/notice.ctp +++ b/src/Template/Element/Flash/notice.ctp @@ -1,2 +1,8 @@ JsData->notifications()->add($message, ['type' => 'notice']); +$this->JsData->notifications()->add( + $message, + [ + 'title' => $params['title'] ?? null, + 'type' => 'notice', + ] +); diff --git a/src/Template/Element/Flash/success.ctp b/src/Template/Element/Flash/success.ctp index c3befdb9a..2150667e1 100644 --- a/src/Template/Element/Flash/success.ctp +++ b/src/Template/Element/Flash/success.ctp @@ -1,2 +1,8 @@ JsData->notifications()->add($message, ['type' => 'success']); +$this->JsData->notifications()->add( + $message, + [ + 'title' => $params['title'] ?? null, + 'type' => 'success', + ] +); diff --git a/src/Template/Element/Flash/warning.ctp b/src/Template/Element/Flash/warning.ctp index 60cc279f2..96a776baa 100755 --- a/src/Template/Element/Flash/warning.ctp +++ b/src/Template/Element/Flash/warning.ctp @@ -1,2 +1,8 @@ JsData->notifications()->add($message, ['type' => 'warning']); +$this->JsData->notifications()->add( + $message, + [ + 'title' => $params['title'] ?? null, + 'type' => 'warning', + ] +); diff --git a/src/Template/Element/layout/script_tags.ctp b/src/Template/Element/layout/script_tags.ctp index 62067e459..4b6fe3976 100644 --- a/src/Template/Element/layout/script_tags.ctp +++ b/src/Template/Element/layout/script_tags.ctp @@ -1,9 +1,6 @@ Flash->render(); -// @td after full js refactoring and moving getAppJs to the page bottom -// this should go into View/Users/login.ctp again -$this->Flash->render('auth', ['element' => 'Flash/warning']); echo $this->Html->scriptBlock($this->JsData->getAppJs($this, $CurrentUser)); From 67e192a449daeec780762f97a67a1957f48428ce Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sun, 29 Sep 2019 12:38:02 +0200 Subject: [PATCH 06/20] Migrate password hashers and updater to Authentication plugin --- src/Application.php | 17 +++- src/Auth/CookieAuthenticate.php | 86 ------------------- ...r.php => LegacyPasswordHasherSaltless.php} | 17 +--- src/Auth/Mlf2Authenticate.php | 34 -------- src/Auth/Mlf2PasswordHasher.php | 2 +- src/Auth/MlfAuthenticate.php | 31 ------- .../Component/AuthUserComponent.php | 11 ++- 7 files changed, 29 insertions(+), 169 deletions(-) delete mode 100644 src/Auth/CookieAuthenticate.php rename src/Auth/{MlfPasswordHasher.php => LegacyPasswordHasherSaltless.php} (52%) delete mode 100644 src/Auth/Mlf2Authenticate.php delete mode 100644 src/Auth/MlfAuthenticate.php diff --git a/src/Application.php b/src/Application.php index bb9bc6714..bea1f2e94 100755 --- a/src/Application.php +++ b/src/Application.php @@ -17,6 +17,8 @@ */ namespace App; +use App\Auth\LegacyPasswordHasherSaltless; +use App\Auth\Mlf2PasswordHasher; use App\Middleware\SaitoBootstrapMiddleware; use Authentication\AuthenticationService; use Authentication\AuthenticationServiceProviderInterface; @@ -164,7 +166,20 @@ public function getAuthenticationService(ServerRequestInterface $request, Respon 'unauthenticatedRedirect' => '/login', ]); - $service->loadIdentifier('Authentication.Password'); + $service->loadIdentifier('Authentication.Password', [ + 'passwordHasher' => [ + 'className' => 'Authentication.Fallback', + 'hashers' => [ + // Saito passwords (Cake default) + ['className' => 'Authentication.Default'], + // Mylittleforum 2 legacy passwords + ['className' => Mlf2PasswordHasher::class], + // Mylittleforum 1 legacy passwords + ['className' => LegacyPasswordHasherSaltless::class, 'hashType' => 'md5'], + ] + ] + + ]); // Authenticators are checked in order of registration. // Leave Session first. diff --git a/src/Auth/CookieAuthenticate.php b/src/Auth/CookieAuthenticate.php deleted file mode 100644 index bcdd5f9fe..000000000 --- a/src/Auth/CookieAuthenticate.php +++ /dev/null @@ -1,86 +0,0 @@ - 'logout']; - } - - /** - * {@inheritDoc} - */ - public function authenticate(ServerRequest $request, Response $response) - { - $cookie = $this->getCookie()->read(); - if (empty($cookie)) { - return false; - } - - // identification field for value store in cookie is user.id not user.username - $this->setConfig('fields', ['username' => 'id']); - - $user = $this->_findUser($cookie['id']); - if ($user) { - return $user; - } - - return false; - } - - /** - * Handles logout: i.e. delete cookie - * - * @param Event $event the event - * @param array $user the user - * @return void - */ - public function logout(Event $event, array $user) - { - $this->getCookie()->delete(); - } - - /** - * Creates the cookie storage - * - * @return Storage the cookie - */ - private function getCookie(): Storage - { - if (empty($this->PersistentCookie)) { - $this->PersistentCookie = new CurrentUserCookie($this->_registry->getController()); - } - - return $this->PersistentCookie; - } -} diff --git a/src/Auth/MlfPasswordHasher.php b/src/Auth/LegacyPasswordHasherSaltless.php similarity index 52% rename from src/Auth/MlfPasswordHasher.php rename to src/Auth/LegacyPasswordHasherSaltless.php index 9bae73e54..fd1290444 100644 --- a/src/Auth/MlfPasswordHasher.php +++ b/src/Auth/LegacyPasswordHasherSaltless.php @@ -12,28 +12,19 @@ namespace App\Auth; -use Cake\Auth\AbstractPasswordHasher; +use Authentication\PasswordHasher\LegacyPasswordHasher; use Cake\Utility\Security; /** - * mylittleforum 1.x unsalted md5 passwords. + * Check legacy passwords but without using Cake's salt */ -class MlfPasswordHasher extends AbstractPasswordHasher +class LegacyPasswordHasherSaltless extends LegacyPasswordHasher { - /** * {@inheritDoc} */ public function hash($password) { - return Security::hash($password, 'md5', false); - } - - /** - * {@inheritDoc} - */ - public function check($password, $hash) - { - return $hash === self::hash($password); + return Security::hash($password, $this->_config['hashType'], false); } } diff --git a/src/Auth/Mlf2Authenticate.php b/src/Auth/Mlf2Authenticate.php deleted file mode 100644 index e2d4434cf..000000000 --- a/src/Auth/Mlf2Authenticate.php +++ /dev/null @@ -1,34 +0,0 @@ -UsersTable->UserOnline->setOffline($originalSessionId); /// password update - $password = (string)$this->request->getData('password'); - if ($password) { - $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password); + $authenticationService = $this->Authentication->getAuthenticationService(); + /** @var PasswordIdentifier */ + $identifier = $authenticationService->identifiers()->get('Password'); + if ($identifier->needsPasswordRehash()) { + $user = $this->UsersTable->get($user->get('id')); + $user->set('password', $this->request->getData('password')); + $this->UsersTable->save($user); } return true; From df9d366c6695fffdec869213fab7757c57a34e5a Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sun, 29 Sep 2019 19:32:56 +0200 Subject: [PATCH 07/20] Move ActionAuthorization into AuthUserComponent --- src/Controller/AppController.php | 16 ------ .../ActionAuthorizationComponent.php | 50 ------------------- .../Component/AuthUserComponent.php | 48 ++++++++++++++++-- 3 files changed, 44 insertions(+), 70 deletions(-) delete mode 100644 src/Controller/Component/ActionAuthorizationComponent.php diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index dbfc3d3bc..786bd5d35 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -12,7 +12,6 @@ namespace App\Controller; -use App\Controller\Component\ActionAuthorizationComponent; use App\Controller\Component\AuthUserComponent; use App\Controller\Component\JsDataComponent; use App\Controller\Component\RefererComponent; @@ -36,7 +35,6 @@ /** * Class AppController * - * @property ActionAuthorizationComponent $ActionAuthorization * @property AuthUserComponent $AuthUser * @property AuthenticationComponent $Authentication * @property JsDataComponent $JsData @@ -105,7 +103,6 @@ public function initialize() $this->loadComponent('Detectors.Detectors'); $this->loadComponent('Cookie'); $this->loadComponent('Authentication.Authentication'); - $this->loadComponent('ActionAuthorization'); $this->loadComponent('Security', ['blackHoleCallback' => 'blackhole']); $this->loadComponent('Csrf', ['expiry' => time() + 10800]); $this->loadComponent('RequestHandler', ['enableBeforeRedirect' => false]); @@ -273,17 +270,4 @@ protected function _l10nRenderFile() } } } - - /** - * Check if user is authorized. - * - * @param array $user Session.Auth - * @return bool - */ - public function isAuthorized(array $user) - { - $action = $this->request->getParam('action'); - - return $this->ActionAuthorization->isAuthorized($this->CurrentUser, $action); - } } diff --git a/src/Controller/Component/ActionAuthorizationComponent.php b/src/Controller/Component/ActionAuthorizationComponent.php deleted file mode 100644 index 0827d2100..000000000 --- a/src/Controller/Component/ActionAuthorizationComponent.php +++ /dev/null @@ -1,50 +0,0 @@ -_registry->getController(); - if (isset($Controller->actionAuthConfig) - && isset($Controller->actionAuthConfig[$action])) { - $requiredRole = $Controller->actionAuthConfig[$action]; - - return Registry::get('Permission') - ->check($user->getRole(), $requiredRole); - } - - $prefix = $this->request->getParam('prefix'); - $plugin = $this->request->getParam('plugin'); - $isAdminRoute = ($prefix && strtolower($prefix) === 'admin') - || ($plugin && strtolower($plugin) === 'admin'); - if ($isAdminRoute) { - return $user->permission('saito.core.admin.backend'); - } - - return true; - } -} diff --git a/src/Controller/Component/AuthUserComponent.php b/src/Controller/Component/AuthUserComponent.php index bbe0df961..63c435c56 100755 --- a/src/Controller/Component/AuthUserComponent.php +++ b/src/Controller/Component/AuthUserComponent.php @@ -22,10 +22,12 @@ use Cake\Controller\Controller; use Cake\Core\Configure; use Cake\Event\Event; +use Cake\Http\Exception\ForbiddenException; use Cake\ORM\TableRegistry; use Firebase\JWT\JWT; use Saito\App\Registry; use Saito\User\Cookie\Storage; +use Saito\User\CurrentUser\CurrentUser; use Saito\User\CurrentUser\CurrentUserFactory; use Saito\User\CurrentUser\CurrentUserInterface; use Stopwatch\Lib\Stopwatch; @@ -49,8 +51,12 @@ class AuthUserComponent extends Component * * @var array */ - // TODO Check why Cron is used here - public $components = ['Authentication', 'Cron.Cron']; + public $components = [ + 'ActionAuthorization', + 'Authentication', + // TODO Check why Cron is used here + 'Cron.Cron' + ]; /** * Current user @@ -103,6 +109,10 @@ public function initialize(array $config) $this->setCurrentUser($CurrentUser); + if(!$this->isAuthorized($this->CurrentUser)) { + throw new ForbiddenException(); + } + Stopwatch::stop('CurrentUser::initialize()'); } @@ -186,7 +196,7 @@ protected function authenticate(): ?User return null; } - $this->refreshAuthenticationProvider($user); + $this->refreshAuthenticationProvider(); return $user; } @@ -259,7 +269,7 @@ public function shutdown(Event $event) * @param User $user User identity to refresh * @return void */ - private function refreshAuthenticationProvider(User $user) + private function refreshAuthenticationProvider() { // Get current authentication provider $authenticationProvider = $this->Authentication @@ -357,4 +367,34 @@ private function setCurrentUser(CurrentUserInterface $CurrentUser): void $controller->set('CurrentUser', $this->CurrentUser); Registry::set('CU', $this->CurrentUser); } + + /** + * Check if user is authorized to access the current action. + * + * @param CurrentUser $user The current user. + * @return bool True if authorized False otherwise. + */ + private function isAuthorized(CurrentUser $user) + { + $controller = $this->getController(); + $action = $controller->getRequest()->getParam('action'); + + if (isset($controller->actionAuthConfig) + && isset($controller->actionAuthConfig[$action])) { + $requiredRole = $controller->actionAuthConfig[$action]; + + return Registry::get('Permission') + ->check($user->getRole(), $requiredRole); + } + + $prefix = $this->request->getParam('prefix'); + $plugin = $this->request->getParam('plugin'); + $isAdminRoute = ($prefix && strtolower($prefix) === 'admin') + || ($plugin && strtolower($plugin) === 'admin'); + if ($isAdminRoute) { + return $user->permission('saito.core.admin.backend'); + } + + return true; + } } From 3f43afb965bac3da733e1b6b03ef6f7bde6924f5 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sun, 29 Sep 2019 19:39:58 +0200 Subject: [PATCH 08/20] Migrate JWT-auth to Authentication middleware --- composer.json | 4 +- composer.lock | 59 ++----------- .../Api/src/Controller/ApiAppController.php | 40 --------- src/Application.php | 87 +++++++++++-------- .../Component/AuthUserComponent.php | 1 - 5 files changed, 60 insertions(+), 131 deletions(-) diff --git a/composer.json b/composer.json index bf3878f4b..e76d26d74 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,11 @@ "suin/php-rss-writer": "^1.6", "friendsofcake/bootstrap-ui": "dev-develop", "friendsofcake/search": "^4.4", - "admad/cakephp-jwt-auth": "^2.3", "claviska/simpleimage": "^3.3", "embed/embed": "^3.3", "layershifter/tld-extract": "^2.0", - "cakephp/authentication": "^1.2" + "cakephp/authentication": "^1.2", + "firebase/php-jwt": "^5.0" }, "require-dev": { "cakephp/bake": "~1.0", diff --git a/composer.lock b/composer.lock index 310aeeb4f..5df0496c6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,57 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9ec963bf32ec11efe0e22ceb5f9f79e4", + "content-hash": "4fe3322e84051ba16558d0c8bd7d5df2", "packages": [ - { - "name": "admad/cakephp-jwt-auth", - "version": "2.3.2", - "source": { - "type": "git", - "url": "https://github.com/ADmad/cakephp-jwt-auth.git", - "reference": "d8d39a94783e1bc96a5a7155106072f304bb6ea2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ADmad/cakephp-jwt-auth/zipball/d8d39a94783e1bc96a5a7155106072f304bb6ea2", - "reference": "d8d39a94783e1bc96a5a7155106072f304bb6ea2", - "shasum": "" - }, - "require": { - "cakephp/cakephp": "^3.5", - "firebase/php-jwt": "^5.0" - }, - "require-dev": { - "cakephp/chronos": "^1.1", - "phpunit/phpunit": "^5.7.14|^6.0" - }, - "type": "cakephp-plugin", - "autoload": { - "psr-4": { - "ADmad\\JwtAuth\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "ADmad", - "homepage": "https://github.com/ADmad", - "role": "Author" - } - ], - "description": "CakePHP plugin for authenticating using JSON Web Tokens", - "homepage": "http://github.com/ADmad/cakephp-jwt-auth", - "keywords": [ - "Authentication", - "authenticate", - "cakephp", - "jwt" - ], - "time": "2018-04-20T07:58:59+00:00" - }, { "name": "aura/di", "version": "2.2.5", @@ -632,9 +583,9 @@ "authors": [ { "name": "Oscar Otero", - "role": "Developer", "email": "oom@oscarotero.com", - "homepage": "http://oscarotero.com" + "homepage": "http://oscarotero.com", + "role": "Developer" } ], "description": "PHP library to retrieve page info using oembed, opengraph, etc", @@ -2929,8 +2880,8 @@ "authors": [ { "name": "Mark Story", - "role": "Author", - "homepage": "http://mark-story.com" + "homepage": "http://mark-story.com", + "role": "Author" }, { "name": "CakePHP Community", diff --git a/plugins/Api/src/Controller/ApiAppController.php b/plugins/Api/src/Controller/ApiAppController.php index 54a20e282..2e27d8be4 100644 --- a/plugins/Api/src/Controller/ApiAppController.php +++ b/plugins/Api/src/Controller/ApiAppController.php @@ -27,9 +27,6 @@ class ApiAppController extends AppController */ public function initialize() { - // Initialize Jwt-auth before parent, so its config is before other CurrentUser Auth-conf - $this->initializeJwtAuth($this->loadComponent('Auth')); - parent::initialize(); if ($this->components()->has('Csrf')) { @@ -39,41 +36,4 @@ public function initialize() $this->components()->unload('Security'); } } - - /** - * Initialize Jwt-Auth - * - * @see https://github.com/ADmad/cakephp-jwt-auth - * @param AuthComponent $auth Cake's auth-component - * @return void - */ - private function initializeJwtAuth(AuthComponent $auth): void - { - $auth->setConfig([ - 'storage' => 'Memory', - 'authenticate' => [ - 'ADmad/JwtAuth.Jwt' => [ - 'userModel' => 'Users', - 'key' => Configure::read('Security.cookieSalt'), - 'fields' => [ - 'username' => 'id' - ], - - 'parameter' => 'token', - - // Boolean indicating whether the "sub" claim of JWT payload - // should be used to query the Users model and get user info. - // If set to `false` JWT's payload is directly returned. - 'queryDatasource' => true, - ] - ], - - 'unauthorizedRedirect' => false, - 'checkAuthIn' => 'Controller.initialize', - - // If you don't have a login action in your application set - // 'loginAction' to false to prevent getting a MissingRouteException. - 'loginAction' => false - ]); - } } diff --git a/src/Application.php b/src/Application.php index bea1f2e94..a34956ea1 100755 --- a/src/Application.php +++ b/src/Application.php @@ -101,7 +101,6 @@ public function bootstrap() $this->addPlugin(\SpectrumColorpicker\Plugin::class); $this->addPlugin(\Stopwatch\Plugin::class); - $this->addPlugin('ADmad/JwtAuth'); $this->addPlugin('Proffer'); $this->loadDefaultThemePlugin(); @@ -166,43 +165,63 @@ public function getAuthenticationService(ServerRequestInterface $request, Respon 'unauthenticatedRedirect' => '/login', ]); - $service->loadIdentifier('Authentication.Password', [ - 'passwordHasher' => [ - 'className' => 'Authentication.Fallback', - 'hashers' => [ - // Saito passwords (Cake default) - ['className' => 'Authentication.Default'], - // Mylittleforum 2 legacy passwords - ['className' => Mlf2PasswordHasher::class], - // Mylittleforum 1 legacy passwords - ['className' => LegacyPasswordHasherSaltless::class, 'hashType' => 'md5'], + /// Check if request goes to stateless JWT API. + $uri = $request->getUri(); + if (property_exists($uri, 'base')) { + $uri = $uri->withPath($uri->base . $uri->getPath()); + } + $uri= $uri->getPath(); + // TODO Is this save on non root installation? + $apiUri = Router::url('/api/', false); + $isApi = stristr($uri, $apiUri) !== false; + + if ($isApi) { + /// Configure stateless JWT API + $service->loadIdentifier('Authentication.JwtSubject'); + $service->loadAuthenticator('Authentication.Jwt', [ + 'returnPayload' => false, + 'secretKey' => Configure::read('Security.cookieSalt'), + ]); + } else { + /// Configure statefull webapp + $service->loadIdentifier('Authentication.Password', [ + 'passwordHasher' => [ + 'className' => 'Authentication.Fallback', + 'hashers' => [ + // Saito passwords (Cake default) + ['className' => 'Authentication.Default'], + // Mylittleforum 2 legacy passwords + ['className' => Mlf2PasswordHasher::class], + // Mylittleforum 1 legacy passwords + ['className' => LegacyPasswordHasherSaltless::class, 'hashType' => 'md5'], + ] ] - ] - ]); + ]); - // Authenticators are checked in order of registration. - // Leave Session first. - $service->loadAuthenticator( - 'Authentication.Session', - [ - // Always check against DB. User-state (type, locked) might have - // changed and must be reflected immediately. - 'identify' => true, - ] - ); - $service->loadAuthenticator( - 'Authentication.Cookie', - [ - 'cookie' => [ - 'expire' => new \DateTimeImmutable('+10 days'), - 'httpOnly' => true, - 'name' => Configure::read('Security.cookieAuthName'), - 'path' => Router::url('/', false), + // Authenticators are checked in order of registration. + // Leave Session first. + $service->loadAuthenticator( + 'Authentication.Session', + [ + // Always check against DB. User-state (type, locked) might have + // changed and must be reflected immediately. + 'identify' => true, ] - ] - ); - $service->loadAuthenticator('Authentication.Form'); + ); + $service->loadAuthenticator( + 'Authentication.Cookie', + [ + 'cookie' => [ + 'expire' => new \DateTimeImmutable('+10 days'), + 'httpOnly' => true, + 'name' => Configure::read('Security.cookieAuthName'), + 'path' => Router::url('/', false), + ] + ] + ); + $service->loadAuthenticator('Authentication.Form'); + } return $service; } diff --git a/src/Controller/Component/AuthUserComponent.php b/src/Controller/Component/AuthUserComponent.php index 63c435c56..95fb49d46 100755 --- a/src/Controller/Component/AuthUserComponent.php +++ b/src/Controller/Component/AuthUserComponent.php @@ -266,7 +266,6 @@ public function shutdown(Event $event) * * Users who visit somewhat regularly shall not be logged-out. * - * @param User $user User identity to refresh * @return void */ private function refreshAuthenticationProvider() From a657afb44bdc143dfe173c9ebd0d51932290a2b4 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Tue, 1 Oct 2019 19:14:24 +0200 Subject: [PATCH 09/20] Finishes migration to authentication-plugin --- frontend/src/app/app.ts | 2 +- .../Controller/AdminsControllerTest.php | 5 +- .../Controller/SettingsControllerTest.php | 22 +-- .../Controller/UsersControllerTest.php | 36 ++--- .../Controller/BookmarksControllerTest.php | 10 +- .../Controller/UploadsControllerTest.php | 8 +- src/Application.php | 70 +--------- src/Auth/AuthenticationServiceFactory.php | 95 +++++++++++++ .../Component/AuthUserComponent.php | 118 ++++++---------- src/Controller/UsersController.php | 42 ++++-- src/Lib/Saito/Test/IntegrationTestCase.php | 21 ++- src/Locale/de/default.po | 2 +- src/Locale/en/default.po | 2 +- src/Model/Table/UsersTable.php | 65 +++------ tests/Fixture/UserFixture.php | 31 +++-- tests/TestCase/ApplicationTest.php | 80 +++++++++++ ...p => LegacyPasswordHasherSaltlessTest.php} | 20 ++- .../Component/AuthUserComponentTest.php | 99 ++++++++++---- .../Controller/DraftsControllerTest.php | 6 +- .../Controller/EntriesControllerTest.php | 14 +- .../Controller/PostingsControllerTest.php | 8 +- .../Controller/PreviewControllerTest.php | 32 +++-- .../Controller/UsersControllerTest.php | 105 +++++++++------ .../User/Cookie/CurrentUserCookieTest.php | 126 ------------------ tests/TestCase/Model/Table/UsersTableTest.php | 39 ++++-- 25 files changed, 558 insertions(+), 500 deletions(-) create mode 100644 src/Auth/AuthenticationServiceFactory.php create mode 100644 tests/TestCase/ApplicationTest.php rename tests/TestCase/Auth/{MlfPasswordHasherTest.php => LegacyPasswordHasherSaltlessTest.php} (56%) delete mode 100644 tests/TestCase/Lib/Saito/User/Cookie/CurrentUserCookieTest.php diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 05e4bfe87..c2846c3b8 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -98,7 +98,7 @@ class Bootstrap { }); /// set JWT-token - const jwtCookie = document.cookie.match(/Saito-jwt=([^\s;]*)/); + const jwtCookie = document.cookie.match(/Saito-JWT=([^\s;]*)/); if (!jwtCookie) { return; } diff --git a/plugins/Admin/tests/TestCase/Controller/AdminsControllerTest.php b/plugins/Admin/tests/TestCase/Controller/AdminsControllerTest.php index 56f939ea4..c3e932cb1 100644 --- a/plugins/Admin/tests/TestCase/Controller/AdminsControllerTest.php +++ b/plugins/Admin/tests/TestCase/Controller/AdminsControllerTest.php @@ -15,6 +15,7 @@ use App\Controller\ToolsController; use Cake\Cache\Cache; use Cake\Event\EventManager; +use Cake\Http\Exception\ForbiddenException; use Saito\Test\IntegrationTestCase; /** @@ -45,17 +46,17 @@ class AdminControllerTest extends IntegrationTestCase */ public function testAdminEmptyCachesNonAdmin() { + $this->expectException(ForbiddenException::class); $url = '/admin/admins/emptyCaches'; $this->get($url); - $this->assertRedirectLogin($url); } public function testAdminEmptyCachesUser() { $this->_loginUser(2); $url = '/admin/admins/emptyCaches'; + $this->expectException(ForbiddenException::class); $this->get($url); - $this->assertRedirectLogin($url); } public function testAdminEmptyCaches() diff --git a/plugins/Admin/tests/TestCase/Controller/SettingsControllerTest.php b/plugins/Admin/tests/TestCase/Controller/SettingsControllerTest.php index 187bbb0a7..fc14f3721 100644 --- a/plugins/Admin/tests/TestCase/Controller/SettingsControllerTest.php +++ b/plugins/Admin/tests/TestCase/Controller/SettingsControllerTest.php @@ -31,26 +31,8 @@ class SettingsControllerTest extends IntegrationTestCase 'app.UserOnline' ]; - public function testIndexFailureUserNotLoggedIn() + public function testIndexAccess() { - $this->get('/admin/settings/index'); - - $this->assertRedirectLogin('/admin/settings/index'); - } - - public function testIndexFailureUserNoAdmin() - { - $this->_loginUser(2); - $this->get('/admin/settings/index'); - - $this->assertRedirectLogin('/admin/settings/index'); - } - - public function testIndexSuccess() - { - $this->_loginUser(1); - $this->get('/admin/settings/index'); - - $this->assertResponseOk(); + $this->assertRouteForRole('/admin/settings/index', 'admin'); } } diff --git a/plugins/Admin/tests/TestCase/Controller/UsersControllerTest.php b/plugins/Admin/tests/TestCase/Controller/UsersControllerTest.php index 9e5a00931..8da3dbade 100644 --- a/plugins/Admin/tests/TestCase/Controller/UsersControllerTest.php +++ b/plugins/Admin/tests/TestCase/Controller/UsersControllerTest.php @@ -12,6 +12,7 @@ namespace App\Test\TestCase\Controller\Admin; +use Cake\Http\Exception\ForbiddenException; use Cake\ORM\TableRegistry; use Saito\Test\IntegrationTestCase; @@ -26,6 +27,7 @@ class UsersControllerTest extends IntegrationTestCase public $fixtures = [ 'app.Category', + 'app.Draft', 'app.Entry', 'app.Setting', 'app.User', @@ -45,35 +47,33 @@ public function setUp() } } - public function testUsersBlockIndex() + public function testUsersIndexAccess() { - $this->_loginUser(1); - - $this->get('/admin/users/block'); - - $this->assertResponseOk(); + $this->assertRouteForRole('/admin/users/block', 'admin'); } - public function testDelete() + public function testNotAuthenticatedCantDelete() { $this->mockSecurity(); - /* - * not logged in can't delete - */ + $this->expectException(ForbiddenException::class); $url = '/admin/users/delete/3'; $this->get($url); - $this->assertRedirectLogin($url); - $this->assertTrue($this->_controller->Users->exists(3)); + } - /* - * user can't delete admin/users - */ - $url = '/admin/users/delete/4'; + public function testAuthorizationUsersCantDelete() + { + $this->mockSecurity(); + + $this->expectException(ForbiddenException::class); $this->_loginUser(3); + $url = '/admin/users/delete/4'; $this->get($url); - $this->assertTrue($this->_controller->Users->exists(4)); - $this->assertRedirectLogin($url); + } + + public function testDelete() + { + $this->mockSecurity(); /* * mod can access delete ui diff --git a/plugins/Bookmarks/tests/TestCase/Controller/BookmarksControllerTest.php b/plugins/Bookmarks/tests/TestCase/Controller/BookmarksControllerTest.php index a655e94ee..0502893d5 100644 --- a/plugins/Bookmarks/tests/TestCase/Controller/BookmarksControllerTest.php +++ b/plugins/Bookmarks/tests/TestCase/Controller/BookmarksControllerTest.php @@ -12,7 +12,7 @@ namespace Bookmarks\Test\TestCase\Controller; -use Cake\Http\Exception\UnauthorizedException; +use Authentication\Authenticator\UnauthenticatedException; use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Saito\Test\IntegrationTestCase; @@ -47,7 +47,7 @@ public function setUp() public function testIndexNoAuthorization() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->get('api/v2/bookmarks'); } @@ -99,7 +99,7 @@ public function testIndexSuccess() public function testEditFailureNotLoggedIn() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->put('api/v2/bookmarks/1'); } @@ -146,7 +146,7 @@ public function testEditSuccess() public function testDeleteFailureNotLoggedIn() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->delete('api/v2/bookmarks/1'); } @@ -175,7 +175,7 @@ public function testDeleteSuccess() public function testAddFailureNotLoggedIn() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->post('api/v2/bookmarks/'); } diff --git a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php index 8240ebe51..983ca09ff 100644 --- a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php +++ b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php @@ -13,10 +13,10 @@ namespace ImageUploader\Test\TestCase\Controller; use Api\Error\Exception\GenericApiException; +use Authentication\Authenticator\UnauthenticatedException; use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Filesystem\File; -use Cake\Http\Exception\UnauthorizedException; use Cake\ORM\TableRegistry; use ImageUploader\Model\Table\UploadsTable; use Saito\Exception\SaitoForbiddenException; @@ -59,7 +59,7 @@ public function tearDown() public function testAddNotAuthorized() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->post('api/v2/uploads', []); } @@ -259,7 +259,7 @@ public function testAddFailureFilenameToLong() public function testIndexNoAuthorization() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->get('api/v2/uploads'); } @@ -302,7 +302,7 @@ public function testIndexSuccess() public function testDeleteNoAuthorization() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->delete('api/v2/uploads/1'); } diff --git a/src/Application.php b/src/Application.php index a34956ea1..ced100496 100755 --- a/src/Application.php +++ b/src/Application.php @@ -17,12 +17,12 @@ */ namespace App; -use App\Auth\LegacyPasswordHasherSaltless; -use App\Auth\Mlf2PasswordHasher; +use App\Auth\AuthenticationServiceFactory; use App\Middleware\SaitoBootstrapMiddleware; use Authentication\AuthenticationService; use Authentication\AuthenticationServiceProviderInterface; use Authentication\Middleware\AuthenticationMiddleware; +use Authentication\UrlChecker\DefaultUrlChecker; use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; use Cake\Core\Plugin; @@ -33,7 +33,6 @@ use Cake\Http\Middleware\SecurityHeadersMiddleware; use Cake\Routing\Middleware\AssetMiddleware; use Cake\Routing\Middleware\RoutingMiddleware; -use Cake\Routing\Router; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Saito\App\Registry; @@ -160,70 +159,13 @@ public function middleware($middlewareQueue) */ public function getAuthenticationService(ServerRequestInterface $request, ResponseInterface $response): AuthenticationService { - $service = new AuthenticationService([ - 'queryParam' => 'redirect', - 'unauthenticatedRedirect' => '/login', - ]); - - /// Check if request goes to stateless JWT API. - $uri = $request->getUri(); - if (property_exists($uri, 'base')) { - $uri = $uri->withPath($uri->base . $uri->getPath()); - } - $uri= $uri->getPath(); - // TODO Is this save on non root installation? - $apiUri = Router::url('/api/', false); - $isApi = stristr($uri, $apiUri) !== false; - + $isApi = (new DefaultUrlChecker()) + ->check($request, ['#api/v2#'], ['useRegex' => true]); if ($isApi) { - /// Configure stateless JWT API - $service->loadIdentifier('Authentication.JwtSubject'); - $service->loadAuthenticator('Authentication.Jwt', [ - 'returnPayload' => false, - 'secretKey' => Configure::read('Security.cookieSalt'), - ]); - } else { - /// Configure statefull webapp - $service->loadIdentifier('Authentication.Password', [ - 'passwordHasher' => [ - 'className' => 'Authentication.Fallback', - 'hashers' => [ - // Saito passwords (Cake default) - ['className' => 'Authentication.Default'], - // Mylittleforum 2 legacy passwords - ['className' => Mlf2PasswordHasher::class], - // Mylittleforum 1 legacy passwords - ['className' => LegacyPasswordHasherSaltless::class, 'hashType' => 'md5'], - ] - ] - - ]); - - // Authenticators are checked in order of registration. - // Leave Session first. - $service->loadAuthenticator( - 'Authentication.Session', - [ - // Always check against DB. User-state (type, locked) might have - // changed and must be reflected immediately. - 'identify' => true, - ] - ); - $service->loadAuthenticator( - 'Authentication.Cookie', - [ - 'cookie' => [ - 'expire' => new \DateTimeImmutable('+10 days'), - 'httpOnly' => true, - 'name' => Configure::read('Security.cookieAuthName'), - 'path' => Router::url('/', false), - ] - ] - ); - $service->loadAuthenticator('Authentication.Form'); + return AuthenticationServiceFactory::buildJwt(); } - return $service; + return AuthenticationServiceFactory::buildApp(); } /** diff --git a/src/Auth/AuthenticationServiceFactory.php b/src/Auth/AuthenticationServiceFactory.php new file mode 100644 index 000000000..944929e4c --- /dev/null +++ b/src/Auth/AuthenticationServiceFactory.php @@ -0,0 +1,95 @@ +loadIdentifier('Authentication.JwtSubject'); + $service->loadAuthenticator('Authentication.Jwt', [ + 'returnPayload' => false, + 'secretKey' => Configure::read('Security.cookieSalt'), + ]); + + return $service; + } + + /** + * Build authentication service with Session, Cookie and Form + * + * @return AuthenticationService + */ + public static function buildApp(): AuthenticationService + { + $service = new AuthenticationService(); + + $service->setConfig('queryParam', 'redirect'); + $service->setConfig('unauthenticatedRedirect', '/login'); + + $service->loadIdentifier('Authentication.Password', [ + 'passwordHasher' => [ + 'className' => 'Authentication.Fallback', + 'hashers' => [ + // Saito passwords (Cake default) + ['className' => 'Authentication.Default'], + // Mylittleforum 2 legacy passwords + ['className' => Mlf2PasswordHasher::class], + // Mylittleforum 1 legacy passwords + ['className' => LegacyPasswordHasherSaltless::class, 'hashType' => 'md5'], + ] + ] + ]); + + // Authenticators are checked in order of registration. + // Leave Session first. + $service->loadAuthenticator( + 'Authentication.Session', + [ + // Always check against DB. User-state (type, locked) might have + // changed and must be reflected immediately. + 'identify' => true, + ] + ); + $service->loadAuthenticator( + 'Authentication.Cookie', + [ + 'cookie' => [ + 'expire' => new \DateTimeImmutable('+10 days'), + 'httpOnly' => true, + 'name' => Configure::read('Security.cookieAuthName'), + 'path' => Router::url('/', false), + ] + ] + ); + $service->loadAuthenticator('Authentication.Form', ['loginUrl' => '/login']); + + return $service; + } +} diff --git a/src/Controller/Component/AuthUserComponent.php b/src/Controller/Component/AuthUserComponent.php index 95fb49d46..ba5134b3b 100755 --- a/src/Controller/Component/AuthUserComponent.php +++ b/src/Controller/Component/AuthUserComponent.php @@ -17,13 +17,13 @@ use App\Model\Table\UsersTable; use Authentication\Authenticator\CookieAuthenticator; use Authentication\Controller\Component\AuthenticationComponent; -use Authentication\Identifier\PasswordIdentifier; use Cake\Controller\Component; use Cake\Controller\Controller; use Cake\Core\Configure; use Cake\Event\Event; use Cake\Http\Exception\ForbiddenException; use Cake\ORM\TableRegistry; +use DateTimeImmutable; use Firebase\JWT\JWT; use Saito\App\Registry; use Saito\User\Cookie\Storage; @@ -52,10 +52,7 @@ class AuthUserComponent extends Component * @var array */ public $components = [ - 'ActionAuthorization', - 'Authentication', - // TODO Check why Cron is used here - 'Cron.Cron' + 'Authentication.Authentication', ]; /** @@ -86,30 +83,26 @@ public function initialize(array $config) if ($this->isBot()) { $CurrentUser = CurrentUserFactory::createDummy(); } else { - $user = $this->authenticate(); - $isLoggedIn = !empty($user); - $controller = $this->getController(); $request = $controller->getRequest(); - /// don't auto-login on login related pages - $excluded = ['login', 'register']; - $useLoggedIn = $isLoggedIn - && !in_array($request->getParam('action'), $excluded); - if ($useLoggedIn) { + $user = $this->authenticate(); + if (!empty($user)) { $CurrentUser = CurrentUserFactory::createLoggedIn($user->toArray()); $userId = (string)$CurrentUser->getId(); + $isLoggedIn = true; } else { $CurrentUser = CurrentUserFactory::createVisitor($controller); $userId = $request->getSession()->id(); + $isLoggedIn = false; } - $this->UsersTable->UserOnline->setOnline($userId, $useLoggedIn); + $this->UsersTable->UserOnline->setOnline($userId, $isLoggedIn); } $this->setCurrentUser($CurrentUser); - if(!$this->isAuthorized($this->CurrentUser)) { + if (!$this->isAuthorized($this->CurrentUser)) { throw new ForbiddenException(); } @@ -156,13 +149,9 @@ public function login(): bool $this->UsersTable->UserOnline->setOffline($originalSessionId); /// password update - $authenticationService = $this->Authentication->getAuthenticationService(); - /** @var PasswordIdentifier */ - $identifier = $authenticationService->identifiers()->get('Password'); - if ($identifier->needsPasswordRehash()) { - $user = $this->UsersTable->get($user->get('id')); - $user->set('password', $this->request->getData('password')); - $this->UsersTable->save($user); + $password = (string)$this->request->getData('password'); + if ($password) { + $this->UsersTable->autoUpdatePassword($this->CurrentUser->getId(), $password); } return true; @@ -182,16 +171,16 @@ protected function authenticate(): ?User return null; } + /** @var User User is always retrieved from ORM */ $user = $result->getData(); - $allowed = $user['activate_code'] === 0 && $user['user_lock'] === false; + $isUnactivated = $user['activate_code'] !== 0; + $isLocked = $user['user_lock'] == true; - if (!$allowed) { + if ($isUnactivated || $isLocked) { /// User isn't allowed to be logged-in // Destroy any existing (session) storage information. $this->logout(); - // Send to logout-form for formal logout procedure. - $this->getController()->redirect(['_name' => 'logout']); return null; } @@ -217,42 +206,6 @@ public function logout(): void $this->Authentication->logout(); } - /** - * Configures CakePHP's authentication-component - * - * @param AuthComponent $auth auth-component to configure - * @return void - */ - public function initSessionAuth(AuthComponent $auth): void - { - if ($auth->getConfig('authenticate')) { - // Different auth configuration already in place (e.g. API). This is - // important for the JWT-request, so that we don't authenticate via - // Cookie and open up for xsrf issues. - return; - }; - - $auth->setConfig( - 'authenticate', - [ - AuthComponent::ALL => ['finder' => 'allowedToLogin'], - 'Cookie', - 'Mlf', - 'Mlf2', - 'Form' - ] - ); - - $auth->setConfig('authorize', ['Controller']); - $auth->setConfig('loginAction', '/login'); - - $here = urlencode($this->getController()->getRequest()->getRequestTarget()); - $auth->setConfig('unauthorizedRedirect', '/login?redirect=' . $here); - - $auth->deny(); - $auth->setConfig('authError', __('authentication.error')); - } - /** * {@inheritDoc} */ @@ -298,19 +251,24 @@ private function refreshAuthenticationProvider() } /** - * Sets (or deletes) the JS-Web-Token in Cookie for access in front-end + * Stores (or deletes) the JS-Web-Token as Cookie for access in front-end * * @param Controller $controller The controller * @return void */ private function setJwtCookie(Controller $controller): void { - $cookieKey = Configure::read('Session.cookie') . '-jwt'; - $cookie = (new Storage($controller, $cookieKey, ['http' => false, 'expire' => '+1 week'])); + $expire = '+1 day'; + $cookieKey = Configure::read('Session.cookie') . '-JWT'; + $cookie = new Storage( + $controller, + $cookieKey, + ['http' => false, 'expire' => $expire] + ); $existingToken = $cookie->read(); - // user not logged-in: no JWT-cookie for you + // User not logged-in: No JWT-cookie for you! if (!$this->CurrentUser->isLoggedIn()) { if ($existingToken) { $cookie->delete(); @@ -320,20 +278,32 @@ private function setJwtCookie(Controller $controller): void } if ($existingToken) { - //// check that token belongs to current-user + // Encoded JWT token format:
      .. $parts = explode('.', $existingToken); - // [performance] Done every logged-in request. Don't decrypt whole token with signature. - // We only make sure it exists, the auth happens elsewhere. - $payload = Jwt::jsonDecode(Jwt::urlsafeB64Decode($parts[1])); - if ($payload->sub === $this->CurrentUser->getId() && $payload->exp > time()) { + $payloadEncoded = $parts[1]; + // [performance] Done every logged-in request. Don't decrypt whole + // token with signature. We only make sure it exists, the auth + // happens elsewhere. + $payload = Jwt::jsonDecode(Jwt::urlsafeB64Decode($payloadEncoded)); + $isCurrentUser = $payload->sub === $this->CurrentUser->getId(); + // Assume expired if within the next two hours. + $aboutToExpire = $payload->exp > (time() - 7200); + // Token doesn't require an update if it belongs to current user and + // isn't about to expire. + if ($isCurrentUser && !$aboutToExpire) { return; } } - // use easy to change cookieSalt to allow emergency invalidation of all existing tokens + /// Set new token + // Use easy to change cookieSalt to allow emergency invalidation of all + // existing tokens. $jwtKey = Configure::read('Security.cookieSalt'); - // cookie expires before JWT (7 days < 14 days): JWT exp should always be valid - $jwtPayload = ['sub' => $this->CurrentUser->getId(), 'exp' => time() + (86400 * 14)]; + $jwtPayload = [ + 'sub' => $this->CurrentUser->getId(), + // Token is valid for one day. + 'exp' => (new DateTimeImmutable($expire))->getTimestamp(), + ]; $jwtToken = \Firebase\JWT\JWT::encode($jwtPayload, $jwtKey); $cookie->write($jwtToken); } diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 56a206d79..a4aa8d76f 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -59,12 +59,18 @@ public function initialize() /** * Login user. * - * @return void|\Cake\Network\Response + * @return void|Response */ public function login() { $data = $this->request->getData(); if (empty($data['username'])) { + if ($this->CurrentUser->isLoggedIn()) { + $this->AuthUser->logout(); + + return $this->redirect($this->getRequest()->getRequestTarget()); + } + /// Show form to user. if ($this->getRequest()->getQuery('redirect', null)) { $this->Flash->set( @@ -79,9 +85,9 @@ public function login() if ($this->AuthUser->login()) { /// Successful login with request data. if ($this->Referer->wasAction('login')) { - // TODO - // return $this->redirect($this->Auth->redirectUrl()); - return $this->redirect('/'); + $target = $this->getRequest()->getQuery('redirect', '/'); + + return $this->redirect($target); } else { return $this->redirect($this->referer()); } @@ -89,7 +95,10 @@ public function login() //= error on login $username = $this->request->getData('username'); - $readUser = $this->Users->findByUsername($username)->first(); + /** @var User */ + $readUser = $this->Users->find() + ->where(['username' => $username]) + ->first(); $message = __('user.authe.e.generic'); @@ -104,8 +113,8 @@ public function login() if ($ends) { $time = new Time($ends); $data = [ - $username, - $time->timeAgoInWords(['accuracy' => 'hour']) + 'name' => $username, + 'end' => $time->timeAgoInWords(['accuracy' => 'hour']) ]; $message = __('user.block.pubExpEnds', $data); } else { @@ -131,14 +140,15 @@ public function login() /** * Logout user. * - * @return void + * @return void|Response */ public function logout() { - $cookies = $this->request->getCookieCollection(); + $request = $this->getRequest(); + $cookies = $request->getCookieCollection(); foreach ($cookies as $cookie) { - $cookie = $cookie->withPath($this->request->getAttribute('webroot')); - $this->response = $this->response->withExpiredCookie($cookie); + $cookie = $cookie->withPath($request->getAttribute('webroot')); + $this->setResponse($this->getResponse()->withExpiredCookie($cookie)); } $this->AuthUser->logout(); @@ -148,7 +158,7 @@ public function logout() /** * Register new user. * - * @return void + * @return void|Response */ public function register() { @@ -163,6 +173,12 @@ public function register() $this->set('user', $user); if (!$this->request->is('post')) { + if ($this->CurrentUser->isLoggedIn()) { + $this->AuthUser->logout(); + + return $this->redirect($this->getRequest()->getRequestTarget()); + } + return; } @@ -783,7 +799,7 @@ public function beforeFilter(Event $event) $unlocked = ['slidetabToggle', 'slidetabOrder']; $this->Security->setConfig('unlockedActions', $unlocked); - $this->Authentication->allowUnauthenticated(['login', 'register', 'rs']); + $this->Authentication->allowUnauthenticated(['login', 'logout', 'register', 'rs']); $this->modLocking = $this->CurrentUser ->permission('saito.core.user.block'); $this->set('modLocking', $this->modLocking); diff --git a/src/Lib/Saito/Test/IntegrationTestCase.php b/src/Lib/Saito/Test/IntegrationTestCase.php index 68b8f4520..13808a1fd 100644 --- a/src/Lib/Saito/Test/IntegrationTestCase.php +++ b/src/Lib/Saito/Test/IntegrationTestCase.php @@ -17,6 +17,7 @@ use Cake\Core\Configure; use Cake\Event\Event; use Cake\Event\EventManager; +use Cake\Http\Exception\ForbiddenException; use Cake\Http\Response; use Cake\ORM\TableRegistry; use Cake\Routing\Router; @@ -190,7 +191,7 @@ protected function _loginUser($id) $userFixture = new UserFixture(); $users = $userFixture->records; $user = $users[$id - 1]; - $this->session(['Auth.User' => $user]); + $this->session(['Auth' => $user]); return $user; } @@ -209,7 +210,7 @@ protected function _logoutUser() if (isset($_COOKIE['Saito'])) : unset($_COOKIE['Saito']); endif; - unset($this->_session['Auth.User']); + unset($this->_session['Auth']); } /** @@ -285,9 +286,19 @@ public function assertRouteForRole($route, $role, $referer = true, $method = 'GE } if ($type < $types[$role]) { - $this->{$method}($route); + $cought = false; + try { + $this->{$method}($route); + } catch (ForbiddenException $e) { + $cought = true; + } + $method = strtoupper($method); - $this->assertRedirectLogin($referer, "No login redirect for $role on $method $route"); + + $this->assertTrue( + $cought, + sprintf('Route "%s %s" should not be accessible to role "%s"', $method, $title, $route) + ); } else { $this->{$method}($route); $method = strtoupper($method); @@ -306,7 +317,7 @@ public function assertRouteForRole($route, $role, $referer = true, $method = 'GE public function assertRedirectLogin($redirectUrl = null, string $msg = '') { /** @var Response $response */ - $response = $this->_controller->response; + $response = $this->_response; $expected = Router::url([ '_name' => 'login', 'plugin' => false, diff --git a/src/Locale/de/default.po b/src/Locale/de/default.po index 58b0afa67..d06c1017d 100644 --- a/src/Locale/de/default.po +++ b/src/Locale/de/default.po @@ -391,7 +391,7 @@ msgstr "Das Nutzerkonto wurde noch nicht aktiviert" #: src/Controller/UsersController.php:101 msgid "user.block.pubExpEnds" -msgstr "Mitglied %s wurde gesperrt. Versuchen Sie es in %s erneut." +msgstr "Mitglied {name} wurde gesperrt. Versuchen Sie es in {end} erneut." #: src/Controller/UsersController.php:103 msgid "user.block.pubExp" diff --git a/src/Locale/en/default.po b/src/Locale/en/default.po index fd4a6e441..e14bae7f9 100644 --- a/src/Locale/en/default.po +++ b/src/Locale/en/default.po @@ -385,7 +385,7 @@ msgstr "The user account is not activated yet." #: src/Controller/UsersController.php:101 msgid "user.block.pubExpEnds" -msgstr "User {0} is locked. Try again in %s." +msgstr "User {name} is locked. Try again in {end}." #: src/Controller/UsersController.php:103 msgid "user.block.pubExp" diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 26f85738d..d79ac7269 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -17,8 +17,9 @@ use App\Model\Table\EntriesTable; use App\Model\Table\UserBlocksTable; use App\Model\Table\UserIgnoresTable; -use Cake\Auth\DefaultPasswordHasher; -use Cake\Auth\PasswordHasherFactory; +use Authentication\PasswordHasher\DefaultPasswordHasher; +use Authentication\PasswordHasher\PasswordHasherFactory; +use Authentication\PasswordHasher\PasswordHasherInterface; use Cake\Core\Configure; use Cake\Database\Schema\TableSchema; use Cake\Datasource\EntityInterface; @@ -49,15 +50,6 @@ class UsersTable extends AppTable */ public const USERNAME_MAXLENGTH = 191; - /** - * @var array password hasher - */ - protected $_passwordHasher = [ - 'default' => 'Cake\Auth\DefaultPasswordHasher', - 'App\Auth\Mlf2PasswordHasher', - 'App\Auth\MlfPasswordHasher' - ]; - /** * {@inheritDoc} */ @@ -479,7 +471,7 @@ public function deleteAllExceptEntries(int $userId) } /** - * updates non-blowfish-hash to current hashing method + * Updates the hashed password if hash-algo is out-of-date * * @param int $userId user-ID * @param string $password password @@ -487,14 +479,13 @@ public function deleteAllExceptEntries(int $userId) */ public function autoUpdatePassword(int $userId, string $password): void { - $Entity = $this->get($userId, ['fields' => ['id', 'password']]); - $oldPassword = $Entity->get('password'); - $hasher = new $this->_passwordHasher['default']; - if (!$hasher->needsRehash($oldPassword)) { - return; + $user = $this->get($userId, ['fields' => ['id', 'password']]); + $oldPassword = $user->get('password'); + $needsRehash = $this->getPasswordHasher()->needsRehash($oldPassword); + if ($needsRehash) { + $user->set('password', $password); + $this->save($user); } - $Entity->set('password', $password); - $this->save($Entity); } /** @@ -519,7 +510,7 @@ public function beforeSave( \ArrayObject $options ) { if ($entity->isDirty('password')) { - $hashedPassword = $this->_hashPassword($entity->get('password')); + $hashedPassword = $this->getPasswordHasher()->hash($entity->get('password')); $entity->set('password', $hashedPassword); } } @@ -551,10 +542,10 @@ public function beforeValidate( public function validateCheckOldPassword($value, array $context) { $userId = $context['data']['id']; - $oldPassword = $this->get($userId, ['fields' => ['password']]) + $oldPasswordHash = $this->get($userId, ['fields' => ['password']]) ->get('password'); - return $this->checkPassword($value, $oldPassword); + return $this->getPasswordHasher()->check($value, $oldPasswordHash); } /** @@ -831,35 +822,13 @@ public function setCategory($userId, $category) } /** - * Checks if password is valid against all supported auth methods - * - * @param string $password password - * @param string $hash hash - * @return bool TRUE if password match FALSE otherwise - */ - public function checkPassword($password, $hash) - { - foreach ($this->_passwordHasher as $passwordHasher) { - $hasher = PasswordHasherFactory::build($passwordHasher); - if ($hasher->check($password, $hash)) { - return true; - } - } - - return false; - } - - /** - * Custom hash function used for authentication with Auth component + * Get default password hasher for hashing user passwords. * - * @param string $password passwrod - * @return string hashed password + * @return PasswordHasherInterface */ - protected function _hashPassword($password) + public function getPasswordHasher(): PasswordHasherInterface { - $auth = new DefaultPasswordHasher(); - - return $auth->hash($password); + return PasswordHasherFactory::build(DefaultPasswordHasher::class); } /** diff --git a/tests/Fixture/UserFixture.php b/tests/Fixture/UserFixture.php index d8cc5cfae..293df0c11 100755 --- a/tests/Fixture/UserFixture.php +++ b/tests/Fixture/UserFixture.php @@ -2,6 +2,8 @@ namespace App\Test\Fixture; +use Authentication\PasswordHasher\DefaultPasswordHasher; +use Authentication\PasswordHasher\PasswordHasherFactory; use Cake\TestSuite\Fixture\TestFixture; class UserFixture extends TestFixture @@ -215,19 +217,6 @@ class UserFixture extends TestFixture ] ]; - protected $_common = [ - 'activate_code' => 0, - // `test` - 'password' => '098f6bcd4621d373cade4e832627b4f6', - 'personal_messages' => 0, - 'registered' => '2009-01-01 00:00', - 'slidetab_order' => null, - 'user_automaticaly_mark_as_read' => 0, - 'user_category_custom' => '', - 'user_lock' => 0, - 'user_type' => 'user' - ]; - public $records = [ [ 'id' => 1, @@ -293,6 +282,7 @@ class UserFixture extends TestFixture 'username' => 'Liane', 'user_type' => 'user', 'user_email' => 'liane@example.com', + 'password' => '098f6bcd4621d373cade4e832627b4f6', // outdated password 'personal_messages' => 1, ], [ @@ -305,8 +295,21 @@ class UserFixture extends TestFixture public function init() { + $hasher = PasswordHasherFactory::build(DefaultPasswordHasher::class); + $common = [ + 'activate_code' => 0, + 'password' => $hasher->hash('test'), + 'personal_messages' => 0, + 'registered' => '2009-01-01 00:00', + 'slidetab_order' => null, + 'user_automaticaly_mark_as_read' => 0, + 'user_category_custom' => '', + 'user_lock' => 0, + 'user_type' => 'user' + ]; + foreach ($this->records as $k => $record) { - $this->records[$k] += $this->_common; + $this->records[$k] += $common; } return parent::init(); diff --git a/tests/TestCase/ApplicationTest.php b/tests/TestCase/ApplicationTest.php new file mode 100644 index 000000000..784c6a740 --- /dev/null +++ b/tests/TestCase/ApplicationTest.php @@ -0,0 +1,80 @@ +application = new Application(__DIR__); + } + + public function teardDown() + { + unset($this->application); + parent::tearDown(); + } + + public function testGetAuthenticationServiceJwt() + { + $urls = [ + '/foo/' . self::API_ROOT . '/foo', + '/' . self::API_ROOT, + '/' . self::API_ROOT . '/', + ]; + + foreach ($urls as $url) { + $request = new ServerRequest([], [], $url); + $response = new Response(); + + $provider = $this->application->getAuthenticationService($request, $response); + + $authenticator = $provider->authenticators()->get('Jwt'); + $this->assertNotEmpty($authenticator); + + $authenticator = $provider->authenticators()->get('Session'); + $this->assertEmpty($authenticator); + $authenticator = $provider->authenticators()->get('Cookie'); + $this->assertEmpty($authenticator); + } + } + + public function testGetAuthenticationServiceApp() + { + $urls = [ '/', '/foo', '/foo/', ]; + + foreach ($urls as $url) { + $request = new ServerRequest([], [], $url); + $response = new Response(); + + $provider = $this->application->getAuthenticationService($request, $response); + $authenticator = $provider->authenticators()->get('Session'); + + $this->assertNotEmpty($authenticator); + } + } +} diff --git a/tests/TestCase/Auth/MlfPasswordHasherTest.php b/tests/TestCase/Auth/LegacyPasswordHasherSaltlessTest.php similarity index 56% rename from tests/TestCase/Auth/MlfPasswordHasherTest.php rename to tests/TestCase/Auth/LegacyPasswordHasherSaltlessTest.php index 5a1154ce3..d4a6bfdad 100644 --- a/tests/TestCase/Auth/MlfPasswordHasherTest.php +++ b/tests/TestCase/Auth/LegacyPasswordHasherSaltlessTest.php @@ -1,16 +1,26 @@ Hasher = new MlfPasswordHasher(); + $this->Hasher = new LegacyPasswordHasherSaltless(['hashType' => 'md5']); } public function tearDown() @@ -18,13 +28,13 @@ public function tearDown() unset($this->Hasher); } - public function testPassword() + public function testCheck() { $password = 'Rosinenbrötchen'; $hash = 'df7d879155bec3f2674c2b3e03fe9086'; $this->assertTrue($this->Hasher->check($password, $hash)); - // test own hash + // Test own hash $password = 'Rosinenbrötchen'; $hash = $this->Hasher->hash($password); $this->assertTrue($this->Hasher->check($password, $hash)); diff --git a/tests/TestCase/Controller/Component/AuthUserComponentTest.php b/tests/TestCase/Controller/Component/AuthUserComponentTest.php index 7493f5a08..7b21978af 100644 --- a/tests/TestCase/Controller/Component/AuthUserComponentTest.php +++ b/tests/TestCase/Controller/Component/AuthUserComponentTest.php @@ -1,4 +1,7 @@ controller = $this->getMockBuilder(Controller::class) - ->setConstructorArgs([$request, $response]) - ->setMethods(null) - ->getMock(); - $this->controller->loadComponent('Auth'); - $registry = new ComponentRegistry($this->controller); - $this->component = new AuthUserComponent($registry); - // $event = new Event('Controller.startup', $this->controller); - // $this->component->startup($event); + + $this->_setup(); } public function tearDown() @@ -91,9 +90,9 @@ public function testSetJwtCookieLoggedInSetCookieSet() $event = new Event('Controller.shutdown', $this->controller); $this->component->shutdown($event); - $cookie = $this->controller->getResponse()->getCookie('Saito-jwt'); + $cookie = $this->controller->getResponse()->getCookie('Saito-JWT'); $this->assertNotEmpty($cookie); - $this->assertSame('Saito-jwt', $cookie['name']); + $this->assertSame('Saito-JWT', $cookie['name']); $this->assertFalse($cookie['httpOnly']); } @@ -105,15 +104,15 @@ public function testSetJwtCookieLoggedInSetCookieSet() public function testSetJwtCookieDeleteCookieIfNotLoggedIn() { $request = $this->controller->getRequest(); - $request = $request->withCookieParams(['Saito-jwt' => 'foo']); + $request = $request->withCookieParams(['Saito-JWT' => 'foo']); $this->controller->setRequest($request); $event = new Event('Controller.shutdown', $this->controller); $this->component->shutdown($event); - $cookie = $this->controller->getResponse()->getCookie('Saito-jwt'); + $cookie = $this->controller->getResponse()->getCookie('Saito-JWT'); $this->assertNotEmpty($cookie); - $this->assertSame('Saito-jwt', $cookie['name']); + $this->assertSame('Saito-JWT', $cookie['name']); $this->assertSame('1', $cookie['expire']); } @@ -131,20 +130,74 @@ public function testSetJwtCookieCheckUserAndReplace() $jwtKey = Configure::read('Security.cookieSalt'); $oldUser = 2; - $jwtPayload = ['sub' => $oldUser]; + $jwtPayload = ['sub' => $oldUser, 'exp' => time() + 10]; $jwtToken = \Firebase\JWT\JWT::encode($jwtPayload, $jwtKey); $request = $this->controller->getRequest(); - $request = $request->withCookieParams(['Saito-jwt' => $jwtToken]); + $request = $request->withCookieParams(['Saito-JWT' => $jwtToken]); $this->controller->setRequest($request); $event = new Event('Controller.shutdown', $this->controller); $this->component->shutdown($event); - $cookie = $this->controller->getResponse()->getCookie('Saito-jwt'); + $cookie = $this->controller->getResponse()->getCookie('Saito-JWT'); $this->assertNotEmpty($cookie); - $this->assertSame('Saito-jwt', $cookie['name']); + $this->assertSame('Saito-JWT', $cookie['name']); $payload = \Firebase\JWT\JWT::decode($cookie['value'], $jwtKey, ['HS256']); $this->assertEquals(1, $payload->sub); } + + /** + * Test that the authentication cookie is refreshed. + * + * @return void + */ + public function testAuthenticationRefresh() + { + /// Setup the request for the authenticator + $Users = TableRegistry::getTableLocator()->get('Users'); + $user = $Users->get(1); + $hasher = PasswordHasherFactory::build(DefaultPasswordHasher::class); + $username = $user->get('username'); + $hash = $hasher->hash($username . $user->get('password')); + $cookieName = Configure::read('Security.cookieAuthName'); + $request = (new ServerRequest()) + ->withCookieParams([$cookieName => json_encode([$username, $hash])]); + $this->_setup($request); + + /// Trigger refresh on cookie-login + $this->component->login(); + + /// Test that cookie is set + $cookie = $this->controller->getResponse()->getCookie($cookieName); + $this->assertNotEmpty($cookie); + + /// Test that cookie expiry is set + $authProvider = $this->component->Authentication + ->getAuthenticationService() + ->authenticators() + ->get('Cookie'); + $expire = $authProvider->getConfig('cookie.expire'); + $this->assertWithinRange($expire->getTimestamp(), (int)$cookie['expire'], 2); + } + + private function _setup(ServerRequestInterface $request = null) + { + $request = $request ?: new ServerRequest(); + $response = new Response(); + + $service = AuthenticationServiceFactory::buildApp(); + $result = $service->authenticate($request, $response); + + $request = $request->withAttribute('authentication', $service); + $request = $request->withAttribute('authenticationResult', $result['result']); + + $controller = new Controller($request, $response); + + $registry = new ComponentRegistry($controller); + $component = new AuthUserComponent($registry); + + $this->component = $component; + $this->controller = $controller; + } } diff --git a/tests/TestCase/Controller/DraftsControllerTest.php b/tests/TestCase/Controller/DraftsControllerTest.php index 1b178546e..e470ef6b1 100644 --- a/tests/TestCase/Controller/DraftsControllerTest.php +++ b/tests/TestCase/Controller/DraftsControllerTest.php @@ -12,9 +12,9 @@ namespace App\Test\TestCase\Controller; +use Authentication\Authenticator\UnauthenticatedException; use Cake\Http\Exception\BadRequestException; use Cake\Http\Exception\NotFoundException; -use Cake\Http\Exception\UnauthorizedException; use Cake\ORM\TableRegistry; use Saito\Exception\SaitoForbiddenException; use Saito\Test\IntegrationTestCase; @@ -38,7 +38,7 @@ class DraftsControllerTest extends IntegrationTestCase public function testAddFailureNoAuthorization() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->configRequest([ 'headers' => ['Accept' => 'application/json'] @@ -88,7 +88,7 @@ public function testAddFailureDoubleEntry() public function testEditFailureNoAuthorization() { - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->configRequest([ 'headers' => ['Accept' => 'application/json'] diff --git a/tests/TestCase/Controller/EntriesControllerTest.php b/tests/TestCase/Controller/EntriesControllerTest.php index ad8b9f7f2..d7058cd7a 100755 --- a/tests/TestCase/Controller/EntriesControllerTest.php +++ b/tests/TestCase/Controller/EntriesControllerTest.php @@ -8,6 +8,7 @@ use Cake\Database\Schema\Table; use Cake\Event\Event; use Cake\Event\EventManager; +use Cake\Http\Exception\ForbiddenException; use Cake\ORM\TableRegistry; use Cake\Routing\Router; use Saito\Exception\SaitoForbiddenException; @@ -257,9 +258,9 @@ function ($entry) { public function testDeleteNotLoggedIn() { - $url = '/entries/delete/1'; - $this->get($url); - $this->assertRedirectLogin($url); + $this->expectException(ForbiddenException::class); + + $this->get('/entries/delete/1'); } /* @@ -298,8 +299,9 @@ public function testDeleteSuccess() public function testDeleteNoAuthorization() { $this->_loginUser(3); + $this->expectException(ForbiddenException::class); + $this->post('/entries/delete/1'); - $this->assertRedirectContains('/login'); } public function testDeletePostingDoesntExist() @@ -398,10 +400,10 @@ public function testMergeIsNotAuthorized() $Entries = $this->getMockForTable('Entries', [$mergeMethod]); $Entries->expects($this->never())->method('threadMerge'); + $this->expectException(ForbiddenException::class); + $this->_loginUser(3); $this->post('/entries/merge/4', ['targetId' => 2]); - - $this->assertRedirectContains('/login'); } public function testMergeSuccess() diff --git a/tests/TestCase/Controller/PostingsControllerTest.php b/tests/TestCase/Controller/PostingsControllerTest.php index 2d4f23e49..a5f627315 100644 --- a/tests/TestCase/Controller/PostingsControllerTest.php +++ b/tests/TestCase/Controller/PostingsControllerTest.php @@ -12,9 +12,9 @@ namespace App\Test\TestCase\Controller; +use Authentication\Authenticator\UnauthenticatedException; use Cake\Http\Exception\BadRequestException; use Cake\Http\Exception\NotFoundException; -use Cake\Http\Exception\UnauthorizedException; use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Saito\Exception\SaitoForbiddenException; @@ -43,7 +43,7 @@ public function testAddFailureNoAuthorization() 'headers' => ['Accept' => 'application/json'] ]); - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $data = ['pid' => 1, 'subject' => 'foo']; $this->post('api/v2/postings/', $data); @@ -125,7 +125,7 @@ public function testMetaFailureAuthorization() 'headers' => ['Accept' => 'application/json'] ]); - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->get('api/v2/postingmeta'); } @@ -213,7 +213,7 @@ public function testEditFailureUnauthorized() 'headers' => ['Accept' => 'application/json'] ]); - $this->expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); $this->put('api/v2/postings/9999', []); } diff --git a/tests/TestCase/Controller/PreviewControllerTest.php b/tests/TestCase/Controller/PreviewControllerTest.php index ce88a0e20..28bc6a3ae 100755 --- a/tests/TestCase/Controller/PreviewControllerTest.php +++ b/tests/TestCase/Controller/PreviewControllerTest.php @@ -1,8 +1,18 @@ expectException(UnauthorizedException::class); + $this->expectException(UnauthenticatedException::class); - $this->get('preview/preview'); + $this->get('/api/v2/preview/preview'); } public function testPreviewSuccessNewThread() @@ -59,10 +69,10 @@ public function testPreviewSuccessNewThread() 'text' => 'bar', ]; - $this->post('preview/preview', $data); + $this->post('/api/v2/preview/preview', $data); $this->assertResponseCode(200); - $response = json_decode($this->_response->getBody(), true); + $response = json_decode((string)$this->_response->getBody(), true); $this->assertEquals(999999999999, $response['id']); $this->assertEquals(999999999999, $response['attributes']['id']); @@ -83,10 +93,10 @@ public function testPreviewSuccessNewAnswer() 'text' => 'bar', ]; - $this->post('preview/preview', $data); + $this->post('/api/v2/preview/preview', $data); $this->assertResponseCode(200); - $response = json_decode($this->_response->getBody(), true); + $response = json_decode((string)$this->_response->getBody(), true); $this->assertEquals(999999999999, $response['id']); $this->assertEquals(999999999999, $response['attributes']['id']); @@ -106,10 +116,10 @@ public function testPreviewFailureNoCategory() 'text' => 'bar', ]; - $this->post('preview/preview', $data); + $this->post('/api/v2/preview/preview', $data); $this->assertResponseCode(200); - $response = json_decode($this->_response->getBody(), true); + $response = json_decode((string)$this->_response->getBody(), true); $this->assertArrayHasKey('errors', $response); @@ -127,10 +137,10 @@ public function testPreviewFailureNoSubjectOnRoot() 'text' => 'bar', ]; - $this->post('preview/preview', $data); + $this->post('/api/v2/preview/preview', $data); $this->assertResponseCode(200); - $response = json_decode($this->_response->getBody(), true); + $response = json_decode((string)$this->_response->getBody(), true); $this->assertArrayHasKey('errors', $response); diff --git a/tests/TestCase/Controller/UsersControllerTest.php b/tests/TestCase/Controller/UsersControllerTest.php index 1f471ef1a..1eb6b388d 100755 --- a/tests/TestCase/Controller/UsersControllerTest.php +++ b/tests/TestCase/Controller/UsersControllerTest.php @@ -2,6 +2,7 @@ namespace App\Test\TestCase\Controller; +use Authentication\PasswordHasher\PasswordHasherFactory; use Cake\Auth\DefaultPasswordHasher; use Cake\Core\Configure; use Cake\Datasource\Exception\RecordNotFoundException; @@ -9,6 +10,7 @@ use Cake\Event\EventManager; use Cake\Filesystem\Folder; use Cake\Http\Exception\BadRequestException; +use Cake\Http\Exception\ForbiddenException; use Cake\Http\Exception\NotFoundException; use Cake\Mailer\Email; use Cake\ORM\TableRegistry; @@ -67,8 +69,9 @@ public function testAdminAddSuccess() public function testAdminAddNoAccess() { + $this->expectException(ForbiddenException::class); + $this->post('/admin/users/add'); - $this->assertRedirect('/login'); } public function testLogin() @@ -78,17 +81,17 @@ public function testLogin() $this->get('/'); $this->assertFalse($this->_controller->CurrentUser->isLoggedIn()); $this->assertNull( - $this->_controller->request->getSession()->read('Auth.User') + $this->_controller->request->getSession()->read('Auth') ); $this->mockSecurity(); - $this->post('/users/login', $data); + $this->post('/login', $data); $this->assertFalse($this->_controller->components()->has('Security')); $this->assertTrue($this->_controller->CurrentUser->isLoggedIn()); $this->assertNotNull( - $this->_controller->request->getSession()->read('Auth.User') + $this->_controller->request->getSession()->read('Auth') ); //# successful login redirects @@ -97,13 +100,13 @@ public function testLogin() //# last login time should be set $Users = TableRegistry::get('Users'); $user = $Users->get(3, ['fields' => 'last_login']); - $this->assertWithinRange($user->get('last_login')->toUnixString(), time(), 1); + $this->assertWithinRange($user->get('last_login')->toUnixString(), time(), 2); } public function testLoginShowForm() { //# show login form - $this->get('/users/login'); + $this->get('/login'); $this->assertResponseSuccess(); $this->assertNoRedirect(); @@ -136,7 +139,8 @@ public function testLoginShowForm() $this->_controller->CurrentUser->setSettings($user); $this->assertTrue($this->_controller->CurrentUser->isLoggedIn()); - $this->get('/users/login'); + $this->get('/login'); + $this->assertFalse($this->_controller->CurrentUser->isLoggedIn()); } @@ -144,7 +148,7 @@ public function testLoginUserNotActivated() { $this->mockSecurity(); $data = ['username' => 'Diane', 'password' => 'test']; - $result = $this->post('/users/login', $data); + $result = $this->post('/login', $data); $this->assertResponseContains('is not activated yet.', $result); } @@ -163,7 +167,7 @@ public function testLoginUserLocked() ->will($this->returnValue(false)); $Users->UserBlocks = $UserBlocks; $data = ['username' => 'Walt', 'password' => 'test']; - $this->post('/users/login', $data); + $this->post('/login', $data); $this->assertResponseContains('is locked.'); } @@ -867,30 +871,30 @@ public function testLockResult() $this->mockSecurity(); $this->_loginUser(2); $Users = TableRegistry::get('Users'); + $userToLock = 5; - // locked user are thrown out + /// Mod locks user 5 $this->post('/users/lock', ['lockUserId' => 5]); - $user = $Users->findById(5)->first(); + $user = $Users->findById($userToLock)->first(); $this->assertTrue($user->get('user_lock') == true); $this->_logoutUser(); - $this->_loginUser(5); - $this->get('/entries/index'); - $this->assertRedirect('/logout'); - - // locked user can't relogin + /// Locked user are thrown out + $this->_loginUser($userToLock); + $result = $this->get('/entries/index'); $this->assertFalse($this->_controller->CurrentUser->isLoggedIn()); - $this->assertNull($this->_controller->request->getSession()->read('Auth.User')); + $this->assertNull($this->_controller->request->getSession()->read('Auth')); + /// Locked user can't relogin $this->_logoutUser(); $this->post( - '/users/login', + '/login', ['username' => 'Uma', 'password' => 'test'] ); $this->assertFalse($this->_controller->CurrentUser->isLoggedIn()); $this->assertNull( - $this->_controller->request->getSession()->read('Auth.User') + $this->_controller->request->getSession()->read('Auth') ); } @@ -928,39 +932,48 @@ public function testChangePasswordViewForm() $this->assertNoRedirect(); } - public function testChangePasswordConfirmationFailed() + public function testChangePasswordOldPasswordNotCorrect() { - $this->_loginUser(4); $this->mockSecurity(); + $this->_loginUser(4); + + $Users = TableRegistry::get('Users'); + $password = $Users->get(5)->get('password'); $data = [ - 'password_old' => 'test', + 'password_old' => 'test_something', 'password' => 'test_new_foo', - 'password_confirm' => 'test_new_bar' + 'password_confirm' => 'test_new_foo', ]; $this->post('/users/changepassword/4', $data); - $expected = '098f6bcd4621d373cade4e832627b4f6'; $user = TableRegistry::get('Users'); $result = $user->get(5, ['fields' => 'password']); - $this->assertEquals($result->get('password'), $expected); + $this->assertEquals($result->get('password'), $password); $this->assertNoRedirect(); } - public function testChangePasswordOldPasswordNotCorrect() + public function testChangePasswordConfirmationFailed() { + $this->_loginUser(5); $this->mockSecurity(); - $this->_loginUser(4); + $Users = TableRegistry::get('Users'); + + /// Set user password to "test" with current password-hasher + $user = $Users->get(5); + $oldPassword = 'test'; + $user->set('password', $oldPassword); + $user = $Users->save($user); $data = [ - 'password_old' => 'test_something', + 'password_old' => $oldPassword, 'password' => 'test_new_foo', - 'password_confirm' => 'test_new_foo', + 'password_confirm' => 'test_new_bar', ]; - $this->post('/users/changepassword/4', $data); + $this->post('/users/changepassword/5', $data); - $expected = '098f6bcd4621d373cade4e832627b4f6'; + $expected = $user['password']; $user = TableRegistry::get('Users'); $result = $user->get(5, ['fields' => 'password']); $this->assertEquals($result->get('password'), $expected); @@ -972,17 +985,25 @@ public function testChangePasswordSuccess() { $this->_loginUser(5); $this->mockSecurity(); + $Users = TableRegistry::get('Users'); + + /// Set user password to "test" with current password-hasher + $user = $Users->get(5); + $oldPassword = 'test'; + $user->set('password', $oldPassword); + $user = $Users->save($user); + $data = [ - 'password_old' => 'test', + 'password_old' => $oldPassword, 'password' => 'test_new', 'password_confirm' => 'test_new', ]; $this->post('/users/changepassword/5', $data); - $user = TableRegistry::get('Users'); - $result = $user->get(5, ['fields' => 'password']); + $result = $Users->get(5, ['fields' => 'password']); $pwH = new DefaultPasswordHasher(); - $this->assertTrue($pwH->check('test_new', $result->get('password'))); + $newHash = $result->get('password'); + $this->assertTrue($pwH->check('test_new', $newHash)); $this->assertRedirect('users/edit/5'); } @@ -1034,18 +1055,21 @@ public function testSetPasswordPostSuccess() public function testSetPasswordPostFailurePwDontMatch() { - $this->_loginUser(1); + $userId = 1; + $this->_loginUser($userId); $this->mockSecurity(); + $Users = TableRegistry::get('Users'); + $password = $Users->get($userId)->get('password'); + $data = [ 'password' => 'test_new', 'password_confirm' => 'test_foo', ]; $this->post('/users/setpassword/5', $data); - $expected = '098f6bcd4621d373cade4e832627b4f6'; $user = TableRegistry::get('Users'); $result = $user->get(5, ['fields' => 'password']); - $this->assertEquals($result->get('password'), $expected); + $this->assertEquals($result->get('password'), $password); $this->assertNoRedirect(); } @@ -1127,8 +1151,9 @@ public function testAvatarGetSuccess() public function testAvatarPostNotLoggedInFailure() { - $this->post('/users/avatar/3'); - $this->assertRedirectLogin(); + $url = '/users/avatar/3'; + $this->post($url); + $this->assertRedirectLogin($url); } public function testAvatarPostNotOwnUserFailure() diff --git a/tests/TestCase/Lib/Saito/User/Cookie/CurrentUserCookieTest.php b/tests/TestCase/Lib/Saito/User/Cookie/CurrentUserCookieTest.php deleted file mode 100644 index b4088221c..000000000 --- a/tests/TestCase/Lib/Saito/User/Cookie/CurrentUserCookieTest.php +++ /dev/null @@ -1,126 +0,0 @@ -createCookie(['id' => 5, 'refreshAfter' => $future]); - - $result = $cookie->read(); - - $this->assertEquals(5, $result['id']); - - $result = $this->getCookie(); - - // refresh is not triggered - $this->assertNull($result); - } - - public function testWrite() - { - $cookie = $this->createCookie(); - - $cookie->write(3); - $result = $this->getCookie(); - - $this->assertWithinRange(Chronos::parse('+30 days')->getTimestamp(), $result['expire'], 2); - $this->assertArrayHasKey('refreshAfter', $result['value']); - $this->assertWithinRange(Chronos::parse('+23 days')->getTimestamp(), $result['value']['refreshAfter'], 2); - } - - public function testRefresh() - { - $past = time() - 60; - $cookie = $this->createCookie(['id' => 5, 'refreshAfter' => $past]); - - $cookie->read(); - $result = $this->getCookie(); - - $this->assertArrayHasKey('refreshAfter', $result['value']); - $this->assertWithinRange(Chronos::parse('+23 days')->getTimestamp(), $result['value']['refreshAfter'], 2); - } - - public function testRefreshOnPreviousVersionCookie() - { - $cookie = $this->createCookie(['id' => 5]); - - $cookie->read(); - $result = $this->getCookie(); - - $this->assertArrayHasKey('refreshAfter', $result['value']); - } - - public function testDeleteUnreadableCookie() - { - $cookie = $this->createCookie(['foerkd']); - - $result = $cookie->read(); - - $this->assertNull($result); - - $result = $this->getCookie(); - $this->assertEquals($result['expire'], '1'); - } - - public function tearDown() - { - unset($this->cookieKey, $this->controller); - - parent::tearDown(); - } - - private function createCookie(?array $data = null): CurrentUserCookie - { - $request = (new ServerRequest()); - if ($data) { - $request = $request->withCookieParams([$this->cookieKey => $data]); - } - $response = new Response(); - $this->controller = $this->getMockBuilder(Controller::class) - ->setConstructorArgs([$request, $response]) - ->setMethods(null) - ->getMock(); - - return new CurrentUserCookie($this->controller, $this->cookieKey); - } - - private function getCookie() - { - $data = $this->controller->getResponse()->getCookie($this->cookieKey); - - if (empty($data)) { - return $data; - } - - $data['value'] = json_decode($data['value'], true); - - return $data; - } -} diff --git a/tests/TestCase/Model/Table/UsersTableTest.php b/tests/TestCase/Model/Table/UsersTableTest.php index 0e9a008cf..c550bfc68 100755 --- a/tests/TestCase/Model/Table/UsersTableTest.php +++ b/tests/TestCase/Model/Table/UsersTableTest.php @@ -13,6 +13,11 @@ class UsersTableTest extends SaitoTableTestCase public $tableClass = 'Users'; + /** + * @var UsersTable + */ + public $Table; + public $fixtures = [ 'app.Category', 'app.Draft', @@ -233,7 +238,7 @@ public function testDeleteUser() $this->assertGreaterThan(0, $this->Table->Drafts->findByUserId(3)->count()); /// UserOnline: Set user online. - $this->Table->UserOnline->setOnline(3, true); + $this->Table->UserOnline->setOnline((string)3, true); $this->assertGreaterThan(0, $this->Table->UserOnline->findByUserId(3)->count()); /// Do the actual delete. @@ -285,7 +290,7 @@ public function testSetPassword() $this->Table->save($Entity); $Entity = $this->Table->get(3); - $result = $this->Table->checkPassword($newPw, $Entity->get('password')); + $result = $this->Table->getPasswordHasher()->check($newPw, $Entity->get('password')); $this->assertTrue($result); } @@ -375,34 +380,44 @@ public function testValidateConfirmPassword() $this->assertEmpty($Entity->getErrors()); } - public function testValidateCheckOldPassword() + public function testValidateCheckOldPasswordNotValid() { $Entity = $this->Table->get(3); $data = [ - 'password_old' => 'something', + 'password_old' => 'something_not_wrong', 'password' => 'new_pw_2', 'password_confirm' => 'new_pw_2', ]; - $this->Table->patchEntity($Entity, $data); - $this->assertTrue(array_key_exists('password_old', $Entity->getErrors())); + $result = $this->Table->patchEntity($Entity, $data); + $this->assertArrayHasKey('pwCheckOld', $result->getError('password_old')); + } + + public function testValidateCheckOldPasswordValid() + { + $Entity = $this->Table->get(3); + + $password = time(); + $Entity->set('password', $password); + $this->Table->save($Entity); $data = [ - 'password_old' => 'test', + 'password_old' => $password, 'password' => 'new_pw_2', 'password_confirm' => 'new_pw_2' ]; - $this->Table->patchEntity($Entity, $data); - $this->assertFalse(array_key_exists('password_old', $Entity->getErrors())); + + $result = $this->Table->patchEntity($Entity, $data); + $this->assertEmpty($result->getErrors()); } public function testAutoUpdatePassword() { // test exchanging - $userId = 3; + $userId = 9; $newPassword = 'testtest'; $this->Table->autoUpdatePassword($userId, $newPassword); $Entity = $this->Table->get($userId); - $result = $this->Table->checkPassword( + $result = $this->Table->getPasswordHasher()->check( $newPassword, $Entity->get('password') ); @@ -479,7 +494,7 @@ public function testRegisterSuccess() $this->assertEmpty($user->getErrors()); - $result = $this->Table->checkPassword($pw, $user->get('password')); + $result = $this->Table->getPasswordHasher()->check($pw, $user->get('password')); $this->assertTrue($result); $expected = $data + [ From 91eba7f413c40b28fbe21beb68e17baab17f2165 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Wed, 2 Oct 2019 18:05:33 +0200 Subject: [PATCH 10/20] Cleaned UserController redirect --- src/Controller/UsersController.php | 54 ++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index a4aa8d76f..d5681d68e 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -20,6 +20,7 @@ use Cake\Http\Exception\NotFoundException; use Cake\Http\Response; use Cake\I18n\Time; +use Cake\Routing\Router; use Saito\Exception\Logger\ExceptionLogger; use Saito\Exception\Logger\ForbiddenLogger; use Saito\Exception\SaitoForbiddenException; @@ -65,10 +66,9 @@ public function login() { $data = $this->request->getData(); if (empty($data['username'])) { - if ($this->CurrentUser->isLoggedIn()) { - $this->AuthUser->logout(); - - return $this->redirect($this->getRequest()->getRequestTarget()); + $logout = $this->_logoutAndComeHereAgain(); + if ($logout) { + return $logout; } /// Show form to user. @@ -83,17 +83,19 @@ public function login() } if ($this->AuthUser->login()) { - /// Successful login with request data. - if ($this->Referer->wasAction('login')) { - $target = $this->getRequest()->getQuery('redirect', '/'); + // Redirect query-param in URL. + $target = $this->getRequest()->getQuery('redirect'); + // Referer from Request + $target = $target ?: $this->referer(null, true); - return $this->redirect($target); - } else { - return $this->redirect($this->referer()); + if (!$target || $this->Referer->wasAction('login')) { + $target = '/'; } + + return $this->redirect($target); } - //= error on login + /// error on login $username = $this->request->getData('username'); /** @var User */ $readUser = $this->Users->find() @@ -173,10 +175,9 @@ public function register() $this->set('user', $user); if (!$this->request->is('post')) { - if ($this->CurrentUser->isLoggedIn()) { - $this->AuthUser->logout(); - - return $this->redirect($this->getRequest()->getRequestTarget()); + $logout = $this->_logoutAndComeHereAgain(); + if ($logout) { + return $logout; } return; @@ -393,6 +394,8 @@ public function view($id = null) return; } + $id = (int)$id; + /** @var User */ $user = $this->Users->find() ->contain( @@ -403,10 +406,10 @@ public function view($id = null) 'UserOnline' ] ) - ->where(['Users.id' => $id]) + ->where(['Users.id' => (int)$id]) ->first(); - if ($id === null || empty($user)) { + if (empty($user)) { $this->Flash->set(__('Invalid user'), ['element' => 'error']); return $this->redirect('/'); @@ -426,7 +429,7 @@ public function view($id = null) ($user->numberOfPostings() - $entriesShownOnPage) > 0 ); - if ($this->CurrentUser->getId() === (int)$id) { + if ($this->CurrentUser->getId() === $id) { $ignores = $this->Users->UserIgnores->getAllIgnoredBy($id); $user->set('ignores', $ignores); } @@ -829,4 +832,19 @@ protected function _isEditingAllowed(CurrentUserInterface $CurrentUser, $userId) return $CurrentUser->getId() === (int)$userId; } + + /** + * Logout user if logged in and create response to revisit logged out + * + * @return Response|null + */ + protected function _logoutAndComeHereAgain(): ?Response + { + if (!$this->CurrentUser->isLoggedIn()) { + return null; + } + $this->AuthUser->logout(); + + return $this->redirect($this->getRequest()->getRequestTarget()); + } } From 78efb14d72afdde9791bfc4fa19c4e62c27a9a9c Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Mon, 7 Oct 2019 19:04:13 +0200 Subject: [PATCH 11/20] Insert additional whitespace after closing BBCode tag #360 --- frontend/src/@types/index.d.ts | 4 + .../src/lib/saito/Editor/Bbcode/BbcodeTag.ts | 86 +++++++++ .../MediaInsert/Prefilter/DropboxPrefilter.ts | 20 ++ .../Prefilter/PrefilterAbstract.ts | 48 +++++ .../MediaInsert/Prefilter/YoutubePrefilter.ts | 49 +++++ .../Editor/Bbcode/MediaInsert/markup.media.ts | 76 ++++++++ frontend/src/lib/saito/markup.media.ts | 177 ------------------ .../modules/answering/editor/EditorView.ts | 5 +- .../answering/editor/Menu/MediaInsertView.ts | 10 +- .../editor/MenuButton/MenuButtonUploadView.ts | 12 +- .../test/lib/Saito/Bbcode/BbcodeTagSpec.js | 50 +++++ .../Bbcode/MediaInsert}/MarkItUpSpec.js | 84 +++++---- .../MenuButton/MenuButtonUploadViewSpec.js | 45 +++-- 13 files changed, 430 insertions(+), 236 deletions(-) create mode 100644 frontend/src/lib/saito/Editor/Bbcode/BbcodeTag.ts create mode 100644 frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/DropboxPrefilter.ts create mode 100644 frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/PrefilterAbstract.ts create mode 100644 frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/YoutubePrefilter.ts create mode 100644 frontend/src/lib/saito/Editor/Bbcode/MediaInsert/markup.media.ts delete mode 100644 frontend/src/lib/saito/markup.media.ts create mode 100644 frontend/test/lib/Saito/Bbcode/BbcodeTagSpec.js rename frontend/test/lib/{ => Saito/Bbcode/MediaInsert}/MarkItUpSpec.js (55%) diff --git a/frontend/src/@types/index.d.ts b/frontend/src/@types/index.d.ts index 3be13b4e6..a972d09c2 100644 --- a/frontend/src/@types/index.d.ts +++ b/frontend/src/@types/index.d.ts @@ -100,6 +100,10 @@ declare module 'moment' { export default moment; } +interface IStringable { + toString(): string; +} + /** * Helper declaration for .js files and TS strict */ diff --git a/frontend/src/lib/saito/Editor/Bbcode/BbcodeTag.ts b/frontend/src/lib/saito/Editor/Bbcode/BbcodeTag.ts new file mode 100644 index 000000000..97c3270fe --- /dev/null +++ b/frontend/src/lib/saito/Editor/Bbcode/BbcodeTag.ts @@ -0,0 +1,86 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +interface IBbcodeTag { + tag: string; + attributes?: string; + content?: string; +} + +interface IBbcodeTagConfig { + prefix: string; + suffix: string; +} + +/** + * BBCode block tag [tag attributes]content[/tag]. + */ +class BbcodeTag implements IStringable { + private params: IBbcodeTag; + + private config: IBbcodeTagConfig; + + private defaultConfig: IBbcodeTagConfig = { + prefix: '', + suffix: ' ', + }; + + /** + * Constructor + * + * @param params tag parameter + */ + public constructor(params: IBbcodeTag, config: Partial = {}) { + this.params = params; + this.config = Object.assign({}, this.defaultConfig, config); + } + + /** + * Get tag of [tag attributes]content[/tag] + */ + public getTag(): string { + return this.params.tag; + } + + /** + * Get attributes of [tag attributes]content[/tag] + */ + public getAttributes(): string|null { + return this.params.attributes || null; + } + + /** + * Get content of [tag attributes]content[/tag] + */ + public getContent(): string|null { + return this.params.content || null; + } + + /** + * Get the whole tag as string + */ + public toString(): string { + let out = '[' + this.params.tag; + if (this.getAttributes()) { + out += ' ' + this.getAttributes(); + } + out += ']'; + if (this.getContent()) { + out += this.getContent(); + } + out += '[/' + this.getTag() + ']'; + + // Insert whitespace esp. after to not trigger iOS 12/13 autocorrect. + // @see https://github.com/Schlaefer/Saito/issues/360 + out = this.config.prefix + out + this.config.suffix; + + return out; + } +} + +export default BbcodeTag; diff --git a/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/DropboxPrefilter.ts b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/DropboxPrefilter.ts new file mode 100644 index 000000000..ceb38fc9e --- /dev/null +++ b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/DropboxPrefilter.ts @@ -0,0 +1,20 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import PrefilterAbstract from './PrefilterAbstract'; + +export default class DropboxPreFilter extends PrefilterAbstract { + /** + * Convert dropbox HTML-page URL to actual file URL + * + * @see https://www.dropbox.com/help/201/en + */ + public cleanUp(text: string): string { + return text.replace(/https:\/\/www\.dropbox\.com\//, 'https://dl.dropbox.com/'); + } +} diff --git a/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/PrefilterAbstract.ts b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/PrefilterAbstract.ts new file mode 100644 index 000000000..e5e6e9851 --- /dev/null +++ b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/PrefilterAbstract.ts @@ -0,0 +1,48 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import * as _ from 'underscore'; + +interface IPrefilter { + cleanUp(text: string): string; +} + +/** + * Filters applied before URL is evaluated + */ +abstract class PrefilterAbstract implements IPrefilter { + public abstract cleanUp(text: string): string; + + /** + * Create HTML iframe tag + * + * @param {object} attr - iframe-tag attributes + * @returns {string} + */ + protected createIframe(attr: _.Dictionary): string { + const defaults = { + allowfullscreen: 'allowfullscreen', + frameborder: 0, + height: 315, + width: 560, + }; + _.defaults(attr, defaults); + + const reducer = (memo: string, value: string, key: string) => { + return memo + key + '="' + value + '" '; + }; + let attributes = _.reduce(attr, reducer, ''); + attributes = attributes.trim(); + + return ''; + } +} + +export default PrefilterAbstract; + +export { IPrefilter, PrefilterAbstract }; diff --git a/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/YoutubePrefilter.ts b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/YoutubePrefilter.ts new file mode 100644 index 000000000..ed29f4eaa --- /dev/null +++ b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/Prefilter/YoutubePrefilter.ts @@ -0,0 +1,49 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import PrefilterAbstract from './PrefilterAbstract'; + +export default class YoutubePreFilter extends PrefilterAbstract { + public cleanUp(text: string): string { + let url: string = text; + + if (/http/.test(text) === false) { + url = 'http://' + text; + } + + const regex = /(http|https):\/\/(\w+:?\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/i; + if (!regex.test(url)) { + return text; + } + + let domainRegex: RegExp | undefined; + const matches = url.match(/(https?:\/\/)?(www\.)?(.[^\/:]+)/i); + const domain = matches ? matches.pop() : null; + switch (domain) { + case 'youtu.be': + domainRegex = /youtu.be\/(.*?)(&.*)?$/; + break; + case 'youtube.com': + domainRegex = /v=(.*?)(&.*)?$/; + break; + } + + if (domainRegex !== undefined) { + if (domainRegex.test(url)) { + const mt = url.match(domainRegex); + if (mt) { + text = this.createIframe({ + src: '//www.youtube-nocookie.com/embed/' + mt[1], + }); + } + } + } + + return text; + } +} diff --git a/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/markup.media.ts b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/markup.media.ts new file mode 100644 index 000000000..599359877 --- /dev/null +++ b/frontend/src/lib/saito/Editor/Bbcode/MediaInsert/markup.media.ts @@ -0,0 +1,76 @@ +/** + * Saito - The Threaded Web Forum + * + * @copyright Copyright (c) the Saito Project Developers + * @link https://github.com/Schlaefer/Saito + * @license http://opensource.org/licenses/MIT + */ + +import * as _ from 'underscore'; +import BbcodeTag from './../BbcodeTag'; +import DropboxPreFilter from './Prefilter/DropboxPrefilter'; +import { IPrefilter } from './Prefilter/PrefilterAbstract'; +import YoutubePreFilter from './Prefilter/YoutubePrefilter'; + +/** + * Helper for converting multimedia content to BBCode tags + */ +export default class MarkupMultimedia { + private preFilters: IPrefilter[]; + + public constructor() { + this.preFilters = [ + new DropboxPreFilter(), + new YoutubePreFilter(), + ]; + } + + /** + * Resolve multimedia input to BBCode syntax + * + * @param text - content to embed + * @param options - converting options + * @returns BBCode for multimedia element + */ + public multimedia(text: string, options: object = {}): IStringable { + let textv = $.trim(text); + const patternEnd = '([\\/?]|$)'; + + const patternImage = new RegExp('\\.(png|gif|jpg|jpeg|webp|svg)' + patternEnd, 'i'); + const patternHtml = new RegExp('\\.(mp4|webm|m4v)' + patternEnd, 'i'); + const patternAudio = new RegExp('\\.(m4a|ogg|mp3|wav|opus)' + patternEnd, 'i'); + const patternIframe = /'; - } -} - -class DropboxPreFilter extends PreFilter { - /** - * Convert dropbox HTML-page URL to actual file URL - * - * @see https://www.dropbox.com/help/201/en - */ - public cleanUp(text: string): string { - return text.replace(/https:\/\/www\.dropbox\.com\//, 'https://dl.dropbox.com/'); - } -} - -class YoutubePreFilter extends PreFilter { - public cleanUp(text: string): string { - let url: string = text; - - if (/http/.test(text) === false) { - url = 'http://' + text; - } - - const regex = /(http|https):\/\/(\w+:?\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/i; - if (!regex.test(url)) { - return text; - } - - let domainRegex: RegExp | undefined; - const matches = url.match(/(https?:\/\/)?(www\.)?(.[^\/:]+)/i); - const domain = matches ? matches.pop() : null; - switch (domain) { - case 'youtu.be': - domainRegex = /youtu.be\/(.*?)(&.*)?$/; - break; - case 'youtube.com': - domainRegex = /v=(.*?)(&.*)?$/; - break; - } - - if (domainRegex !== undefined) { - if (domainRegex.test(url)) { - const mt = url.match(domainRegex); - if (mt) { - text = this.createIframe({ - src: '//www.youtube-nocookie.com/embed/' + mt[1], - }); - } - } - } - - return text; - } -} - -/** - * Helper for converting multimedia content to BBCode tags - */ -class MarkupMultimedia { - private preFilters: IPreFilter[]; - - public constructor() { - this.preFilters = [ - new DropboxPreFilter(), - new YoutubePreFilter(), - ]; - } - - /** - * Resolve multimedia input to BBCode syntax - * - * @param text - content to embed - * @param options - converting options - * @returns BBCode for multimedia element - */ - public multimedia(text: string, options: object = {}) { - let textv = $.trim(text); - const patternEnd = '([\\/?]|$)'; - - const patternImage = new RegExp('\\.(png|gif|jpg|jpeg|webp|svg)' + patternEnd, 'i'); - const patternHtml = new RegExp('\\.(mp4|webm|m4v)' + patternEnd, 'i'); - const patternAudio = new RegExp('\\.(m4a|ogg|mp3|wav|opus)' + patternEnd, 'i'); - const patternIframe = /'; - result = markup.multimedia(input); - expected = '[iframe src=http://www.youtube.com/embed/qa-4E8ZDj9s ' + - 'width=560 height=315 frameborder=0 allowfullscreen][/iframe]'; - expect(result).toEqual(expected); + const content = 'src="http://www.youtube.com/embed/qa-4E8ZDj9s" width="560" ' + + 'height="315" frameborder="0" allowfullscreen'; + const input = ''; + const result = markup.multimedia(input); + expect(result.getTag()).toEqual('iframe'); + expect(result.getContent()).toEqual(null); + const attributes = 'src=http://www.youtube.com/embed/qa-4E8ZDj9s ' + + 'width=560 height=315 frameborder=0 allowfullscreen'; + expect(result.getAttributes()).toEqual(attributes); }); it("outputs an [iframe] tag for a raw youtube url", function () { input = 'http://www.youtube.com/watch?v=qa-4E8ZDj9s'; result = markup.multimedia(input); - expected = '[iframe src=//www.youtube-nocookie.com/embed/qa-4E8ZDj9s' + - ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560][/iframe]'; - expect(result).toEqual(expected); + expect(result.getTag()).toEqual('iframe'); + expect(result.getContent()).toEqual(null); + const attributes = 'src=//www.youtube-nocookie.com/embed/qa-4E8ZDj9s' + + ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560'; + expect(result.getAttributes()).toEqual(attributes); }); it("outputs an [iframe] tag for a raw youtube url without protocol", function () { input = 'www.youtube.com/watch?v=0u8KUgUqprw'; result = markup.multimedia(input); - expected = '[iframe src=//www.youtube-nocookie.com/embed/0u8KUgUqprw' + - ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560][/iframe]'; - expect(result).toEqual(expected); + expect(result.getTag()).toEqual('iframe'); + expect(result.getContent()).toEqual(null); + const attributes = 'src=//www.youtube-nocookie.com/embed/0u8KUgUqprw' + + ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560'; + expect(result.getAttributes()).toEqual(attributes); }); it("outputs an [iframe] tag for youtu.be url shortener ", function () { input = 'http://youtu.be/qa-4E8ZDj9s'; result = markup.multimedia(input); - expected = '[iframe src=//www.youtube-nocookie.com/embed/qa-4E8ZDj9s' + - ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560][/iframe]'; - expect(result).toEqual(expected); + expect(result.getTag()).toEqual('iframe'); + expect(result.getContent()).toEqual(null); + const attributes = 'src=//www.youtube-nocookie.com/embed/qa-4E8ZDj9s' + + ' allowfullscreen=allowfullscreen frameborder=0 height=315 width=560'; + expect(result.getAttributes()).toEqual(attributes); }); it("outputs [embed] tag to use embed.ly as fallback", function () { input = 'https://twitter.com/apfelwiki/status/211385090444505088'; + result = markup.multimedia(input); - expected = '[embed]' + input + '[/embed]'; - expect(result).toEqual(expected); + + expect(result.getTag()).toEqual('embed'); + expect(result.getContent()).toEqual(input); + expect(result.getAttributes()).toEqual(null); }); $.each(['png', 'gif', 'jpg', 'jpeg', 'webp'], function (key, value) { it("outputs an [img] tag for " + value + " files", function () { input = 'http://foo.bar/baz.' + value; + result = markup.multimedia(input); - expected = '[img]http://foo.bar/baz.' + value + '[/img]'; - expect(result).toEqual(expected); + + expect(result.getTag()).toEqual('img'); + expect(result.getContent()).toEqual(input); + expect(result.getAttributes()).toEqual(null); }); }); it("replaces dropbox horrible html fubar with download link", function () { input = 'https://www.dropbox.com/foo/baz.png'; + result = markup.multimedia(input); - expected = '[img]https://dl.dropbox.com/foo/baz.png[/img]'; - expect(result).toEqual(expected); - }); + expect(result.getTag()).toEqual('img'); + expect(result.getContent()).toEqual('https://dl.dropbox.com/foo/baz.png'); + expect(result.getAttributes()).toEqual(null); + }); }); - }); diff --git a/frontend/test/modules/answering/editor/MenuButton/MenuButtonUploadViewSpec.js b/frontend/test/modules/answering/editor/MenuButton/MenuButtonUploadViewSpec.js index e8b06d367..c752b5844 100644 --- a/frontend/test/modules/answering/editor/MenuButton/MenuButtonUploadViewSpec.js +++ b/frontend/test/modules/answering/editor/MenuButton/MenuButtonUploadViewSpec.js @@ -11,10 +11,11 @@ describe('answering form', function () { view.getUI('button').trigger('click'); - expect(channel.request).toHaveBeenCalledWith( - 'insert:text', - '[file src=upload]foo.txt[/file]', - ); + const args = channel.request.calls.mostRecent().args; + expect(args[0]).toEqual('insert:text'); + expect(args[1].getTag()).toEqual('file'); + expect(args[1].getAttributes()).toEqual('src=upload'); + expect(args[1].getContent()).toEqual('foo.txt'); }); it('unknown file', function () { @@ -24,10 +25,11 @@ describe('answering form', function () { view.getUI('button').trigger('click'); - expect(channel.request).toHaveBeenCalledWith( - 'insert:text', - '[file src=upload]foo.txt[/file]', - ); + const args = channel.request.calls.mostRecent().args; + expect(args[0]).toEqual('insert:text'); + expect(args[1].getTag()).toEqual('file'); + expect(args[1].getAttributes()).toEqual('src=upload'); + expect(args[1].getContent()).toEqual('foo.txt'); }); it('image', function () { @@ -37,10 +39,11 @@ describe('answering form', function () { view.getUI('button').trigger('click'); - expect(channel.request).toHaveBeenCalledWith( - 'insert:text', - '[img src=upload]foo.jpg[/img]', - ); + const args = channel.request.calls.mostRecent().args; + expect(args[0]).toEqual('insert:text'); + expect(args[1].getTag()).toEqual('img'); + expect(args[1].getAttributes()).toEqual('src=upload'); + expect(args[1].getContent()).toEqual('foo.jpg'); }); it('audio', function () { @@ -50,10 +53,11 @@ describe('answering form', function () { view.getUI('button').trigger('click'); - expect(channel.request).toHaveBeenCalledWith( - 'insert:text', - '[audio src=upload]foo.mp3[/audio]', - ); + const args = channel.request.calls.mostRecent().args; + expect(args[0]).toEqual('insert:text'); + expect(args[1].getTag()).toEqual('audio'); + expect(args[1].getAttributes()).toEqual('src=upload'); + expect(args[1].getContent()).toEqual('foo.mp3'); }); it('video', function () { @@ -63,10 +67,11 @@ describe('answering form', function () { view.getUI('button').trigger('click'); - expect(channel.request).toHaveBeenCalledWith( - 'insert:text', - '[video src=upload]foo.mp4[/video]', - ); + const args = channel.request.calls.mostRecent().args; + expect(args[0]).toEqual('insert:text'); + expect(args[1].getTag()).toEqual('video'); + expect(args[1].getAttributes()).toEqual('src=upload'); + expect(args[1].getContent()).toEqual('foo.mp4'); }); }); }); From f7a7bef75a431dc109b27b938ec47b8adc2da793 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Wed, 9 Oct 2019 17:02:11 +0200 Subject: [PATCH 12/20] Fixes form-login autentication route setup for subdir install --- src/Auth/AuthenticationServiceFactory.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Auth/AuthenticationServiceFactory.php b/src/Auth/AuthenticationServiceFactory.php index 944929e4c..3d60167f5 100644 --- a/src/Auth/AuthenticationServiceFactory.php +++ b/src/Auth/AuthenticationServiceFactory.php @@ -88,7 +88,10 @@ public static function buildApp(): AuthenticationService ] ] ); - $service->loadAuthenticator('Authentication.Form', ['loginUrl' => '/login']); + $service->loadAuthenticator( + 'Authentication.Form', + ['loginUrl' => Router::url(['_name' => 'login'])] + ); return $service; } From 36e10428190a85df54266d4cb02ccac2389b2fdd Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Wed, 9 Oct 2019 17:03:40 +0200 Subject: [PATCH 13/20] Install APCU in docker container --- dev/docker/php7/apache/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dev/docker/php7/apache/Dockerfile b/dev/docker/php7/apache/Dockerfile index 4d208219d..4d4494cf6 100644 --- a/dev/docker/php7/apache/Dockerfile +++ b/dev/docker/php7/apache/Dockerfile @@ -32,6 +32,10 @@ RUN docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \ zip \ opcache +# Install apcu +RUN pecl install apcu-5.1.17 +RUN docker-php-ext-enable apcu + # Install xdebug RUN pecl install xdebug-2.7.1 RUN docker-php-ext-enable xdebug From faf129c26973b21d78ddb44215c9e20881e03555 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Thu, 10 Oct 2019 16:42:03 +0200 Subject: [PATCH 14/20] Enforce JSON error response on Api --- plugins/Api/src/Error/JsonApiExceptionRenderer.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/Api/src/Error/JsonApiExceptionRenderer.php b/plugins/Api/src/Error/JsonApiExceptionRenderer.php index b8e086227..6de2a893b 100644 --- a/plugins/Api/src/Error/JsonApiExceptionRenderer.php +++ b/plugins/Api/src/Error/JsonApiExceptionRenderer.php @@ -13,6 +13,7 @@ namespace Api\Error; use Api\Error\Exception\GenericApiException; +use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Exception\Exception; use Cake\Error\ExceptionRenderer; @@ -44,6 +45,10 @@ protected function _outputMessage($template) $this->controller->set('data', $data); $this->controller->set('_serialize', 'data'); + // Render output as JSON instead of HTML. + $viewClass = App::className('Json', 'View', 'View'); + $this->controller->viewBuilder()->setClassName($viewClass); + return parent::_outputMessage($template); } } From 1ba18e8935b72a69bd5dcf7e63ec32e3e00c982d Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Thu, 10 Oct 2019 18:13:39 +0200 Subject: [PATCH 15/20] Composer update --- composer.lock | 407 +++++++++++++++++++++++++------------------------- 1 file changed, 203 insertions(+), 204 deletions(-) diff --git a/composer.lock b/composer.lock index 5df0496c6..6c01da003 100644 --- a/composer.lock +++ b/composer.lock @@ -161,16 +161,16 @@ }, { "name": "cakephp/cakephp", - "version": "3.8.2", + "version": "3.8.5", "source": { "type": "git", - "url": "git@github.com:cakephp/cakephp.git", - "reference": "d57a5193312a21e013a1f21191826933398c026f" + "url": "https://github.com/cakephp/cakephp.git", + "reference": "ea64434740f0d2a53438f95a3de414de63a11101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp/zipball/d57a5193312a21e013a1f21191826933398c026f", - "reference": "d57a5193312a21e013a1f21191826933398c026f", + "url": "https://api.github.com/repos/cakephp/cakephp/zipball/ea64434740f0d2a53438f95a3de414de63a11101", + "reference": "ea64434740f0d2a53438f95a3de414de63a11101", "shasum": "" }, "require": { @@ -246,7 +246,7 @@ "rapid-development", "validation" ], - "time": "2019-08-09T02:42:41+00:00" + "time": "2019-10-07T00:51:50+00:00" }, { "name": "cakephp/chronos", @@ -307,23 +307,23 @@ }, { "name": "cakephp/migrations", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/cakephp/migrations.git", - "reference": "38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc" + "reference": "3d1750bb218958b4c48fea1365c619bedea62d69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/migrations/zipball/38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc", - "reference": "38fbee62e7f387dbe0dc7ef492aa7dddb8e304fc", + "url": "https://api.github.com/repos/cakephp/migrations/zipball/3d1750bb218958b4c48fea1365c619bedea62d69", + "reference": "3d1750bb218958b4c48fea1365c619bedea62d69", "shasum": "" }, "require": { "cakephp/cache": "^3.6.0", "cakephp/orm": "^3.6.0", "php": ">=5.6.0", - "robmorgan/phinx": "^0.10.3" + "robmorgan/phinx": "^0.10.3|^0.11.1" }, "require-dev": { "cakephp/bake": "^1.7.0", @@ -356,7 +356,7 @@ "cakephp", "migrations" ], - "time": "2019-07-22T03:02:47+00:00" + "time": "2019-10-07T22:04:21+00:00" }, { "name": "cakephp/plugin-installer", @@ -401,16 +401,16 @@ }, { "name": "claviska/simpleimage", - "version": "3.3.3", + "version": "3.3.4", "source": { "type": "git", "url": "https://github.com/claviska/SimpleImage.git", - "reference": "31ba5b8358e1663a2813e2ada7242fa8d97a96dc" + "reference": "3786d80af8e6d05e5e42f0350e5e5da5b92041a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/31ba5b8358e1663a2813e2ada7242fa8d97a96dc", - "reference": "31ba5b8358e1663a2813e2ada7242fa8d97a96dc", + "url": "https://api.github.com/repos/claviska/SimpleImage/zipball/3786d80af8e6d05e5e42f0350e5e5da5b92041a0", + "reference": "3786d80af8e6d05e5e42f0350e5e5da5b92041a0", "shasum": "" }, "require": { @@ -436,20 +436,20 @@ } ], "description": "A PHP class that makes working with images as simple as possible.", - "time": "2017-09-12T09:03:56+00:00" + "time": "2019-09-26T01:22:02+00:00" }, { "name": "composer/ca-bundle", - "version": "1.2.3", + "version": "1.2.4", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "f26a67e397be0e5c00d7c52ec7b5010098e15ce5" + "reference": "10bb96592168a0f8e8f6dcde3532d9fa50b0b527" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/f26a67e397be0e5c00d7c52ec7b5010098e15ce5", - "reference": "f26a67e397be0e5c00d7c52ec7b5010098e15ce5", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/10bb96592168a0f8e8f6dcde3532d9fa50b0b527", + "reference": "10bb96592168a0f8e8f6dcde3532d9fa50b0b527", "shasum": "" }, "require": { @@ -492,7 +492,7 @@ "ssl", "tls" ], - "time": "2019-08-02T09:05:43+00:00" + "time": "2019-08-30T08:44:50+00:00" }, { "name": "davidyell/proffer", @@ -548,16 +548,16 @@ }, { "name": "embed/embed", - "version": "v3.4.1", + "version": "v3.4.2", "source": { "type": "git", "url": "https://github.com/oscarotero/Embed.git", - "reference": "960bbd5a62c5697302bd5394d58efba2e998b787" + "reference": "dc1dc3c126f8a78acdae06b83f591c0728ea131d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/oscarotero/Embed/zipball/960bbd5a62c5697302bd5394d58efba2e998b787", - "reference": "960bbd5a62c5697302bd5394d58efba2e998b787", + "url": "https://api.github.com/repos/oscarotero/Embed/zipball/dc1dc3c126f8a78acdae06b83f591c0728ea131d", + "reference": "dc1dc3c126f8a78acdae06b83f591c0728ea131d", "shasum": "" }, "require": { @@ -597,7 +597,7 @@ "opengraph", "twitter cards" ], - "time": "2019-07-20T17:05:41+00:00" + "time": "2019-09-16T19:34:02+00:00" }, { "name": "firebase/php-jwt", @@ -651,12 +651,12 @@ "source": { "type": "git", "url": "https://github.com/FriendsOfCake/bootstrap-ui.git", - "reference": "958a5dddd90f37ac816188ec4ab44ded82ce6ce7" + "reference": "6d57476ae08b039d1c5a3c84fc881f723ab5e988" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FriendsOfCake/bootstrap-ui/zipball/958a5dddd90f37ac816188ec4ab44ded82ce6ce7", - "reference": "958a5dddd90f37ac816188ec4ab44ded82ce6ce7", + "url": "https://api.github.com/repos/FriendsOfCake/bootstrap-ui/zipball/6d57476ae08b039d1c5a3c84fc881f723ab5e988", + "reference": "6d57476ae08b039d1c5a3c84fc881f723ab5e988", "shasum": "" }, "require": { @@ -679,8 +679,8 @@ "authors": [ { "name": "Jad Bitar", - "role": "Author", - "homepage": "http://jadb.io" + "homepage": "http://jadb.io", + "role": "Author" }, { "name": "Others", @@ -694,7 +694,7 @@ "cakephp", "twitter" ], - "time": "2019-08-19T06:18:07+00:00" + "time": "2019-09-30T11:37:59+00:00" }, { "name": "friendsofcake/search", @@ -1411,16 +1411,16 @@ }, { "name": "mobiledetect/mobiledetectlib", - "version": "2.8.33", + "version": "2.8.34", "source": { "type": "git", "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "cd385290f9a0d609d2eddd165a1e44ec1bf12102" + "reference": "6f8113f57a508494ca36acbcfa2dc2d923c7ed5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/cd385290f9a0d609d2eddd165a1e44ec1bf12102", - "reference": "cd385290f9a0d609d2eddd165a1e44ec1bf12102", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/6f8113f57a508494ca36acbcfa2dc2d923c7ed5b", + "reference": "6f8113f57a508494ca36acbcfa2dc2d923c7ed5b", "shasum": "" }, "require": { @@ -1459,7 +1459,7 @@ "mobile detector", "php mobile detect" ], - "time": "2018-09-01T15:05:15+00:00" + "time": "2019-09-18T18:44:20+00:00" }, { "name": "psr/container", @@ -1697,29 +1697,31 @@ }, { "name": "robmorgan/phinx", - "version": "0.10.8", + "version": "0.11.1", "source": { "type": "git", "url": "https://github.com/cakephp/phinx.git", - "reference": "1960e93169707096fdfde04904a204970077f4be" + "reference": "a6cced878695d26396b26dfd62ce300aea07de05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/phinx/zipball/1960e93169707096fdfde04904a204970077f4be", - "reference": "1960e93169707096fdfde04904a204970077f4be", + "url": "https://api.github.com/repos/cakephp/phinx/zipball/a6cced878695d26396b26dfd62ce300aea07de05", + "reference": "a6cced878695d26396b26dfd62ce300aea07de05", "shasum": "" }, "require": { "cakephp/collection": "^3.6", + "cakephp/core": "^3.6", "cakephp/database": "^3.6", + "cakephp/datasource": "^3.6", "php": ">=5.6", - "symfony/config": "^2.8|^3.0|^4.0", - "symfony/console": "^2.8|^3.0|^4.0", - "symfony/yaml": "^2.8|^3.0|^4.0" + "symfony/config": "^3.4|^4.0", + "symfony/console": "^3.4|^4.0", + "symfony/yaml": "^3.4|^4.0" }, "require-dev": { "cakephp/cakephp-codesniffer": "^3.0", - "phpunit/phpunit": ">=5.7,<7.0", + "phpunit/phpunit": ">=5.7,<8.0", "sebastian/comparator": ">=1.2.3" }, "bin": [ @@ -1736,26 +1738,27 @@ "MIT" ], "authors": [ - { - "name": "Woody Gilk", - "role": "Developer", - "email": "woody.gilk@gmail.com", - "homepage": "http://shadowhand.me" - }, { "name": "Rob Morgan", - "role": "Lead Developer", "email": "robbym@gmail.com", - "homepage": "https://robmorgan.id.au" + "homepage": "https://robmorgan.id.au", + "role": "Lead Developer" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com", + "homepage": "https://shadowhand.me", + "role": "Developer" }, { "name": "Richard Quadling", - "role": "Developer", - "email": "rquadling@gmail.com" + "email": "rquadling@gmail.com", + "role": "Developer" }, { "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/phinx/graphs/contributors" + "homepage": "https://github.com/cakephp/phinx/graphs/contributors", + "role": "Developer" } ], "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.", @@ -1767,7 +1770,7 @@ "migrations", "phinx" ], - "time": "2019-07-08T16:59:55+00:00" + "time": "2019-08-28T12:24:19+00:00" }, { "name": "siezi/cakephp-simple-captcha", @@ -1861,16 +1864,16 @@ }, { "name": "symfony/config", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "a17a2aea43950ce83a0603ed301bac362eb86870" + "reference": "0acb26407a9e1a64a275142f0ae5e36436342720" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/a17a2aea43950ce83a0603ed301bac362eb86870", - "reference": "a17a2aea43950ce83a0603ed301bac362eb86870", + "url": "https://api.github.com/repos/symfony/config/zipball/0acb26407a9e1a64a275142f0ae5e36436342720", + "reference": "0acb26407a9e1a64a275142f0ae5e36436342720", "shasum": "" }, "require": { @@ -1921,20 +1924,20 @@ ], "description": "Symfony Config Component", "homepage": "https://symfony.com", - "time": "2019-07-18T10:34:59+00:00" + "time": "2019-09-19T15:51:53+00:00" }, { "name": "symfony/console", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9" + "reference": "929ddf360d401b958f611d44e726094ab46a7369" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", - "reference": "8b0ae5742ce9aaa8b0075665862c1ca397d1c1d9", + "url": "https://api.github.com/repos/symfony/console/zipball/929ddf360d401b958f611d44e726094ab46a7369", + "reference": "929ddf360d401b958f611d44e726094ab46a7369", "shasum": "" }, "require": { @@ -1996,20 +1999,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2019-07-24T17:13:59+00:00" + "time": "2019-10-07T12:36:49+00:00" }, { "name": "symfony/filesystem", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d" + "reference": "9abbb7ef96a51f4d7e69627bc6f63307994e4263" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b9896d034463ad6fd2bf17e2bf9418caecd6313d", - "reference": "b9896d034463ad6fd2bf17e2bf9418caecd6313d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9abbb7ef96a51f4d7e69627bc6f63307994e4263", + "reference": "9abbb7ef96a51f4d7e69627bc6f63307994e4263", "shasum": "" }, "require": { @@ -2046,7 +2049,7 @@ ], "description": "Symfony Filesystem Component", "homepage": "https://symfony.com", - "time": "2019-06-23T08:51:25+00:00" + "time": "2019-08-20T14:07:54+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2342,16 +2345,16 @@ }, { "name": "symfony/service-contracts", - "version": "v1.1.5", + "version": "v1.1.7", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d" + "reference": "ffcde9615dc5bb4825b9f6aed07716f1f57faae0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", - "reference": "f391a00de78ec7ec8cf5cdcdae59ec7b883edb8d", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffcde9615dc5bb4825b9f6aed07716f1f57faae0", + "reference": "ffcde9615dc5bb4825b9f6aed07716f1f57faae0", "shasum": "" }, "require": { @@ -2396,20 +2399,20 @@ "interoperability", "standards" ], - "time": "2019-06-13T11:15:36+00:00" + "time": "2019-09-17T11:12:18+00:00" }, { "name": "symfony/yaml", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6" + "reference": "41e16350a2a1c7383c4735aa2f9fce74cf3d1178" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/34d29c2acd1ad65688f58452fd48a46bd996d5a6", - "reference": "34d29c2acd1ad65688f58452fd48a46bd996d5a6", + "url": "https://api.github.com/repos/symfony/yaml/zipball/41e16350a2a1c7383c4735aa2f9fce74cf3d1178", + "reference": "41e16350a2a1c7383c4735aa2f9fce74cf3d1178", "shasum": "" }, "require": { @@ -2455,7 +2458,7 @@ ], "description": "Symfony Yaml Component", "homepage": "https://symfony.com", - "time": "2019-07-24T14:47:54+00:00" + "time": "2019-09-11T15:41:19+00:00" }, { "name": "yzalis/identicon", @@ -2794,16 +2797,16 @@ }, { "name": "cakephp/cakephp-codesniffer", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/cakephp/cakephp-codesniffer.git", - "reference": "682e79fda294c4383e094a2a881e16dcf1130750" + "reference": "45a1dcc2e83598362b8c323df3e67510676457fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/682e79fda294c4383e094a2a881e16dcf1130750", - "reference": "682e79fda294c4383e094a2a881e16dcf1130750", + "url": "https://api.github.com/repos/cakephp/cakephp-codesniffer/zipball/45a1dcc2e83598362b8c323df3e67510676457fe", + "reference": "45a1dcc2e83598362b8c323df3e67510676457fe", "shasum": "" }, "require": { @@ -2835,20 +2838,20 @@ "codesniffer", "framework" ], - "time": "2018-11-30T16:04:05+00:00" + "time": "2019-08-30T01:55:00+00:00" }, { "name": "cakephp/debug_kit", - "version": "3.20.1", + "version": "3.20.3", "source": { "type": "git", "url": "https://github.com/cakephp/debug_kit.git", - "reference": "2d2a9844ee7e8a08be8824e63f83b18699a134b3" + "reference": "2ebc6b61fdb4741e890c564ab4d55a9b1d29c47f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/2d2a9844ee7e8a08be8824e63f83b18699a134b3", - "reference": "2d2a9844ee7e8a08be8824e63f83b18699a134b3", + "url": "https://api.github.com/repos/cakephp/debug_kit/zipball/2ebc6b61fdb4741e890c564ab4d55a9b1d29c47f", + "reference": "2ebc6b61fdb4741e890c564ab4d55a9b1d29c47f", "shasum": "" }, "require": { @@ -2895,7 +2898,7 @@ "debug", "kit" ], - "time": "2019-08-12T01:28:07+00:00" + "time": "2019-10-09T01:55:34+00:00" }, { "name": "composer/composer", @@ -3743,16 +3746,16 @@ }, { "name": "nette/finder", - "version": "v2.5.0", + "version": "v2.5.1", "source": { "type": "git", "url": "https://github.com/nette/finder.git", - "reference": "6be1b83ea68ac558aff189d640abe242e0306fe2" + "reference": "14164e1ddd69e9c5f627ff82a10874b3f5bba5fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/finder/zipball/6be1b83ea68ac558aff189d640abe242e0306fe2", - "reference": "6be1b83ea68ac558aff189d640abe242e0306fe2", + "url": "https://api.github.com/repos/nette/finder/zipball/14164e1ddd69e9c5f627ff82a10874b3f5bba5fe", + "reference": "14164e1ddd69e9c5f627ff82a10874b3f5bba5fe", "shasum": "" }, "require": { @@ -3793,7 +3796,7 @@ "homepage": "https://nette.org/contributors" } ], - "description": "? Nette Finder: find files and directories with an intuitive API.", + "description": "🔍 Nette Finder: find files and directories with an intuitive API.", "homepage": "https://nette.org", "keywords": [ "filesystem", @@ -3801,7 +3804,7 @@ "iterator", "nette" ], - "time": "2019-02-28T18:13:25+00:00" + "time": "2019-07-11T18:02:17+00:00" }, { "name": "nette/neon", @@ -4120,16 +4123,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.2.3", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "e612609022e935f3d0337c1295176505b41188c8" + "reference": "97e59c7a16464196a8b9c77c47df68e4a39a45c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/e612609022e935f3d0337c1295176505b41188c8", - "reference": "e612609022e935f3d0337c1295176505b41188c8", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/97e59c7a16464196a8b9c77c47df68e4a39a45c4", + "reference": "97e59c7a16464196a8b9c77c47df68e4a39a45c4", "shasum": "" }, "require": { @@ -4167,7 +4170,7 @@ "parser", "php" ], - "time": "2019-08-12T20:17:41+00:00" + "time": "2019-09-01T07:51:21+00:00" }, { "name": "ocramius/package-versions", @@ -4416,35 +4419,33 @@ }, { "name": "phpdocumentor/reflection-common", - "version": "1.0.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6" + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", - "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a", + "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a", "shasum": "" }, "require": { - "php": ">=5.5" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "^4.6" + "phpunit/phpunit": "~6" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src" - ] + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4466,30 +4467,30 @@ "reflection", "static analysis" ], - "time": "2017-09-11T18:02:19+00:00" + "time": "2018-08-07T13:53:10+00:00" }, { "name": "phpdocumentor/reflection-docblock", - "version": "4.3.1", + "version": "4.3.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c" + "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", - "reference": "bdd9f737ebc2a01c06ea7ff4308ec6697db9b53c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/b83ff7cfcfee7827e1e78b637a5904fe6a96698e", + "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e", "shasum": "" }, "require": { "php": "^7.0", - "phpdocumentor/reflection-common": "^1.0.0", - "phpdocumentor/type-resolver": "^0.4.0", + "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0", + "phpdocumentor/type-resolver": "~0.4 || ^1.0.0", "webmozart/assert": "^1.0" }, "require-dev": { - "doctrine/instantiator": "~1.0.5", + "doctrine/instantiator": "^1.0.5", "mockery/mockery": "^1.0", "phpunit/phpunit": "^6.4" }, @@ -4517,41 +4518,40 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2019-04-30T17:48:53+00:00" + "time": "2019-09-12T14:27:41+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "0.4.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7" + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7", - "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", + "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9", "shasum": "" }, "require": { - "php": "^5.5 || ^7.0", - "phpdocumentor/reflection-common": "^1.0" + "php": "^7.1", + "phpdocumentor/reflection-common": "^2.0" }, "require-dev": { - "mockery/mockery": "^0.9.4", - "phpunit/phpunit": "^5.2||^4.8.24" + "ext-tokenizer": "^7.1", + "mockery/mockery": "~1", + "phpunit/phpunit": "^7.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": [ - "src/" - ] + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -4564,26 +4564,27 @@ "email": "me@mikevanriel.com" } ], - "time": "2017-07-14T14:27:02+00:00" + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "time": "2019-08-22T18:11:29+00:00" }, { "name": "phpspec/prophecy", - "version": "1.8.1", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/phpspec/prophecy.git", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76" + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/1927e75f4ed19131ec9bcc3b002e07fb1173ee76", - "reference": "1927e75f4ed19131ec9bcc3b002e07fb1173ee76", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/f6811d96d97bdf400077a0cc100ae56aa32b9203", + "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203", "shasum": "" }, "require": { "doctrine/instantiator": "^1.0.2", "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0", "sebastian/comparator": "^1.1|^2.0|^3.0", "sebastian/recursion-context": "^1.0|^2.0|^3.0" }, @@ -4627,7 +4628,7 @@ "spy", "stub" ], - "time": "2019-06-13T12:50:23+00:00" + "time": "2019-10-03T11:07:50+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -4678,16 +4679,16 @@ }, { "name": "phpstan/phpstan", - "version": "0.11.15", + "version": "0.11.16", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "1be5b3a706db16ac472a4c40ec03cf4c810b118d" + "reference": "635cf20f3b92ce34ee94a8d2f282d62eb9dc6e1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1be5b3a706db16ac472a4c40ec03cf4c810b118d", - "reference": "1be5b3a706db16ac472a4c40ec03cf4c810b118d", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/635cf20f3b92ce34ee94a8d2f282d62eb9dc6e1b", + "reference": "635cf20f3b92ce34ee94a8d2f282d62eb9dc6e1b", "shasum": "" }, "require": { @@ -4739,8 +4740,7 @@ "autoload": { "psr-4": { "PHPStan\\": [ - "src/", - "build/PHPStan" + "src/" ] } }, @@ -4749,7 +4749,7 @@ "MIT" ], "description": "PHPStan - PHP Static Analysis Tool", - "time": "2019-08-18T20:51:53+00:00" + "time": "2019-09-17T11:19:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -5431,16 +5431,16 @@ }, { "name": "sebastian/exporter", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "06a9a5947f47b3029d76118eb5c22802e5869687" + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/06a9a5947f47b3029d76118eb5c22802e5869687", - "reference": "06a9a5947f47b3029d76118eb5c22802e5869687", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e", + "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e", "shasum": "" }, "require": { @@ -5494,7 +5494,7 @@ "export", "exporter" ], - "time": "2019-08-11T12:43:14+00:00" + "time": "2019-09-14T09:02:43+00:00" }, { "name": "sebastian/global-state", @@ -5872,16 +5872,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.4.2", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8" + "reference": "0afebf16a2e7f1e434920fa976253576151effe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", - "reference": "b8a7362af1cc1aadb5bd36c3defc4dda2cf5f0a8", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/0afebf16a2e7f1e434920fa976253576151effe9", + "reference": "0afebf16a2e7f1e434920fa976253576151effe9", "shasum": "" }, "require": { @@ -5919,20 +5919,20 @@ "phpcs", "standards" ], - "time": "2019-04-10T23:49:02+00:00" + "time": "2019-09-26T23:12:26+00:00" }, { "name": "symfony/css-selector", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "105c98bb0c5d8635bea056135304bd8edcc42b4d" + "reference": "f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/105c98bb0c5d8635bea056135304bd8edcc42b4d", - "reference": "105c98bb0c5d8635bea056135304bd8edcc42b4d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9", + "reference": "f4b3ff6a549d9ed28b2b0ecd1781bf67cf220ee9", "shasum": "" }, "require": { @@ -5972,20 +5972,20 @@ ], "description": "Symfony CssSelector Component", "homepage": "https://symfony.com", - "time": "2019-01-16T21:53:39+00:00" + "time": "2019-10-02T08:36:26+00:00" }, { "name": "symfony/dom-crawler", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "291397232a2eefb3347eaab9170409981eaad0e2" + "reference": "e9f7b4d19d69b133bd638eeddcdc757723b4211f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/291397232a2eefb3347eaab9170409981eaad0e2", - "reference": "291397232a2eefb3347eaab9170409981eaad0e2", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/e9f7b4d19d69b133bd638eeddcdc757723b4211f", + "reference": "e9f7b4d19d69b133bd638eeddcdc757723b4211f", "shasum": "" }, "require": { @@ -6033,20 +6033,20 @@ ], "description": "Symfony DomCrawler Component", "homepage": "https://symfony.com", - "time": "2019-06-13T11:03:18+00:00" + "time": "2019-09-28T21:25:05+00:00" }, { "name": "symfony/finder", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2" + "reference": "5e575faa95548d0586f6bedaeabec259714e44d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9638d41e3729459860bb96f6247ccb61faaa45f2", - "reference": "9638d41e3729459860bb96f6247ccb61faaa45f2", + "url": "https://api.github.com/repos/symfony/finder/zipball/5e575faa95548d0586f6bedaeabec259714e44d1", + "reference": "5e575faa95548d0586f6bedaeabec259714e44d1", "shasum": "" }, "require": { @@ -6082,20 +6082,20 @@ ], "description": "Symfony Finder Component", "homepage": "https://symfony.com", - "time": "2019-06-28T13:16:30+00:00" + "time": "2019-09-16T11:29:48+00:00" }, { "name": "symfony/process", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c" + "reference": "50556892f3cc47d4200bfd1075314139c4c9ff4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/856d35814cf287480465bb7a6c413bb7f5f5e69c", - "reference": "856d35814cf287480465bb7a6c413bb7f5f5e69c", + "url": "https://api.github.com/repos/symfony/process/zipball/50556892f3cc47d4200bfd1075314139c4c9ff4b", + "reference": "50556892f3cc47d4200bfd1075314139c4c9ff4b", "shasum": "" }, "require": { @@ -6131,20 +6131,20 @@ ], "description": "Symfony Process Component", "homepage": "https://symfony.com", - "time": "2019-05-30T16:10:05+00:00" + "time": "2019-09-26T21:17:10+00:00" }, { "name": "symfony/var-dumper", - "version": "v4.3.3", + "version": "v4.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "e4110b992d2cbe198d7d3b244d079c1c58761d07" + "reference": "bde8957fc415fdc6964f33916a3755737744ff05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e4110b992d2cbe198d7d3b244d079c1c58761d07", - "reference": "e4110b992d2cbe198d7d3b244d079c1c58761d07", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/bde8957fc415fdc6964f33916a3755737744ff05", + "reference": "bde8957fc415fdc6964f33916a3755737744ff05", "shasum": "" }, "require": { @@ -6207,7 +6207,7 @@ "debug", "dump" ], - "time": "2019-07-27T06:42:46+00:00" + "time": "2019-10-04T19:48:13+00:00" }, { "name": "theseer/tokenizer", @@ -6251,26 +6251,26 @@ }, { "name": "twig/twig", - "version": "v1.42.2", + "version": "v1.42.3", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "21707d6ebd05476854805e4f91b836531941bcd4" + "reference": "201baee843e0ffe8b0b956f336dd42b2a92fae4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/21707d6ebd05476854805e4f91b836531941bcd4", - "reference": "21707d6ebd05476854805e4f91b836531941bcd4", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/201baee843e0ffe8b0b956f336dd42b2a92fae4e", + "reference": "201baee843e0ffe8b0b956f336dd42b2a92fae4e", "shasum": "" }, "require": { - "php": ">=5.4.0", + "php": ">=5.5.0", "symfony/polyfill-ctype": "^1.8" }, "require-dev": { "psr/container": "^1.0", - "symfony/debug": "^2.7", - "symfony/phpunit-bridge": "^3.4.19|^4.1.8|^5.0" + "symfony/debug": "^3.4|^4.2", + "symfony/phpunit-bridge": "^4.4@dev|^5.0" }, "type": "library", "extra": { @@ -6297,15 +6297,15 @@ "homepage": "http://fabien.potencier.org", "role": "Lead Developer" }, - { - "name": "Armin Ronacher", - "email": "armin.ronacher@active-4.com", - "role": "Project Founder" - }, { "name": "Twig Team", "homepage": "https://twig.symfony.com/contributors", "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" } ], "description": "Twig, the flexible, fast, and secure template language for PHP", @@ -6313,7 +6313,7 @@ "keywords": [ "templating" ], - "time": "2019-06-18T15:35:16+00:00" + "time": "2019-08-24T12:51:03+00:00" }, { "name": "umpirsky/twig-php-function", @@ -6358,16 +6358,16 @@ }, { "name": "webmozart/assert", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/webmozart/assert.git", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9" + "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozart/assert/zipball/83e253c8e0be5b0257b881e1827274667c5c17a9", - "reference": "83e253c8e0be5b0257b881e1827274667c5c17a9", + "url": "https://api.github.com/repos/webmozart/assert/zipball/88e6d84706d09a236046d686bbea96f07b3a34f4", + "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4", "shasum": "" }, "require": { @@ -6375,8 +6375,7 @@ "symfony/polyfill-ctype": "^1.8" }, "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" + "phpunit/phpunit": "^4.8.36 || ^7.5.13" }, "type": "library", "extra": { @@ -6405,7 +6404,7 @@ "check", "validate" ], - "time": "2018-12-25T11:19:39+00:00" + "time": "2019-08-24T08:43:50+00:00" }, { "name": "wyrihaximus/twig-view", From d2da7274202d76c9af8456c3060862f0d6b979b1 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Thu, 10 Oct 2019 18:42:51 +0200 Subject: [PATCH 16/20] Extends timeout on phpunit tests --- composer.json | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e76d26d74..1c2646392 100644 --- a/composer.json +++ b/composer.json @@ -101,11 +101,25 @@ "cs-fix": "phpcbf > /dev/null || true", "check": ["@cs-fix", "@cs-check"], "phpstan": "vendor/bin/phpstan analyse --ansi", - "coverage": "unset XDEBUG_CONFIG; export COMPOSER_PROCESS_TIMEOUT=900; composer phpunit -- --coverage-html docs/local/", - - "phpunit-stop": "phpunit --colors=always --stderr --stop-on-error --stop-on-failure", - "phpunit": "phpunit --colors=always", - "test": ["@phpunit", "@phpstan", "@check"], + "coverage": [ + "unset XDEBUG_CONFIG", + "Composer\\Config::disableProcessTimeout", + "composer phpunit -- --coverage-html docs/local/" + ], + "phpunit-stop": [ + "Composer\\Config::disableProcessTimeout", + "phpunit --colors=always --stderr --stop-on-error --stop-on-failure" + ], + "phpunit": [ + "Composer\\Config::disableProcessTimeout", + "phpunit --colors=always" + ], + "test": [ + "unset XDEBUG_CONFIG", + "@phpunit", + "@phpstan", + "@check" + ], "js-all": "yarn run test", From 3fa33db825a9358ca481d8553c3f077fdb6430d3 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sat, 12 Oct 2019 06:47:46 +0200 Subject: [PATCH 17/20] Improves uploader mime-type detection --- .../src/Controller/UploadsController.php | 1 - plugins/ImageUploader/src/Lib/MimeType.php | 79 +++++++++++++++++++ .../src/Model/Table/UploadsTable.php | 46 ++++++----- .../tests/Fixture/test-application-octo.mp4 | 1 + .../Controller/UploadsControllerTest.php | 21 ++++- 5 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 plugins/ImageUploader/src/Lib/MimeType.php create mode 100644 plugins/ImageUploader/tests/Fixture/test-application-octo.mp4 diff --git a/plugins/ImageUploader/src/Controller/UploadsController.php b/plugins/ImageUploader/src/Controller/UploadsController.php index 4e147230a..effbacbe9 100644 --- a/plugins/ImageUploader/src/Controller/UploadsController.php +++ b/plugins/ImageUploader/src/Controller/UploadsController.php @@ -68,7 +68,6 @@ public function add() 'name' => $name, 'title' => $submitted['name'], 'size' => $submitted['size'], - 'type' => $submitted['type'], 'user_id' => $this->CurrentUser->getId(), ]; $document = $this->Uploads->newEntity($data); diff --git a/plugins/ImageUploader/src/Lib/MimeType.php b/plugins/ImageUploader/src/Lib/MimeType.php new file mode 100644 index 000000000..f8c23586c --- /dev/null +++ b/plugins/ImageUploader/src/Lib/MimeType.php @@ -0,0 +1,79 @@ + => [ => ]] */ + private static $conversion = [ + 'application/octet-stream' => [ + 'mp4' => 'video/mp4', + ], + ]; + + /** + * Get mime-type + * + * @param string $filepath File path on server to check the actual file + * @param string|null $name Original file name with original extension + * @return string Determined mime-type + */ + public static function get(string $filepath, ?string $name): string + { + $file = new File($filepath); + $type = $file->mime(); + + $name = $name ?: $file->pwd(); + $type = self::fixByFileExtension($type, $name); + + return $type; + } + + /** + * Fix type based on filename extension + * + * @param string $type original mime-type + * @param string $filename path to file for filename + * @return string fixed mime-type + */ + private static function fixByFileExtension(string $type, string $filename): string + { + // Check that mime-type has an .extension based fix. + if (array_key_exists($type, self::$conversion)) { + $UploaderConfig = Configure::read('Saito.Settings.uploader'); + $fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + $conversion = self::$conversion[$type]; + foreach ($conversion as $extension => $newType) { + // Check that file has the matching .extension. + if ($fileExtension !== $extension) { + continue; + } + // Check that the mime-type wich is considered a fix is allowed + // at all. + if (!$UploaderConfig->hasType($newType)) { + continue; + } + $type = $newType; + break; + } + } + + return $type; + } +} diff --git a/plugins/ImageUploader/src/Model/Table/UploadsTable.php b/plugins/ImageUploader/src/Model/Table/UploadsTable.php index 343133c5a..9676b8233 100644 --- a/plugins/ImageUploader/src/Model/Table/UploadsTable.php +++ b/plugins/ImageUploader/src/Model/Table/UploadsTable.php @@ -21,6 +21,7 @@ use Cake\Validation\Validation; use Cake\Validation\Validator; use claviska\SimpleImage; +use ImageUploader\Lib\MimeType; use ImageUploader\Model\Entity\Upload; /** @@ -72,18 +73,8 @@ public function validationDefault(Validator $validator) $validator->add( 'document', [ - 'mimeType' => [ - 'rule' => [ - 'mimeType', - $UploaderConfig->getAllTypes(), - ], - 'message' => __d( - 'image_uploader', - 'validation.error.mimeType' - ) - ], - 'fileSize' => [ - 'rule' => [$this, 'validateFileSize'], + 'file' => [ + 'rule' => [$this, 'validateFile'], ], ] ); @@ -137,6 +128,18 @@ function (Upload $entity, array $options) use ($nMax) { return $rules; } + /** + * {@inheritDoc} + */ + public function beforeMarshal(Event $event, \ArrayObject $data) + { + if (!empty($data['document'])) { + /// Set mime/type by what is determined on the server about the file. + $data['type'] = MimeType::get($data['document']['tmp_name'], $data['name']); + $data['document']['type'] = $data['type']; + } + } + /** * {@inheritDoc} */ @@ -190,6 +193,7 @@ private function moveUpload(Upload $entity): void switch ($mime) { case 'image/png': $file = $this->convertToJpeg($file); + $entity->set('type', $file->mime()); // fall through: png is further processed as jpeg // no break case 'image/jpeg': @@ -201,7 +205,6 @@ private function moveUpload(Upload $entity): void } $entity->set('name', $file->name); - $entity->set('type', $file->mime()); } catch (\Throwable $e) { if ($file->exists()) { $file->delete(); @@ -302,24 +305,19 @@ private function resize(File $file, int $target): void * @param array $context context * @return string|bool */ - public function validateFileSize($check, array $context) + public function validateFile($check, array $context) { /** @var \ImageUploader\Lib\UploaderConfig */ $UploaderConfig = Configure::read('Saito.Settings.uploader'); - $type = $check['type']; - if (!$UploaderConfig->hasType($type)) { - return __d( - 'image_uploader', - 'validation.error.mimeType', - $type - ); + /// Check file type + if (!$UploaderConfig->hasType($check['type'])) { + return __d('image_uploader', 'validation.error.mimeType', $check['type']); } + /// Check file size $size = $UploaderConfig->getSize($check['type']); - $result = Validation::fileSize($check, '<', $size); - - if ($result !== true) { + if (!Validation::fileSize($check, '<', $size)) { return __d( 'image_uploader', 'validation.error.fileSize', diff --git a/plugins/ImageUploader/tests/Fixture/test-application-octo.mp4 b/plugins/ImageUploader/tests/Fixture/test-application-octo.mp4 new file mode 100644 index 000000000..bb5213bc2 --- /dev/null +++ b/plugins/ImageUploader/tests/Fixture/test-application-octo.mp4 @@ -0,0 +1 @@ +r4 \ No newline at end of file diff --git a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php index 983ca09ff..01893b42c 100644 --- a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php +++ b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php @@ -155,6 +155,25 @@ public function testAddSvg() $this->assertTrue($upload->get('file')->exists()); } + public function testAddMimeTypeConversion() + { + $this->loginJwt(1); + + $this->file = new File(TMP . 'test.mp4'); + $fixture = new File(Plugin::path('ImageUploader') . 'tests/Fixture/test-application-octo.mp4'); + $fixture->copy($this->file->path); + $this->assertEquals('application/octet-stream', $this->file->mime()); + + $this->upload($this->file); + + $this->assertResponseOk(); + + $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $upload = $Uploads->get(3); + $this->assertSame('test.mp4', $upload->get('title')); + $this->assertSame('video/mp4', $upload->get('type')); + } + public function testRemoveExifData() { $this->loginJwt(1); @@ -345,7 +364,7 @@ private function upload(File $file) 0 => [ 'file' => [ 'tmp_name' => $file->path, - 'name' => $file->name() . '.' . $this->file->ext(), + 'name' => $file->name() . '.' . $file->ext(), 'size' => $file->size(), 'type' => $file->mime(), ] From 942cff3af4b9d4dc045df547929bcfddf8c6faca Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sat, 12 Oct 2019 07:21:15 +0200 Subject: [PATCH 18/20] Remove dead code, improve type check, fix deprecated --- plugins/ImageUploader/src/Lib/MimeType.php | 13 +++++++++++++ .../ImageUploader/src/Model/Table/UploadsTable.php | 3 --- .../TestCase/Controller/UploadsControllerTest.php | 14 +++++++------- .../TestCase/Controller/EntriesControllerTest.php | 2 +- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/plugins/ImageUploader/src/Lib/MimeType.php b/plugins/ImageUploader/src/Lib/MimeType.php index f8c23586c..c8579f941 100644 --- a/plugins/ImageUploader/src/Lib/MimeType.php +++ b/plugins/ImageUploader/src/Lib/MimeType.php @@ -37,7 +37,20 @@ class MimeType public static function get(string $filepath, ?string $name): string { $file = new File($filepath); + if (!$file->exists()) { + throw new \RuntimeException( + sprintf('File "%s" does not exists.', $filepath), + 1570856931 + ); + } + $type = $file->mime(); + if (!$type) { + throw new \RuntimeException( + sprintf('Cannot determine mime-type for file "%s".', $filepath), + 1570856932 + ); + } $name = $name ?: $file->pwd(); $type = self::fixByFileExtension($type, $name); diff --git a/plugins/ImageUploader/src/Model/Table/UploadsTable.php b/plugins/ImageUploader/src/Model/Table/UploadsTable.php index 9676b8233..cffbd9c78 100644 --- a/plugins/ImageUploader/src/Model/Table/UploadsTable.php +++ b/plugins/ImageUploader/src/Model/Table/UploadsTable.php @@ -67,9 +67,6 @@ public function validationDefault(Validator $validator) ->notBlank('user_id') ->requirePresence(['name', 'size', 'type', 'user_id'], 'create'); - /** @var \ImageUploader\Lib\UploaderConfig */ - $UploaderConfig = Configure::read('Saito.Settings.uploader'); - $validator->add( 'document', [ diff --git a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php index 01893b42c..29710083a 100644 --- a/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php +++ b/plugins/ImageUploader/tests/TestCase/Controller/UploadsControllerTest.php @@ -102,7 +102,7 @@ public function testAddSuccess() ]; $this->assertEquals($expected, $response); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $upload = $Uploads->get(3); $this->assertSame('1_ebd536d37aff03f2b570329b20ece832.jpg', $upload->get('name')); @@ -147,7 +147,7 @@ public function testAddSvg() ]; $this->assertEquals($expected, $response); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $upload = $Uploads->get(3); $this->assertSame('1_853fe7aa4ef213b0c11f4b739cf444a8.svg', $upload->get('name')); @@ -168,7 +168,7 @@ public function testAddMimeTypeConversion() $this->assertResponseOk(); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $upload = $Uploads->get(3); $this->assertSame('test.mp4', $upload->get('title')); $this->assertSame('video/mp4', $upload->get('type')); @@ -199,7 +199,7 @@ public function testRemoveExifData() $this->assertResponseCode(200); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $upload = $Uploads->find('all')->last(); $exif = $readExif($upload->get('file')); @@ -212,7 +212,7 @@ public function testAddFailureMaxUploadsPerUser() Configure::read('Saito.Settings.uploader')->setMaxNumberOfUploadsPerUser(1); $this->loginJwt(1); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $count = $Uploads->find()->count(); $this->expectException(GenericApiException::class); @@ -231,7 +231,7 @@ public function testAddFailureMaxDocumentSize() $this->loginJwt(1); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $count = $Uploads->find()->count(); $this->expectException(GenericApiException::class); @@ -329,7 +329,7 @@ public function testDeleteNoAuthorization() public function testDeleteSuccess() { $this->loginJwt(1); - $Uploads = TableRegistry::get('ImageUploader.Uploads'); + $Uploads = TableRegistry::getTableLocator()->get('ImageUploader.Uploads'); $upload = $Uploads->get(1); $this->assertNotEmpty($Uploads->get(1)); $this->mockMediaFile($upload->get('file')); diff --git a/tests/TestCase/Controller/EntriesControllerTest.php b/tests/TestCase/Controller/EntriesControllerTest.php index d7058cd7a..7259c486c 100755 --- a/tests/TestCase/Controller/EntriesControllerTest.php +++ b/tests/TestCase/Controller/EntriesControllerTest.php @@ -510,7 +510,7 @@ public function testViewNotLoggedInSuccess() $this->_viewOk(1); } - /* + /** * anon users view posting not available to him */ public function testViewNotLoggedInAuthFailure() From df9935e6c972616bf41114916766899a44192705 Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sat, 12 Oct 2019 08:12:03 +0200 Subject: [PATCH 19/20] Fix button layout in Admin Settings --- plugins/Admin/src/View/Helper/SettingHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/Admin/src/View/Helper/SettingHelper.php b/plugins/Admin/src/View/Helper/SettingHelper.php index 8adbc6313..7fb14bf50 100644 --- a/plugins/Admin/src/View/Helper/SettingHelper.php +++ b/plugins/Admin/src/View/Helper/SettingHelper.php @@ -120,7 +120,7 @@ public function tableRow($name, $Settings) $this->Html->link( __('edit'), ['controller' => 'settings', 'action' => 'edit', $name], - ['class' => 'btn'] + ['class' => 'btn btn-primary'] ) ] ); From 1c966330b213d0e541930044ce72005446ca53eb Mon Sep 17 00:00:00 2001 From: Schlaefer Date: Sat, 12 Oct 2019 08:12:31 +0200 Subject: [PATCH 20/20] Bumps version string to 5.4.0 --- src/Lib/version.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Lib/version.php b/src/Lib/version.php index 798dbb759..eea7da002 100644 --- a/src/Lib/version.php +++ b/src/Lib/version.php @@ -13,7 +13,7 @@ $config = [ 'Saito' => [ - 'v' => '5.3.3', + 'v' => '5.4.0', 'saitoHomepage' => 'https://saito.siezi.com/' ] ];