<ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse>
<tr>
- <td>
+ <td class="expand-cell">
<span class="expander" [pRowToggler]="videoAbuse">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span>
<ng-template pTemplate="body" let-videoBlacklist let-expanded="expanded">
<tr>
- <td>
+ <td class="expand-cell">
<span *ngIf="videoBlacklist.reason" class="expander" [pRowToggler]="videoBlacklist">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span>
<ng-template pTemplate="body" let-expanded="expanded" let-job>
<tr>
- <td>
+ <td class="expand-cell">
<span class="expander" [pRowToggler]="job">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span>
message: string
meta: string
+ by: string
+ domain: string
+ action: string
+
constructor (row: any) {
this.date = new Date(row.timestamp)
this.localeDate = this.date.toLocaleString()
const metaObj = omit(row, 'timestamp', 'level', 'message', 'label')
if (Object.keys(metaObj).length !== 0) this.meta = JSON.stringify(metaObj, undefined, 2)
+
+ if (row.level === 'audit') {
+ try {
+ const message = JSON.parse(row.message)
+
+ this.by = message.user
+ this.domain = message.domain
+ this.action = message.action
+
+ this.meta = JSON.stringify(message, null, 2)
+ this.message = ''
+ } catch (err) {
+ console.error('Cannot parse audit message.', err)
+ }
+ }
}
}
<div class="header">
+ <div class="peertube-select-container">
+ <select [(ngModel)]="logType" (ngModelChange)="refresh()">
+ <option *ngFor="let logTypeChoice of logTypeChoices" [value]="logTypeChoice.id">{{ logTypeChoice.label }}</option>
+ </select>
+ </div>
+
<div class="peertube-select-container">
<select [(ngModel)]="startDate" (ngModelChange)="refresh()">
<option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option>
</select>
</div>
- <div class="peertube-select-container">
+ <div class="peertube-select-container" *ngIf="!isAuditLog()">
<select [(ngModel)]="level" (ngModelChange)="refresh()">
<option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option>
</select>
<span class="log-date">[{{ log.localeDate }}]</span>
+ <strong class="log-by" *ngIf="log.by" i18n>By {{ log.by }} -></strong>
+ <strong class="log-domain-action" *ngIf="log.domain">{{ log.domain }} -> {{ log.action }}</strong>
+
{{ log.message }}
- {{ log.meta }}
+ <pre>{{ log.meta }}</pre>
</div>
</div>
</div>
margin-right: 5px;
}
+ .log-by {
+ margin: 0 5px;
+ }
+
.warn {
color: $orange-color;
}
.error {
color: $red;
}
+
+ pre {
+ margin-bottom: 5px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
}
.header {
logs: LogRow[] = []
timeChoices: { id: string, label: string }[] = []
levelChoices: { id: LogLevel, label: string }[] = []
+ logTypeChoices: { id: 'audit' | 'standard', label: string }[] = []
startDate: string
level: LogLevel
+ logType: 'audit' | 'standard'
constructor (
private logsService: LogsService,
ngOnInit (): void {
this.buildTimeChoices()
this.buildLevelChoices()
+ this.buildLogTypeChoices()
this.load()
}
load () {
this.loading = true
- this.logsService.getLogs(this.level, this.startDate)
+ this.logsService.getLogs({ isAuditLog: this.isAuditLog(), level: this.level, startDate: this.startDate })
.subscribe(
logs => {
this.logs = logs
)
}
+ isAuditLog () {
+ return this.logType === 'audit'
+ }
+
buildTimeChoices () {
const lastHour = new Date()
lastHour.setHours(lastHour.getHours() - 1)
this.level = 'warn'
}
+
+ buildLogTypeChoices () {
+ this.logTypeChoices = [
+ {
+ id: 'standard',
+ label: this.i18n('Standard logs')
+ },
+ {
+ id: 'audit',
+ label: this.i18n('Audit logs')
+ }
+ ]
+
+ this.logType = 'audit'
+ }
}
@Injectable()
export class LogsService {
private static BASE_LOG_URL = environment.apiUrl + '/api/v1/server/logs'
+ private static BASE_AUDIT_LOG_URL = environment.apiUrl + '/api/v1/server/audit-logs'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) {}
- getLogs (level: LogLevel, startDate: string, endDate?: string): Observable<any[]> {
+ getLogs (options: {
+ isAuditLog: boolean,
+ startDate: string,
+ level?: LogLevel,
+ endDate?: string
+ }): Observable<any[]> {
+ const { isAuditLog, startDate } = options
+
let params = new HttpParams()
params = params.append('startDate', startDate)
- params = params.append('level', level)
- if (endDate) params.append('endDate', endDate)
+ if (!isAuditLog) params = params.append('level', options.level)
+ if (options.endDate) params.append('endDate', options.endDate)
+
+ const path = isAuditLog
+ ? LogsService.BASE_AUDIT_LOG_URL
+ : LogsService.BASE_LOG_URL
- return this.authHttp.get<any[]>(LogsService.BASE_LOG_URL, { params })
+ return this.authHttp.get<any[]>(path, { params })
.pipe(
map(rows => rows.map(r => new LogRow(r))),
catchError(err => this.restExtractor.handleError(err))
<ng-template pTemplate="body" let-expanded="expanded" let-user>
<tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }">
- <td>
+ <td class="expand-cell">
<p-tableCheckbox [value]="user"></p-tableCheckbox>
</td>
td {
padding-left: 15px !important;
- &:not(.action-cell) {
+ &:not(.action-cell):not(.expand-cell) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../../middlewares'
import { mtimeSortFilesDesc } from '../../../../shared/core-utils/logs/logs'
import { readdir, readFile } from 'fs-extra'
-import { MAX_LOGS_OUTPUT_CHARACTERS } from '../../../initializers/constants'
+import { AUDIT_LOG_FILENAME, MAX_LOGS_OUTPUT_CHARACTERS, LOG_FILENAME } from '../../../initializers/constants'
import { join } from 'path'
-import { getLogsValidator } from '../../../middlewares/validators/logs'
+import { getAuditLogsValidator, getLogsValidator } from '../../../middlewares/validators/logs'
import { LogLevel } from '../../../../shared/models/server/log-level.type'
import { CONFIG } from '../../../initializers/config'
+import { logger } from '@server/helpers/logger'
const logsRouter = express.Router()
asyncMiddleware(getLogs)
)
+logsRouter.get('/audit-logs',
+ authenticate,
+ ensureUserHasRight(UserRight.MANAGE_LOGS),
+ getAuditLogsValidator,
+ asyncMiddleware(getAuditLogs)
+)
+
// ---------------------------------------------------------------------------
export {
// ---------------------------------------------------------------------------
+const auditLogNameFilter = generateLogNameFilter(AUDIT_LOG_FILENAME)
+async function getAuditLogs (req: express.Request, res: express.Response) {
+ const output = await generateOutput({
+ startDateQuery: req.query.startDate,
+ endDateQuery: req.query.endDate,
+ level: 'audit',
+ nameFilter: auditLogNameFilter
+ })
+
+ return res.json(output).end()
+}
+
+const logNameFilter = generateLogNameFilter(LOG_FILENAME)
async function getLogs (req: express.Request, res: express.Response) {
+ const output = await generateOutput({
+ startDateQuery: req.query.startDate,
+ endDateQuery: req.query.endDate,
+ level: req.query.level || 'info',
+ nameFilter: logNameFilter
+ })
+
+ return res.json(output).end()
+}
+
+async function generateOutput (options: {
+ startDateQuery: string,
+ endDateQuery?: string,
+ level: LogLevel,
+ nameFilter: RegExp
+}) {
+ const { startDateQuery, level, nameFilter } = options
+
const logFiles = await readdir(CONFIG.STORAGE.LOG_DIR)
const sortedLogFiles = await mtimeSortFilesDesc(logFiles, CONFIG.STORAGE.LOG_DIR)
let currentSize = 0
- const startDate = new Date(req.query.startDate)
- const endDate = req.query.endDate ? new Date(req.query.endDate) : new Date()
- const level: LogLevel = req.query.level || 'info'
+ const startDate = new Date(startDateQuery)
+ const endDate = options.endDateQuery ? new Date(options.endDateQuery) : new Date()
let output: string[] = []
for (const meta of sortedLogFiles) {
+ if (nameFilter.exec(meta.file) === null) continue
+
const path = join(CONFIG.STORAGE.LOG_DIR, meta.file)
const result = await getOutputFromFile(path, startDate, endDate, level, currentSize)
if (currentSize > MAX_LOGS_OUTPUT_CHARACTERS || (result.logTime && result.logTime < startDate.getTime())) break
}
- return res.json(output).end()
+ return output
}
async function getOutputFromFile (path: string, startDate: Date, endDate: Date, level: LogLevel, currentSize: number) {
let logTime: number
const logsLevel: { [ id in LogLevel ]: number } = {
+ audit: -1,
debug: 0,
info: 1,
warn: 2,
return { currentSize, output: output.reverse(), logTime }
}
+
+function generateLogNameFilter (baseName: string) {
+ return new RegExp('^' + baseName.replace(/\.log$/, '') + '\d*.log$')
+}
import { VideoComment } from '../../shared/models/videos/video-comment.model'
import { CustomConfig } from '../../shared/models/server/custom-config.model'
import { CONFIG } from '../initializers/config'
+import { AUDIT_LOG_FILENAME } from '@server/initializers/constants'
function getAuditIdFromRes (res: express.Response) {
return res.locals.oauth.token.User.username
levels: { audit: 0 },
transports: [
new winston.transports.File({
- filename: path.join(CONFIG.STORAGE.LOG_DIR, 'peertube-audit.log'),
+ filename: path.join(CONFIG.STORAGE.LOG_DIR, AUDIT_LOG_FILENAME),
level: 'audit',
maxsize: 5242880,
maxFiles: 5,
import { FileTransportOptions } from 'winston/lib/winston/transports'
import { CONFIG } from '../initializers/config'
import { omit } from 'lodash'
+import { LOG_FILENAME } from '@server/initializers/constants'
const label = CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT
})
const fileLoggerOptions: FileTransportOptions = {
- filename: path.join(CONFIG.STORAGE.LOG_DIR, 'peertube.log'),
+ filename: path.join(CONFIG.STORAGE.LOG_DIR, LOG_FILENAME),
handleExceptions: true,
format: winston.format.combine(
winston.format.timestamp(),
}
const MAX_LOGS_OUTPUT_CHARACTERS = 10 * 1000 * 1000
+const LOG_FILENAME = 'peertube.log'
+const AUDIT_LOG_FILENAME = 'peertube-audit.log'
// ---------------------------------------------------------------------------
BCRYPT_SALT_SIZE,
TRACKER_RATE_LIMITS,
FILES_CACHE,
+ LOG_FILENAME,
CONSTRAINTS_FIELDS,
EMBED_SIZE,
REDUNDANCY,
OAUTH_LIFETIME,
CUSTOM_HTML_TAG_COMMENTS,
BROADCAST_CONCURRENCY,
+ AUDIT_LOG_FILENAME,
PAGINATION,
ACTOR_FOLLOW_SCORE,
PREVIEWS_SIZE,
}
]
+const getAuditLogsValidator = [
+ query('startDate')
+ .custom(isDateValid).withMessage('Should have a valid start date'),
+ query('endDate')
+ .optional()
+ .custom(isDateValid).withMessage('Should have a valid end date'),
+
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
+ logger.debug('Checking getAuditLogsValidator parameters.', { parameters: req.query })
+
+ if (areValidationErrors(req, res)) return
+
+ return next()
+ }
+]
+
// ---------------------------------------------------------------------------
export {
- getLogsValidator
+ getLogsValidator,
+ getAuditLogsValidator
}
import * as chai from 'chai'
import 'mocha'
-import {
- flushTests,
- killallServers,
- flushAndRunServer,
- ServerInfo,
- setAccessTokensToServers,
- cleanupTests
-} from '../../../../shared/extra-utils/index'
+import { cleanupTests, flushAndRunServer, ServerInfo, setAccessTokensToServers } from '../../../../shared/extra-utils/index'
import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
import { uploadVideo } from '../../../../shared/extra-utils/videos/videos'
-import { getLogs } from '../../../../shared/extra-utils/logs/logs'
+import { getAuditLogs, getLogs } from '../../../../shared/extra-utils/logs/logs'
const expect = chai.expect
await setAccessTokensToServers([ server ])
})
- it('Should get logs with a start date', async function () {
- this.timeout(10000)
+ describe('With the standard log file', function () {
+ it('Should get logs with a start date', async function () {
+ this.timeout(10000)
- await uploadVideo(server.url, server.accessToken, { name: 'video 1' })
- await waitJobs([ server ])
+ await uploadVideo(server.url, server.accessToken, { name: 'video 1' })
+ await waitJobs([ server ])
- const now = new Date()
+ const now = new Date()
- await uploadVideo(server.url, server.accessToken, { name: 'video 2' })
- await waitJobs([ server ])
+ await uploadVideo(server.url, server.accessToken, { name: 'video 2' })
+ await waitJobs([ server ])
- const res = await getLogs(server.url, server.accessToken, now)
- const logsString = JSON.stringify(res.body)
+ const res = await getLogs(server.url, server.accessToken, now)
+ const logsString = JSON.stringify(res.body)
- expect(logsString.includes('video 1')).to.be.false
- expect(logsString.includes('video 2')).to.be.true
- })
+ expect(logsString.includes('video 1')).to.be.false
+ expect(logsString.includes('video 2')).to.be.true
+ })
+
+ it('Should get logs with an end date', async function () {
+ this.timeout(20000)
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
+ await waitJobs([ server ])
+
+ const now1 = new Date()
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 4' })
+ await waitJobs([ server ])
+
+ const now2 = new Date()
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 5' })
+ await waitJobs([ server ])
+
+ const res = await getLogs(server.url, server.accessToken, now1, now2)
+ const logsString = JSON.stringify(res.body)
- it('Should get logs with an end date', async function () {
- this.timeout(20000)
+ expect(logsString.includes('video 3')).to.be.false
+ expect(logsString.includes('video 4')).to.be.true
+ expect(logsString.includes('video 5')).to.be.false
+ })
- await uploadVideo(server.url, server.accessToken, { name: 'video 3' })
- await waitJobs([ server ])
+ it('Should get filter by level', async function () {
+ this.timeout(10000)
- const now1 = new Date()
+ const now = new Date()
- await uploadVideo(server.url, server.accessToken, { name: 'video 4' })
- await waitJobs([ server ])
+ await uploadVideo(server.url, server.accessToken, { name: 'video 6' })
+ await waitJobs([ server ])
- const now2 = new Date()
+ {
+ const res = await getLogs(server.url, server.accessToken, now, undefined, 'info')
+ const logsString = JSON.stringify(res.body)
- await uploadVideo(server.url, server.accessToken, { name: 'video 5' })
- await waitJobs([ server ])
+ expect(logsString.includes('video 6')).to.be.true
+ }
- const res = await getLogs(server.url, server.accessToken, now1, now2)
- const logsString = JSON.stringify(res.body)
+ {
+ const res = await getLogs(server.url, server.accessToken, now, undefined, 'warn')
+ const logsString = JSON.stringify(res.body)
- expect(logsString.includes('video 3')).to.be.false
- expect(logsString.includes('video 4')).to.be.true
- expect(logsString.includes('video 5')).to.be.false
+ expect(logsString.includes('video 6')).to.be.false
+ }
+ })
})
- it('Should get filter by level', async function () {
- this.timeout(10000)
+ describe('With the audit log', function () {
+ it('Should get logs with a start date', async function () {
+ this.timeout(10000)
- const now = new Date()
+ await uploadVideo(server.url, server.accessToken, { name: 'video 7' })
+ await waitJobs([ server ])
- await uploadVideo(server.url, server.accessToken, { name: 'video 6' })
- await waitJobs([ server ])
+ const now = new Date()
- {
- const res = await getLogs(server.url, server.accessToken, now, undefined, 'info')
+ await uploadVideo(server.url, server.accessToken, { name: 'video 8' })
+ await waitJobs([ server ])
+
+ const res = await getAuditLogs(server.url, server.accessToken, now)
const logsString = JSON.stringify(res.body)
- expect(logsString.includes('video 6')).to.be.true
- }
+ expect(logsString.includes('video 7')).to.be.false
+ expect(logsString.includes('video 8')).to.be.true
+
+ expect(res.body).to.have.lengthOf(1)
+
+ const item = res.body[0]
+
+ const message = JSON.parse(item.message)
+ expect(message.domain).to.equal('videos')
+ expect(message.action).to.equal('create')
+ })
+
+ it('Should get logs with an end date', async function () {
+ this.timeout(20000)
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 9' })
+ await waitJobs([ server ])
+
+ const now1 = new Date()
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 10' })
+ await waitJobs([ server ])
+
+ const now2 = new Date()
+
+ await uploadVideo(server.url, server.accessToken, { name: 'video 11' })
+ await waitJobs([ server ])
- {
- const res = await getLogs(server.url, server.accessToken, now, undefined, 'warn')
+ const res = await getAuditLogs(server.url, server.accessToken, now1, now2)
const logsString = JSON.stringify(res.body)
- expect(logsString.includes('video 6')).to.be.false
- }
+ expect(logsString.includes('video 9')).to.be.false
+ expect(logsString.includes('video 10')).to.be.true
+ expect(logsString.includes('video 11')).to.be.false
+ })
})
after(async function () {
})
}
+function getAuditLogs (url: string, accessToken: string, startDate: Date, endDate?: Date) {
+ const path = '/api/v1/server/audit-logs'
+
+ return makeGetRequest({
+ url,
+ path,
+ token: accessToken,
+ query: { startDate, endDate },
+ statusCodeExpected: 200
+ })
+}
+
export {
- getLogs
+ getLogs,
+ getAuditLogs
}
-export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
+export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'audit'