diff --git a/composer.json b/composer.json index 2a4358e..21a1082 100644 --- a/composer.json +++ b/composer.json @@ -7,10 +7,12 @@ "type": "flarum-extension", "license": "MIT", "support": { - "forum": "https://discuss.flarum.org/d/31932-flarum-log-viewer" + "forum": "https://discuss.flarum.org/d/31932-flarum-log-viewer", + "source": "https://github.com/imorland/flarum-ext-log-viewer", + "issues": "https://github.com/imorland/flarum-ext-log-viewer/issues" }, "require": { - "flarum/core": "^1.2.0" + "flarum/core": "^1.8.2" }, "authors": [ { @@ -29,9 +31,9 @@ "title": "Log Viewer", "category": "feature", "icon": { - "name": "far fa-file-alt", - "color": "#0072e3", - "backgroundColor": "#fff" + "name": "fas fa-file-alt", + "color": "#fff", + "backgroundColor": "#0072e3" } }, "flarum-cli": { diff --git a/extend.php b/extend.php index 2a274d5..cc72dd0 100644 --- a/extend.php +++ b/extend.php @@ -24,7 +24,9 @@ (new Extend\Routes('api')) ->get('/logs', 'logs.index', Api\Controller\ListLogfilesController::class) - ->get('/logs/{file}', 'logs.show', Api\Controller\ShowLogFileController::class), + ->get('/logs/{file}', 'logs.show', Api\Controller\ShowLogFileController::class) + ->get('/logs/download/{file}', 'logs.download', Api\Controller\DownloadLogFileController::class) + ->delete('/logs/{file}', 'logs.delete', Api\Controller\DeleteLogFileController::class), (new Extend\Settings()) ->default('ianm-log-viewer.purge-days', 90) diff --git a/js/dist-typings/components/LogFileList.d.ts b/js/dist-typings/components/LogFileList.d.ts deleted file mode 100644 index 5a36327..0000000 --- a/js/dist-typings/components/LogFileList.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/// -import Component from 'flarum/common/Component'; -export default class LogFileList extends Component { - oninit(vnode: any): void; - view(): JSX.Element; - refresh(clear?: boolean): Promise; - loadResults(): Promise>; - parseResults(results: any): any; -} diff --git a/js/dist-typings/components/LogFileListItem.d.ts b/js/dist-typings/components/LogFileListItem.d.ts deleted file mode 100644 index 5ec1a7d..0000000 --- a/js/dist-typings/components/LogFileListItem.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -import Component from 'flarum/common/Component'; -export default class LogFileListItem extends Component { - oninit(vnode: any): void; - view(): JSX.Element; - setFile(fileName: string): void; -} diff --git a/js/dist-typings/components/LogFileViewer.d.ts b/js/dist-typings/components/LogFileViewer.d.ts deleted file mode 100644 index cd55197..0000000 --- a/js/dist-typings/components/LogFileViewer.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -import Component from 'flarum/common/Component'; -export default class LogFileViewer extends Component { - oninit(vnode: any): void; - view(): JSX.Element; -} diff --git a/js/dist-typings/components/LogViewerPage.d.ts b/js/dist-typings/components/LogViewerPage.d.ts deleted file mode 100644 index 88f5500..0000000 --- a/js/dist-typings/components/LogViewerPage.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -import ExtensionPage from 'flarum/admin/components/ExtensionPage'; -export default class LogViewerPage extends ExtensionPage { - content(): JSX.Element; -} diff --git a/js/dist-typings/index.d.ts b/js/dist-typings/index.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/js/dist-typings/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/js/dist-typings/models/LogFile.d.ts b/js/dist-typings/models/LogFile.d.ts deleted file mode 100644 index e134e2c..0000000 --- a/js/dist-typings/models/LogFile.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import Model from 'flarum/common/Model'; -export default class LogFile extends Model { - fileName(): string; - fullPath(): string; - size(): number; - modified(): Date | null | undefined; - content(): string | null; -} diff --git a/js/dist-typings/state/LogFileState.d.ts b/js/dist-typings/state/LogFileState.d.ts deleted file mode 100644 index 059b62b..0000000 --- a/js/dist-typings/state/LogFileState.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class LogFileState { - constructor(); - loadLogFile(filename: string): void; - getFile(): any; -} diff --git a/js/package.json b/js/package.json index 6897fef..87c5135 100644 --- a/js/package.json +++ b/js/package.json @@ -3,14 +3,14 @@ "private": true, "version": "0.0.0", "devDependencies": { - "flarum-webpack-config": "^2.0.0", - "webpack": "^5.88.2", - "webpack-cli": "^5.1.4", - "prettier": "^3.0.3", "@flarum/prettier-config": "^1.0.0", "flarum-tsconfig": "^1.0.2", - "typescript": "^4.5.4", - "typescript-coverage-report": "^0.6.1" + "flarum-webpack-config": "^2.0.2", + "prettier": "^3.0.3", + "typescript": "^5.2.2", + "typescript-coverage-report": "^0.8.0", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4" }, "scripts": { "dev": "webpack --mode development --watch", diff --git a/js/src/admin/components/LogFileList.tsx b/js/src/admin/components/LogFileList.tsx index 2fc196e..03d97d3 100644 --- a/js/src/admin/components/LogFileList.tsx +++ b/js/src/admin/components/LogFileList.tsx @@ -1,18 +1,24 @@ import app from 'flarum/admin/app'; -import Component from 'flarum/common/Component'; +import Component, { ComponentAttrs } from 'flarum/common/Component'; import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; import type Mithril from 'mithril'; import LogFileListItem from './LogFileListItem'; +import LogFile from '../models/LogFile'; +import LogFileState from '../state/LogFileState'; -export default class LogFileList extends Component { - oninit(vnode) { - super.oninit(vnode); +interface LogFileListAttrs extends ComponentAttrs { + state: LogFileState; +} - this.loading = true; - this.files = []; +export default class LogFileList extends Component { + loading: boolean = true; + files: LogFile[] = []; + logFileState!: LogFileState; - this.state = this.attrs.state; + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.logFileState = vnode.attrs.state; this.refresh(); } @@ -20,17 +26,16 @@ export default class LogFileList extends Component { if (this.loading) { return ; } - return ( {this.files.map((file) => { - return ; + return ; })} ); } - refresh(clear = true) { + refresh(clear: boolean = true) { if (clear) { this.loading = true; this.files = []; @@ -39,15 +44,14 @@ export default class LogFileList extends Component { return this.loadResults().then(this.parseResults.bind(this)); } - loadResults() { - return app.store.find('logs'); + async loadResults(): Promise { + const results = await app.store.find('logs'); + return results as unknown as Promise; } - parseResults(results) { + parseResults(results: LogFile[]) { this.files.push(...results); - this.loading = false; - m.redraw(); return results; } diff --git a/js/src/admin/components/LogFileListItem.tsx b/js/src/admin/components/LogFileListItem.tsx index 56aa341..e554213 100644 --- a/js/src/admin/components/LogFileListItem.tsx +++ b/js/src/admin/components/LogFileListItem.tsx @@ -1,52 +1,117 @@ import app from 'flarum/admin/app'; -import Component from 'flarum/common/Component'; +import Component, { ComponentAttrs } from 'flarum/common/Component'; import Button from 'flarum/common/components/Button'; import humanTime from 'flarum/common/utils/humanTime'; -import classList from 'flarum/common/utils/classList'; import icon from 'flarum/common/helpers/icon'; +import LogFile from '../models/LogFile'; +import LogFileState from '../state/LogFileState'; +import Mithril from 'mithril'; +import LogFileViewModal from './LogFileViewModal'; +import ItemList from 'flarum/common/utils/ItemList'; +import LabelValue from 'flarum/common/components/LabelValue'; +import Tooltip from 'flarum/common/components/Tooltip'; -export default class LogFileListItem extends Component { - oninit(vnode) { - super.oninit(vnode); +interface LogFileListItemAttrs extends ComponentAttrs { + file: LogFile; + state: LogFileState; +} - this.file = this.attrs.file; - this.state = this.attrs.state; +export default class LogFileListItem extends Component { + file!: LogFile; + logFileState!: LogFileState; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.file = vnode.attrs.file; + this.logFileState = vnode.attrs.state; } view() { - const file = this.file; - const selected = this.state?.file?.data.id === file?.data.id; - return ( + {icon('fas fa-file-alt')} + {this.infoItems().toArray()} + {this.actionItems().toArray()} + + ); + } + + infoItems(): ItemList { + const items = new ItemList(); + + items.add( + 'fileName', + + {this.file.fileName()} + , + 100 + ); + + items.add( + 'fileDate', + + + , + 80 + ); + + items.add( + 'fileInfo', + + + , + 60 + ); + + return items; + } + + actionItems(): ItemList { + const items = new ItemList(); + const fileName = this.file.fileName(); + + items.add( + 'view', + { - this.setFile(file.fileName()); + app.modal.show(LogFileViewModal, { logFileState: this.logFileState, file: this.file }); }} - > - - - {icon('far fa-file-alt')} - {file.fileName()} - - - {app.translator.trans('ianm-log-viewer.admin.viewer.last_updated', { - updated: humanTime(file.modified()), - })} - - - {app.translator.trans('ianm-log-viewer.admin.viewer.file_size', { - size: file.size(), - })} - - - - + /> + , + 50 + ); + + items.add( + 'download', + + { + this.logFileState.downloadFile(fileName); + }} + /> + , + 40 + ); + + items.add( + 'delete', + + { + this.logFileState.deleteFile(fileName); + }} + /> + , + 20 ); - } - setFile(fileName: string) { - this.state.loadLogFile(fileName); + return items; } } diff --git a/js/src/admin/components/LogFileViewModal.tsx b/js/src/admin/components/LogFileViewModal.tsx new file mode 100644 index 0000000..2c2c89d --- /dev/null +++ b/js/src/admin/components/LogFileViewModal.tsx @@ -0,0 +1,68 @@ +import app from 'flarum/admin/app'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import Modal from 'flarum/common/components/Modal'; +import LogFileState from '../state/LogFileState'; +import LogFile from '../models/LogFile'; +import type Mithril from 'mithril'; + +interface LogFileViewModalAttrs { + logFileState: LogFileState; + file: LogFile; +} + +export default class LogFileViewModal extends Modal { + logFileState!: LogFileState; + file!: LogFile; + loading: boolean = true; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + this.logFileState = vnode.attrs.logFileState; + this.file = vnode.attrs.file; + + // Load the file content + this.loadFileContent(); + } + + className() { + return 'LogFileViewModal Modal--large'; + } + + title() { + return this.file.fileName(); + } + + async loadFileContent() { + if (this.loading || !this.file) { + try { + await this.logFileState.loadLogFile(this.file.fileName()); + this.loading = false; + m.redraw(); + } catch (error) { + console.error('Error loading log file:', error); + } + } + } + + content() { + if (this.loading || !this.logFileState.getFile()) { + return ( + + + + ); + } + + const file = this.logFileState.getFile(); + const logContent = file.data.attributes.content; + return ( + + + + {logContent} + + + + ); + } +} diff --git a/js/src/admin/components/LogFileViewer.tsx b/js/src/admin/components/LogFileViewer.tsx deleted file mode 100644 index 2148fa8..0000000 --- a/js/src/admin/components/LogFileViewer.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import app from 'flarum/admin/app'; -import Component from 'flarum/common/Component'; - -export default class LogFileViewer extends Component { - oninit(vnode) { - super.oninit(vnode); - - this.state = this.attrs.state; - } - - view() { - if (!this.state.getFile?.()) { - return ( - - {app.translator.trans('ianm-log-viewer.admin.viewer.no_file_selected')} - - ); - } - - const file = this.state.getFile(); - const content = file['data']['attributes']['content']; - - return ( - - {content} - - ); - } -} diff --git a/js/src/admin/components/LogViewerPage.tsx b/js/src/admin/components/LogViewerPage.tsx index 54b8e81..c6120de 100644 --- a/js/src/admin/components/LogViewerPage.tsx +++ b/js/src/admin/components/LogViewerPage.tsx @@ -1,7 +1,6 @@ import app from 'flarum/admin/app'; import ExtensionPage from 'flarum/admin/components/ExtensionPage'; import LogFileList from './LogFileList'; -import LogFileViewer from './LogFileViewer'; import LogFileState from '../state/LogFileState'; export default class LogViewerPage extends ExtensionPage { @@ -11,15 +10,6 @@ export default class LogViewerPage extends ExtensionPage { return ( - - {app.translator.trans('ianm-log-viewer.admin.viewer.available_logs_heading')} - - - - {app.translator.trans('ianm-log-viewer.admin.viewer.file_contents_heading')} - {/* Note to self: would be nice to show the filename here? */} - - {this.buildSettingComponent({ @@ -35,7 +25,7 @@ export default class LogViewerPage extends ExtensionPage { setting: 'ianm-log-viewer.max-file-size', type: 'number', min: 0, - max: 100, + max: 150, required: true, label: app.translator.trans('ianm-log-viewer.admin.settings.max-file-size'), help: app.translator.trans('ianm-log-viewer.admin.settings.max-file-size-help'), @@ -43,6 +33,12 @@ export default class LogViewerPage extends ExtensionPage { {this.submitButton()} + + {app.translator.trans('ianm-log-viewer.admin.viewer.available_logs_heading')} + + + + ); diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts index 82c65ed..96cd1a6 100644 --- a/js/src/admin/index.ts +++ b/js/src/admin/index.ts @@ -3,7 +3,7 @@ import LogViewerPage from './components/LogViewerPage'; import LogFile from './models/LogFile'; app.initializers.add('ianm-log-viewer', () => { - app.store.models.logs = LogFile; + app.store.models.log = LogFile; app.extensionData .for('ianm-log-viewer') @@ -15,5 +15,13 @@ app.initializers.add('ianm-log-viewer', () => { }, 'view' ) + .registerPermission( + { + icon: 'far fa-trash-alt', + label: app.translator.trans('ianm-log-viewer.admin.permissions.delete_logfile_api'), + permission: 'deleteLogfiles', + }, + 'moderate' + ) .registerPage(LogViewerPage); }); diff --git a/js/src/admin/models/LogFile.ts b/js/src/admin/models/LogFile.ts index e429d1c..541bb5c 100644 --- a/js/src/admin/models/LogFile.ts +++ b/js/src/admin/models/LogFile.ts @@ -1,6 +1,9 @@ import Model from 'flarum/common/Model'; export default class LogFile extends Model { + id() { + return Model.attribute('id').call(this); + } fileName() { return Model.attribute('fileName').call(this); } @@ -13,6 +16,10 @@ export default class LogFile extends Model { return Model.attribute('size').call(this); } + formattedSize() { + return Model.attribute('formattedSize').call(this); + } + modified() { return Model.attribute('modified', Model.transformDate).call(this); } diff --git a/js/src/admin/state/LogFileState.ts b/js/src/admin/state/LogFileState.ts index c334d2a..22a69df 100644 --- a/js/src/admin/state/LogFileState.ts +++ b/js/src/admin/state/LogFileState.ts @@ -1,23 +1,64 @@ import app from 'flarum/admin/app'; +import LogFile from '../models/LogFile'; export default class LogFileState { + private file: LogFile | null; + constructor() { this.file = null; } - loadLogFile(filename: string) { - app - .request({ + async loadLogFile(filename: string): Promise { + try { + const result = await app.request({ method: 'GET', - url: app.forum.attribute('apiUrl') + '/logs/' + filename, - }) - .then((result) => { - this.file = result; - m.redraw(); + url: `${app.forum.attribute('apiUrl')}/logs/${filename}`, }); + + this.file = result as LogFile; + + m.redraw(); + } catch (error) { + console.error('Error loading log file:', error); + // Handle or throw error as per your requirements + } } - getFile() { + getFile(): LogFile | null { return this.file; } + + downloadFile(fileName: string) { + // Create the URL pointing to the backend endpoint + const url = `${app.forum.attribute('apiUrl')}/logs/download/${fileName}`; + + // Use an anchor element to initiate the download + const a = document.createElement('a'); + a.href = url; + a.download = fileName; // Set the file name for the download + document.body.appendChild(a); + a.click(); + + // Cleanup + document.body.removeChild(a); + } + + async deleteFile(fileName: string): Promise { + const isConfirmed = confirm('Are you sure you want to delete this file?'); + + if (isConfirmed) { + try { + await app.request({ + method: 'DELETE', + url: `${app.forum.attribute('apiUrl')}/logs/${fileName}`, + }); + + this.file = null; + + m.redraw(); + } catch (error) { + console.error('Error deleting log file:', error); + } + } + } } diff --git a/js/yarn.lock b/js/yarn.lock index fb803be..5029696 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1164,25 +1164,25 @@ react-is "^16.6.3" "@types/eslint-scope@^3.7.3": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" - integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== + version "3.7.5" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.5.tgz#e28b09dbb1d9d35fdfa8a884225f00440dfc5a3e" + integrity sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA== dependencies: "@types/eslint" "*" "@types/estree" "*" "@types/eslint@*": - version "8.44.2" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.2.tgz#0d21c505f98a89b8dd4d37fa162b09da6089199a" - integrity sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg== + version "8.44.3" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.44.3.tgz#96614fae4875ea6328f56de38666f582d911d962" + integrity sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g== dependencies: "@types/estree" "*" "@types/json-schema" "*" "@types/estree@*", "@types/estree@^1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" - integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.2.tgz#ff02bc3dc8317cd668dfec247b750ba1f1d62453" + integrity sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA== "@types/jquery@^3.5.5": version "3.5.19" @@ -1197,14 +1197,14 @@ integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== "@types/mithril@^2.0.7": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@types/mithril/-/mithril-2.2.0.tgz#83b2013eba751c32d5cc7edbad4978165877dfe3" - integrity sha512-Btdlc8GEvyFaPXoPiUC5nOKlackZrhOHp44osPsBQR5b8aiEZz/oGt2N6t09a9z3N62QIZxd0+97hDS+Z1rIng== + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/mithril/-/mithril-2.2.1.tgz#ccb541bdd095d603581298d7407ac66f6d5b09fc" + integrity sha512-2xeIHDdzm+JK8TtArTQEDf2XPPuCxbt95wCAT8TfAaQtuQMLf+Qdr6hnUI1LZKiBJaeyTTkofXXh/p3cEEuMfA== "@types/node@*": - version "20.6.3" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9" - integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA== + version "20.6.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.4.tgz#7882cb8b8adc3106c352dac9c02d4d3ebb95cf3e" + integrity sha512-nU6d9MPY0NBUMiE/nXd2IIoC4OLvsLpwAjheoAeuzgvDZA1Cb10QYg+91AF6zQiKWRN5i1m07x6sMe0niBznoQ== "@types/sizzle@*": version "2.3.3" @@ -1431,12 +1431,12 @@ babel-plugin-polyfill-corejs2@^0.4.5: semver "^6.3.1" babel-plugin-polyfill-corejs3@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz#b4f719d0ad9bb8e0c23e3e630c0c8ec6dd7a1c52" - integrity sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA== + version "0.8.4" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.4.tgz#1fac2b1dcef6274e72b3c72977ed8325cb330591" + integrity sha512-9l//BZZsPR+5XjyJMPtZSK4jv0BsTO1zDac2GC6ygx9WLGlcsnRd1Co0B2zT5fF5Ic6BZy+9m3HNZ3QcOeDKfg== dependencies: "@babel/helper-define-polyfill-provider" "^0.4.2" - core-js-compat "^3.31.0" + core-js-compat "^3.32.2" babel-plugin-polyfill-regenerator@^0.5.2: version "0.5.2" @@ -1614,7 +1614,7 @@ convert-source-map@^1.7.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== -core-js-compat@^3.31.0: +core-js-compat@^3.31.0, core-js-compat@^3.32.2: version "3.32.2" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.2.tgz#8047d1a8b3ac4e639f0d4f66d4431aa3b16e004c" integrity sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ== @@ -1678,9 +1678,9 @@ duplexer@^0.1.2: integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== electron-to-chromium@^1.4.526: - version "1.4.527" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.527.tgz#5acf0bcc5bf015eb31dd2279989a3712e341a554" - integrity sha512-EafxEiEDzk2aLrdbtVczylHflHdHkNrpGNHIgDyA63sUQLQVS2ayj2hPw3RsVB42qkwURH+T2OxV7kGPUuYszA== + version "1.4.528" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.528.tgz#7c900fd73d9d2e8bb0dab0e301f25f0f4776ef2c" + integrity sha512-UdREXMXzLkREF4jA8t89FQjA8WHI6ssP38PMY4/4KhXFQbtImnghh4GkCgrtiZwLKUKVD2iTVXvDVQjfomEQuA== emoji-regex@^8.0.0: version "8.0.0" @@ -1832,7 +1832,7 @@ flarum-tsconfig@^1.0.2: "@types/throttle-debounce" "^2.1.0" dayjs "^1.10.4" -flarum-webpack-config@^2.0.0: +flarum-webpack-config@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/flarum-webpack-config/-/flarum-webpack-config-2.0.2.tgz#efa67268904390a1e7aee55e1ac5a794a57e0855" integrity sha512-kUCaCsXL8s/OhSWleWtIppRXDNBzAf8/ewCx9OIF0zNO0hlvY5T1N0EO0AnyUJbsp5nOCdzsTo9rTRRsbKT+IA== @@ -2751,7 +2751,7 @@ tsutils@3: dependencies: tslib "^1.8.1" -type-coverage-core@^2.17.2: +type-coverage-core@^2.23.0: version "2.26.3" resolved "https://registry.yarnpkg.com/type-coverage-core/-/type-coverage-core-2.26.3.tgz#47e2c8225f582d1ca9551c2bace20836b295c944" integrity sha512-rzNdW/tClHJvsUiy787b/UX53bNh1Dn7A5KqZDQjkL3j7iKFv/KnTolxDBBgTPcK4Zn9Ab7WLrik7cXw2oZZqw== @@ -2767,10 +2767,10 @@ typed-styles@^0.0.7: resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== -typescript-coverage-report@^0.6.1: - version "0.6.4" - resolved "https://registry.yarnpkg.com/typescript-coverage-report/-/typescript-coverage-report-0.6.4.tgz#3a7a7724c0f27de50d2a0708c7b7b7088bed2055" - integrity sha512-G+0OFYxwN5oRbORlU1nKYtO00G567lcl4+nbg3MU3Y9ayFnh677dMHmAL4JGP/4Cb1IBN5h/DUQDr/z9X+9lag== +typescript-coverage-report@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/typescript-coverage-report/-/typescript-coverage-report-0.8.0.tgz#d189f7074c1c91ea5c4014c2c7b507037975ff91" + integrity sha512-qYUrPt2CISyXTzwwyD7g6lciovvb2qQGMZKC7zHf2TiLg8QJ6cybYKX1/TETB/BzxaSU7+2FVjiIq3q1TG8A0w== dependencies: chalk "^4.0.0" cli-table3 "^0.6.1" @@ -2780,13 +2780,18 @@ typescript-coverage-report@^0.6.1: react-dom "^16.13.1" rimraf "^3.0.2" semantic-ui-react "^0.88.2" - type-coverage-core "^2.17.2" + type-coverage-core "^2.23.0" -typescript@^4.4.4, typescript@^4.5.4: +typescript@^4.4.4: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" + integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" diff --git a/less/admin.less b/less/admin.less index 8692640..c22590c 100644 --- a/less/admin.less +++ b/less/admin.less @@ -1,86 +1,110 @@ .LogViewerPage { - display: grid; - gap: 16px; - height: 80vh; - overflow: hidden; - - grid-template-columns: 1fr; - grid-template-rows: auto 1fr; - margin-top: 16px; + display: grid; + grid-template-columns: 50% 1fr; + gap: 20px; - h3 { - margin-top: 0; - } - - &--fileListItems { - display: flex; + > ul, fieldset > ul { + list-style: none; + margin: 0; + padding: 0; } - .LogFile-item { - &:not(:last-child) { - margin-right: 4px; - } + > ul > li { + margin-bottom: 25px; } +} - @media @desktop-up { - grid-template-columns: 250px 1fr; - grid-template-rows: 1fr; +.LogViewerPage--logFileList { + display: flex; + flex-direction: column; + border-radius: var(--border-radius); + overflow: hidden; - &--fileListItems { - flex-direction: column; + .LogFile-item { + display: flex; + padding: 10px 10px 10px 0; + background-color: var(--control-bg); + color: var(--control-color); + margin-bottom: 10px; + border-radius: @border-radius; - height: 100%; + &--icon { + --font-size: 1.6rem; + font-size: var(--font-size); + width: calc(~"var(--font-size) + 4rem"); + display: flex; + align-items: center; + justify-content: center; } - .LogFile-item { - &:not(:last-child) { - margin-right: 0; - margin-bottom: 4px; + &--info { + .fileName { + font-weight: 600; + font-size: 1.2em; } } - } - &--fileList, - &--container { - // Prevents grid blowlout when the file content is too long - min-width: 0; - min-height: 0; + &--actions { + display: flex; + align-items: center; + margin-left: auto; - display: flex; - flex-direction: column; + > *:not(:first-child) { + margin-left: 8px; + } + } } - &--fileListItems { - overflow: auto; + &--empty { + color: var(--control-color); } +} - .Button--logFile { - text-align: left; - width: 100%; +@media @phone { + .LogViewerPage { + grid-template-columns: 1fr; // Make it stack vertically on mobile + } + + .LogViewerPage--logFileList { + .LogFile-item { + flex-wrap: wrap; + padding: 16px; + + &--icon { + justify-content: start; + padding: 8px; + width: auto; + min-width: calc(~"var(--font-size) + 4rem"); + } - .icon { - margin-right: 5px; + &--actions { + width: auto; + } } } +} - .LogViewerPage--No-File, - .LogViewerPage--fileContent { - width: 100%; - height: 100%; - padding: 16px; +// Styling for the log file viewer modal +.LogFileViewModal { + .Modal-body { + color: @code-color; overflow: auto; - - background-color: @control-bg; - border-radius: @border-radius; - border: 2px solid @primary-color; font-family: source-code-pro, Monaco, Consolas, "Courier New", monospace; - color: @code-color; line-height: 1.3; - pre { - margin: 0; - padding: 0; + .LogViewerPage--fileContent { + padding: 16px; + background-color: @body-bg; + border: 2px solid @primary-color; + border-radius: @border-radius; + overflow: auto; + + pre { + margin: 0; + padding: 0; + white-space: pre-wrap; // Ensures that the content doesn't exceed the modal's width + } } } } diff --git a/locale/en.yml b/locale/en.yml index e086c26..6a47e27 100644 --- a/locale/en.yml +++ b/locale/en.yml @@ -2,6 +2,7 @@ ianm-log-viewer: admin: permissions: access_logfile_api: Access logfile data via the API + delete_logfile_api: Delete logfiles via the API settings: max-file-size: Maximum Log File Size (MB) max-file-size-help: If a log file exceeds this size, it will be split into multiple parts. Set to 0 to disable splitting. Default is 1MB. Maximum allowable size is 150MB. @@ -9,8 +10,9 @@ ianm-log-viewer: purge-days-help: Relies on the Flarum scheduler being active. 0 for disabled. viewer: available_logs_heading: Available files - file_contents_heading: File contents - file_size: "Size (bytes): {size}" - last_updated: "Last updated: {updated}" - no_file_selected: Select a log file to view its content. + size_label: "Size" + last_updated: "Last updated" + view_label: View + download_label: Download + delete_label: Delete diff --git a/src/Api/Controller/DeleteLogFileController.php b/src/Api/Controller/DeleteLogFileController.php new file mode 100644 index 0000000..7998928 --- /dev/null +++ b/src/Api/Controller/DeleteLogFileController.php @@ -0,0 +1,62 @@ +paths = $paths; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + RequestUtil::getActor($request)->assertCan('deleteLogfiles'); + + $fileName = Arr::get($request->getQueryParams(), 'file'); + + // Sanitize the filename to prevent directory traversal + $fileName = basename($fileName); + + $logDir = $this->getLogDirectoryOrThrow($request, $this->paths); + $absoluteFilePath = $logDir.DIRECTORY_SEPARATOR.$fileName; + + // Ensure the resulting path is still within the log directory + if (strpos($absoluteFilePath, $logDir) !== 0) { + throw new \RuntimeException('Invalid file path'); + } + + if (! file_exists($absoluteFilePath)) { + throw new ModelNotFoundException(); + } + + unlink($absoluteFilePath); + + return new EmptyResponse(204); + } +} diff --git a/src/Api/Controller/DownloadLogFileController.php b/src/Api/Controller/DownloadLogFileController.php new file mode 100644 index 0000000..1473d88 --- /dev/null +++ b/src/Api/Controller/DownloadLogFileController.php @@ -0,0 +1,66 @@ +paths = $paths; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + RequestUtil::getActor($request)->assertCan('readLogfiles'); + + $fileName = Arr::get($request->getQueryParams(), 'file'); + + // Sanitize the filename to prevent directory traversal + $fileName = basename($fileName); + + $logDir = $this->getLogDirectoryOrThrow($request, $this->paths); + $absoluteFilePath = $logDir.DIRECTORY_SEPARATOR.$fileName; + + // Ensure the resulting path is still within the log directory + if (strpos($absoluteFilePath, $logDir) !== 0) { + throw new \RuntimeException('Invalid file path'); + } + + if (! file_exists($absoluteFilePath)) { + throw new ModelNotFoundException(); + } + + $fileStream = new Stream($absoluteFilePath, 'r'); + + return (new Response()) + ->withHeader('Content-Type', 'text/plain') + ->withHeader('Content-Disposition', 'attachment; filename="'.$fileName.'"') + ->withBody($fileStream); + } +} diff --git a/src/Api/Controller/ListLogfilesController.php b/src/Api/Controller/ListLogfilesController.php index f3b050e..fbecdd8 100644 --- a/src/Api/Controller/ListLogfilesController.php +++ b/src/Api/Controller/ListLogfilesController.php @@ -15,7 +15,6 @@ use Flarum\Foundation\Paths; use Flarum\Http\RequestUtil; use IanM\LogViewer\Api\Serializer\FileListSerializer; -use IanM\LogViewer\LogDirectoryTrait; use IanM\LogViewer\Model\LogFile; use Illuminate\Support\Collection; use Psr\Http\Message\ServerRequestInterface; @@ -25,18 +24,15 @@ class ListLogfilesController extends AbstractListController { - use LogDirectoryTrait; - - /** - * @var Paths - */ - protected $paths; + use LogFileDirectory; /** * @var Finder */ protected $finder; + protected $paths; + public $serializer = FileListSerializer::class; public function __construct(Paths $paths, Finder $finder) @@ -49,7 +45,7 @@ protected function data(ServerRequestInterface $request, Document $document) { RequestUtil::getActor($request)->assertCan('readLogfiles'); - $logDir = $this->getLogDirectory($this->paths); + $logDir = $this->getLogDirectoryOrThrow($request, $this->paths); $files = new Collection(); $this->finder->files()->in($logDir); diff --git a/src/Api/Controller/LogFileDirectory.php b/src/Api/Controller/LogFileDirectory.php new file mode 100644 index 0000000..df507a5 --- /dev/null +++ b/src/Api/Controller/LogFileDirectory.php @@ -0,0 +1,38 @@ +assertCan('readLogfiles'); + + $logDir = $this->getLogDirectory($paths); + + // Ensure the resulting path is still within the desired directory + if (strpos(realpath($logDir), realpath($paths->base)) !== 0) { + throw new \RuntimeException('Invalid log directory path'); + } + + return $logDir; + } + + private function getLogDirectory(Paths $paths): string + { + return $paths->storage.DIRECTORY_SEPARATOR.'logs'; + } +} diff --git a/src/Api/Controller/ShowLogFileController.php b/src/Api/Controller/ShowLogFileController.php index 33d487a..e6aaf33 100644 --- a/src/Api/Controller/ShowLogFileController.php +++ b/src/Api/Controller/ShowLogFileController.php @@ -16,7 +16,6 @@ use Flarum\Http\Exception\RouteNotFoundException; use Flarum\Http\RequestUtil; use IanM\LogViewer\Api\Serializer\LogFileSerializer; -use IanM\LogViewer\LogDirectoryTrait; use IanM\LogViewer\Model\LogFile; use Illuminate\Support\Arr; use Psr\Http\Message\ServerRequestInterface; @@ -24,7 +23,7 @@ class ShowLogFileController extends AbstractShowController { - use LogDirectoryTrait; + use LogFileDirectory; public $serializer = LogFileSerializer::class; @@ -41,11 +40,21 @@ public function __construct(Paths $paths) protected function data(ServerRequestInterface $request, Document $document) { $fileName = Arr::get($request->getQueryParams(), 'file'); + + // Sanitize the filename to prevent directory traversal + $fileName = basename($fileName); + RequestUtil::getActor($request)->assertCan('readLogfiles'); - $logDir = $this->getLogDirectory($this->paths); + $logDir = $this->getLogDirectoryOrThrow($request, $this->paths); + $absoluteFilePath = $logDir.DIRECTORY_SEPARATOR.$fileName; + + // Ensure the resulting path is still within the log directory + if (strpos($absoluteFilePath, $logDir) !== 0) { + throw new \RuntimeException('Invalid file path'); + } - if (! file_exists($logDir.DIRECTORY_SEPARATOR.$fileName)) { + if (! file_exists($absoluteFilePath)) { throw new RouteNotFoundException(); } diff --git a/src/Api/Serializer/FileListSerializer.php b/src/Api/Serializer/FileListSerializer.php index 6a3d696..59ff0f1 100644 --- a/src/Api/Serializer/FileListSerializer.php +++ b/src/Api/Serializer/FileListSerializer.php @@ -15,17 +15,33 @@ class FileListSerializer extends AbstractSerializer { - protected $type = 'logs'; + protected $type = 'log'; protected function getDefaultAttributes($model) { $attributes = [ 'fileName' => $model->fileName, 'fullPath' => $model->fullPath, - 'size' => $model->size, + 'size' =>$model->size, + 'formattedSize' => $this->formatBytes($model->size), 'modified' => $this->formatDate($model->modified), ]; return $attributes; } + + protected function formatBytes(int $bytes, int $decimals = 2): string + { + if ($bytes === 0) { + return '0 Byte'; + } + + $k = 1024; + $dm = $decimals < 0 ? 0 : $decimals; + $sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + $i = floor(log($bytes, $k)); + + return number_format($bytes / pow($k, $i), $dm).' '.$sizes[$i]; + } } diff --git a/src/Console/CleanupLogfilesCommand.php b/src/Console/CleanupLogfilesCommand.php index 9418936..bc0e2d2 100644 --- a/src/Console/CleanupLogfilesCommand.php +++ b/src/Console/CleanupLogfilesCommand.php @@ -13,14 +13,14 @@ use Flarum\Foundation\Paths; use Flarum\Settings\SettingsRepositoryInterface; -use IanM\LogViewer\LogDirectoryTrait; +use IanM\LogViewer\Api\Controller\LogFileDirectory; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; class CleanupLogfilesCommand extends Command { - use LogDirectoryTrait; + use LogFileDirectory; protected $signature = 'logfiles:cleanup'; protected $description = 'Deletes log files older than x days.'; @@ -28,14 +28,16 @@ class CleanupLogfilesCommand extends Command protected $settings; protected $filesystem; protected $paths; + protected $finder; - public function __construct(SettingsRepositoryInterface $settings, Filesystem $filesystem, Paths $paths) + public function __construct(SettingsRepositoryInterface $settings, Filesystem $filesystem, Paths $paths, Finder $finder) { parent::__construct(); $this->settings = $settings; $this->filesystem = $filesystem; $this->paths = $paths; + $this->finder = $finder; } public function handle() @@ -76,12 +78,11 @@ protected function getPurgeDays(): int protected function getOldLogFiles(int $purgeDays): Finder { - $finder = new Finder(); - $finder->files() + $this->finder->files() ->in($this->getLogDirectory($this->paths)) ->date('< now - '.$purgeDays.' days'); - return $finder; + return $this->finder; } protected function deleteLogFiles(Finder $logFiles, int $purgeDays): void diff --git a/src/Console/SplitLargeLogfilesCommand.php b/src/Console/SplitLargeLogfilesCommand.php index 2327c9e..6c7008c 100644 --- a/src/Console/SplitLargeLogfilesCommand.php +++ b/src/Console/SplitLargeLogfilesCommand.php @@ -13,14 +13,14 @@ use Flarum\Foundation\Paths; use Flarum\Settings\SettingsRepositoryInterface; -use IanM\LogViewer\LogDirectoryTrait; +use IanM\LogViewer\Api\Controller\LogFileDirectory; use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; class SplitLargeLogfilesCommand extends Command { - use LogDirectoryTrait; + use LogFileDirectory; protected $signature = 'logfiles:split-large'; protected $description = 'Splits log files larger than a configured size.'; @@ -28,14 +28,16 @@ class SplitLargeLogfilesCommand extends Command protected $settings; protected $filesystem; protected $paths; + protected $finder; - public function __construct(SettingsRepositoryInterface $settings, Filesystem $filesystem, Paths $paths) + public function __construct(SettingsRepositoryInterface $settings, Filesystem $filesystem, Paths $paths, Finder $finder) { parent::__construct(); $this->settings = $settings; $this->filesystem = $filesystem; $this->paths = $paths; + $this->finder = $finder; } public function handle() @@ -81,12 +83,11 @@ protected function getMaxFileSize(): int protected function getLargeLogFiles(int $maxFileSize): Finder { - $finder = new Finder(); - $finder->files() + $this->finder->files() ->in($this->getLogDirectory($this->paths)) ->size('>'.$maxFileSize); - return $finder; + return $this->finder; } protected function splitLargeFiles(Finder $logFiles, int $maxFileSize): void diff --git a/src/LogDirectoryTrait.php b/src/LogDirectoryTrait.php deleted file mode 100644 index 97580a7..0000000 --- a/src/LogDirectoryTrait.php +++ /dev/null @@ -1,22 +0,0 @@ -storage.DIRECTORY_SEPARATOR.'logs'; - } -} diff --git a/tests/integration/api/ListLogFilesTest.php b/tests/integration/api/LogFilesTest.php similarity index 52% rename from tests/integration/api/ListLogFilesTest.php rename to tests/integration/api/LogFilesTest.php index 7b1b23e..fd35dd9 100644 --- a/tests/integration/api/ListLogFilesTest.php +++ b/tests/integration/api/LogFilesTest.php @@ -15,8 +15,9 @@ use Flarum\Testing\integration\RetrievesAuthorizedUsers; use Flarum\Testing\integration\TestCase; use Illuminate\Support\Arr; +use Psr\Http\Message\ResponseInterface; -class ListLogFileTest extends TestCase +class LogFilesTest extends TestCase { use RetrievesAuthorizedUsers; @@ -36,16 +37,23 @@ public function setUp(): void ], 'group_permission' => [ ['group_id' => 4, 'permission' => 'readLogfiles'], + ['group_id' => 4, 'permission' => 'deleteLogfiles'] ] ]); // Delete any existing log files before starting + $this->clearLogFiles(); + } + + private function clearLogFiles() + { $paths = $this->app()->getContainer()->make('flarum.paths'); $logDir = $paths->storage.'/logs'; - // check the folder exists, if not, create it + if (! is_dir($logDir)) { mkdir($logDir, 0777, true); } + $finder = new \Symfony\Component\Finder\Finder(); $finder->files()->in($logDir); foreach ($finder as $file) { @@ -53,12 +61,40 @@ public function setUp(): void } } + private function createLogEntryAndGetFileName(string $content): string + { + $this->logInfoContent($content); + + $response = $this->send( + $this->request('GET', '/api/logs', [ + 'authenticatedAs' => 3, + ]) + ); + + $json = json_decode($response->getBody()->getContents(), true); + + return Arr::get($json, 'data.0.attributes.fileName'); + } + + private function logInfoContent(string $string): void + { + $this->app()->getContainer()->make('log')->info($string); + } + + /** + * @return mixed + */ + private function getContents(ResponseInterface $response) + { + return json_decode($response->getBody()->getContents(), true); + } + /** * @test */ public function authorized_user_can_list_logfiles() { - $this->app()->getContainer()->make('log')->info('hello, testing'); + $this->logInfoContent('hello, testing'); $response = $this->send( $this->request('GET', '/api/logs', [ @@ -68,11 +104,11 @@ public function authorized_user_can_list_logfiles() $this->assertEquals(200, $response->getStatusCode()); - $json = json_decode($response->getBody()->getContents(), true); + $json = $this->getContents($response); $data = Arr::get($json, 'data'); $this->assertIsArray($json['data']); $this->assertEquals(1, count($data)); - $this->assertEquals('logs', Arr::get($data[0], 'type')); + $this->assertEquals('log', Arr::get($data[0], 'type')); } /** @@ -106,7 +142,7 @@ public function guest_user_cannot_list_logfiles() */ public function authorized_user_can_get_logfile() { - $this->app()->getContainer()->make('log')->info('my !!!content'); + $this->logInfoContent('my !!!content'); $response = $this->send( $this->request('GET', '/api/logs', [ @@ -116,7 +152,7 @@ public function authorized_user_can_get_logfile() $this->assertEquals(200, $response->getStatusCode()); - $json = json_decode($response->getBody()->getContents(), true); + $json = $this->getContents($response); $data = Arr::get($json, 'data'); $logFileName = Arr::get($data[0], 'attributes.fileName'); @@ -127,11 +163,11 @@ public function authorized_user_can_get_logfile() ); $this->assertEquals(200, $response->getStatusCode()); - $json = json_decode($response->getBody()->getContents(), true); + $json = $this->getContents($response); $data = Arr::get($json, 'data'); $this->assertIsArray($json['data']); - $this->assertEquals('logs', Arr::get($data, 'type')); + $this->assertEquals('log', Arr::get($data, 'type')); $this->assertStringContainsString('my !!!content', $data['attributes']['content']); } @@ -140,7 +176,7 @@ public function authorized_user_can_get_logfile() */ public function unauthorized_user_cannot_get_logfile() { - $this->app()->getContainer()->make('log')->info('my !!!content'); + $this->logInfoContent('my !!!content'); $response = $this->send( $this->request('GET', '/api/logs', [ @@ -150,7 +186,7 @@ public function unauthorized_user_cannot_get_logfile() $this->assertEquals(200, $response->getStatusCode()); - $json = json_decode($response->getBody()->getContents(), true); + $json = $this->getContents($response); $data = Arr::get($json, 'data'); $logFileName = Arr::get($data[0], 'id'); @@ -176,4 +212,105 @@ public function unauthorized_user_cannot_get_logfile_not_existing() $this->assertEquals(403, $response->getStatusCode()); } + + /** + * @test + */ + public function authorized_user_can_download_logfile() + { + $logFileName = $this->createLogEntryAndGetFileName('content for download'); + + $response = $this->send( + $this->request('GET', "/api/logs/download/$logFileName", [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('content for download', $response->getBody()->getContents()); + } + + /** + * @test + */ + public function authorized_user_can_delete_logfile() + { + $logFileName = $this->createLogEntryAndGetFileName('content for delete'); + + $response = $this->send( + $this->request('DELETE', "/api/logs/$logFileName", [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(204, $response->getStatusCode()); + + // Check the file is indeed deleted + $response = $this->send( + $this->request('GET', "/api/logs/$logFileName", [ + 'authenticatedAs' => 3, + ]) + ); + $this->assertEquals(404, $response->getStatusCode()); // Not found + } + + /** + * @test + */ + public function unauthorized_user_cannot_download_logfile() + { + $logFileName = $this->createLogEntryAndGetFileName('content'); + + $response = $this->send( + $this->request('GET', "/api/logs/download/$logFileName", [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @test + */ + public function unauthorized_user_cannot_delete_logfile() + { + $logFileName = $this->createLogEntryAndGetFileName('content'); + + $response = $this->send( + $this->request('DELETE', "/api/logs/$logFileName", [ + 'authenticatedAs' => 2, + ]) + ); + + $this->assertEquals(403, $response->getStatusCode()); + } + + /** + * @test + */ + public function cannot_download_nonexistent_logfile() + { + $response = $this->send( + $this->request('GET', '/api/logs/download/idontexist.log', [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function cannot_delete_nonexistent_logfile() + { + $response = $this->send( + $this->request('DELETE', '/api/logs/idontexist.log', [ + 'authenticatedAs' => 3, + ]) + ); + + $this->assertEquals(404, $response->getStatusCode()); + } }
{this.file.fileName()}
{file.fileName()}
+ {logContent} +
{logContent}
{app.translator.trans('ianm-log-viewer.admin.viewer.no_file_selected')}
{content}