Skip to content

Commit 873a792

Browse files
feat: implement log route (#38)
1 parent c0ff4df commit 873a792

File tree

15 files changed

+1207
-26
lines changed

15 files changed

+1207
-26
lines changed

src/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import contentproxy from './contentproxy/handler.js';
2121
import discover from './discover/handler.js';
2222
import index from './index/handler.js';
2323
import live from './live/handler.js';
24+
import log from './log/handler.js';
2425
import { auth, login, logout } from './login/handler.js';
2526
import media from './media/handler.js';
2627
import preview from './preview/handler.js';
@@ -81,6 +82,7 @@ export const router = new Router(nameSelector)
8182
.add('/:org/sites/:site/contentproxy/*', contentproxy)
8283
.add('/:org/sites/:site/preview/*', preview)
8384
.add('/:org/sites/:site/live/*', live)
85+
.add('/:org/sites/:site/log', log)
8486
.add('/:org/sites/:site/login', login)
8587
.add('/:org/sites/:site/media/*', media)
8688
.add('/:org/sites/:site/code/:ref/*', code)
@@ -89,8 +91,7 @@ export const router = new Router(nameSelector)
8991
.add('/:org/sites/:site/sitemap/*', sitemap)
9092
.add('/:org/sites/:site/snapshots/*', notImplemented)
9193
.add('/:org/sites/:site/source/*', notImplemented)
92-
.add('/:org/sites/:site/jobs', notImplemented)
93-
.add('/:org/sites/:site/log', notImplemented);
94+
.add('/:org/sites/:site/jobs', notImplemented);
9495

9596
/**
9697
* Main entry point.

src/log/DateFormat.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at https://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
export default class DateFormat {
13+
static parse(s) {
14+
const chars = [...s.substring(0, 19)];
15+
chars[10] = 'T';
16+
chars[13] = ':';
17+
chars[16] = ':';
18+
chars.push('Z');
19+
return new Date(chars.join(''));
20+
}
21+
22+
static format(date) {
23+
return date.toISOString().substring(0, 19).replace(/[T:]/g, '-');
24+
}
25+
}

src/log/add.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at https://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { Response } from '@adobe/fetch';
13+
import { errorResponse } from '../support/utils.js';
14+
import { AuditBatch } from '../support/audit.js';
15+
16+
/**
17+
* Add entries to an audit log.
18+
*
19+
* @param {import('./AdminContext.js').AdminContext} context context
20+
* @param {import('./RequestInfo.js').RequestInfo} info request info
21+
*
22+
* @returns {Promise<Response>} response
23+
*/
24+
export default async function add(context, info) {
25+
const { attributes: { authInfo }, contentBusId, log } = context;
26+
27+
const { data: { entries } } = context;
28+
if (!entries || !Array.isArray(entries)) {
29+
return errorResponse(log, 400, 'Adding logs requires an array in \'entries\'');
30+
}
31+
if (entries.length > 10) {
32+
return errorResponse(log, 400, 'Array in \'entries\' should not contain more than 10 messages');
33+
}
34+
35+
const batch = new AuditBatch(info);
36+
const user = authInfo.resolveEmail();
37+
38+
entries.forEach((entry) => {
39+
const notification = {
40+
...entry,
41+
timestamp: Date.now(),
42+
};
43+
if (contentBusId) {
44+
notification.contentBusId = contentBusId;
45+
}
46+
if (user) {
47+
notification.user = user;
48+
}
49+
batch.addNotification(info, notification);
50+
});
51+
await batch.send(context);
52+
53+
return new Response('', {
54+
status: 201,
55+
headers: {
56+
'content-type': 'text/plain; charset=utf-8',
57+
},
58+
});
59+
}

src/log/handler.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at https://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { Response } from '@adobe/fetch';
13+
import add from './add.js';
14+
import query from './query.js';
15+
16+
/**
17+
* Allowed methods for that handler
18+
*/
19+
const ALLOWED_METHODS = ['GET', 'POST'];
20+
21+
/**
22+
* Handles the log route
23+
*
24+
* @param {import('../support/AdminContext').AdminContext} context context
25+
* @param {import('../support/RequestInfo').RequestInfo} info request info
26+
* @returns {Promise<Response>} response
27+
*/
28+
export default async function logHandler(context, info) {
29+
const { attributes: { authInfo } } = context;
30+
31+
if (ALLOWED_METHODS.indexOf(info.method) < 0) {
32+
return new Response('method not allowed', {
33+
status: 405,
34+
});
35+
}
36+
37+
authInfo.assertPermissions('log:read');
38+
if (info.method === 'GET') {
39+
return query(context, info);
40+
}
41+
42+
authInfo.assertPermissions('log:write');
43+
return add(context, info);
44+
}

src/log/query.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at https://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { Response } from '@adobe/fetch';
13+
import { AuditLog } from '@adobe/helix-admin-support';
14+
import { errorResponse } from '../support/utils.js';
15+
import {
16+
decode, encode, getNextLinkUrl, parseIntWithCond, parseTimespan,
17+
} from './utils.js';
18+
19+
/**
20+
* Total size of collected entries in log, when stringified.
21+
*/
22+
export const MAX_ENTRIES_SIZE = 3_000_000;
23+
24+
/**
25+
* Query an audit log
26+
*
27+
* @param {import('./AdminContext.js').AdminContext} context context
28+
* @param {import('./RequestInfo.js').RequestInfo} info request info
29+
* @returns {Promise<Response>} response
30+
*/
31+
export default async function query(context, info) {
32+
const {
33+
log, data: {
34+
from: fromS, to: toS, since: sinceS, limit: limitS, nextToken,
35+
},
36+
} = context;
37+
38+
let from;
39+
let to;
40+
41+
try {
42+
([from, to] = parseTimespan(fromS, toS, sinceS));
43+
} catch (e) {
44+
return errorResponse(log, 400, e.message);
45+
}
46+
47+
const limit = parseIntWithCond(limitS, (value) => {
48+
if (value >= 1 && value <= 1000) {
49+
return true;
50+
}
51+
log.warn(`'limit' should be between 1 and 1000: ' ${value}`);
52+
return false;
53+
}, 1000);
54+
55+
const { org, site } = info;
56+
const auditLog = AuditLog.createReader(org, site, log);
57+
58+
try {
59+
await auditLog.init();
60+
61+
const { entries, next } = await auditLog.getEntries(
62+
from,
63+
to,
64+
{ limit, maxSize: MAX_ENTRIES_SIZE },
65+
decode(nextToken),
66+
);
67+
const result = {
68+
from: new Date(from).toISOString(),
69+
to: new Date(to).toISOString(),
70+
entries,
71+
};
72+
if (next) {
73+
result.nextToken = encode(next);
74+
result.links = {
75+
next: getNextLinkUrl(info, {
76+
from: result.from,
77+
to: result.to,
78+
limit: limitS,
79+
nextToken: result.nextToken,
80+
}),
81+
};
82+
}
83+
return new Response(JSON.stringify(result), {
84+
status: 200,
85+
headers: {
86+
'content-type': 'application/json',
87+
},
88+
});
89+
} finally {
90+
auditLog.close();
91+
}
92+
}

src/log/utils.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at https://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
export function decode(nextToken) {
14+
try {
15+
if (nextToken) {
16+
return JSON.parse(Buffer.from(nextToken, 'base64'));
17+
}
18+
} catch (e) {
19+
/* ignore */
20+
}
21+
return null;
22+
}
23+
24+
export function encode(next) {
25+
return Buffer.from(JSON.stringify(next)).toString('base64');
26+
}
27+
28+
export function parseIntWithCond(s, cond, defaultValue) {
29+
if (s) {
30+
const value = Number.parseInt(s, 10);
31+
if (!Number.isNaN(value) && cond(value)) {
32+
return value;
33+
}
34+
}
35+
return defaultValue;
36+
}
37+
38+
export function getNextLinkUrl(info, query) {
39+
return info.getLinkUrl(info.suffix, Object.entries(query).reduce((o, [k, v]) => {
40+
if (v) {
41+
// eslint-disable-next-line no-param-reassign
42+
o[k] = v;
43+
}
44+
return o;
45+
}, {}));
46+
}
47+
48+
const UNITS = {
49+
s: (v) => v,
50+
m: (v) => v * 60,
51+
h: (v) => v * 3600,
52+
d: (v) => v * 3600 * 24,
53+
};
54+
55+
const FIFTEEN_MINUTES_MS = 15 * 60 * 1000;
56+
57+
export function parseTimespan(fromS, toS, sinceS) {
58+
const now = Date.now();
59+
60+
if (sinceS) {
61+
if (fromS || toS) {
62+
throw new Error('\'since\' should not be used with either \'from\' or \'to\'');
63+
}
64+
const match = /^(?<duration>[0-9]+)(?<unit>s|m|h|d)$/.exec(sinceS);
65+
if (!match) {
66+
throw new Error(`'since' should match a number followed by 's(econds)', 'm(inutes)', 'h(ours)' or 'd(ays)': ${sinceS}`);
67+
}
68+
const { duration, unit } = match.groups;
69+
const sinceMs = UNITS[unit](Number.parseInt(duration, 10)) * 1000;
70+
return [now - sinceMs, now];
71+
}
72+
73+
let from;
74+
if (!fromS) {
75+
from = now - FIFTEEN_MINUTES_MS;
76+
} else {
77+
from = Date.parse(fromS);
78+
if (!from) {
79+
throw new Error(`'from' is not a valid date: ${fromS}`);
80+
}
81+
}
82+
let to;
83+
if (!toS) {
84+
to = now;
85+
} else {
86+
to = Date.parse(toS);
87+
if (!to) {
88+
throw new Error(`'to' is not a valid date: ${toS}`);
89+
}
90+
}
91+
if (from >= to) {
92+
throw new Error(`'from' (${fromS || new Date(from).toISOString()}) should be smaller than 'to' (${toS || new Date(to).toISOString()})`);
93+
}
94+
return [from, to];
95+
}

0 commit comments

Comments
 (0)