Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/handlers/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
* governing permissions and limitations under the License.
*/
import { deleteSource } from '../routes/source.js';
import { deleteComments } from '../routes/comments.js';

export default async function deleteHandler({ req, env, daCtx }) {
const { path } = daCtx;

if (path.startsWith('/source')) return deleteSource({ req, env, daCtx });
if (path.startsWith('/comments')) return deleteComments({ req, env, daCtx });

return undefined;
}
2 changes: 2 additions & 0 deletions src/handlers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { postSource } from '../routes/source.js';
import { postConfig } from '../routes/config.js';
import { postVersionSource } from '../routes/version.js';
import { postComments } from '../routes/comments.js';
import copyHandler from '../routes/copy.js';
import logout from '../routes/logout.js';
import moveRoute from '../routes/move.js';
Expand All @@ -23,6 +24,7 @@ export default async function postHandler({ req, env, daCtx }) {
if (path.startsWith('/source')) return postSource({ req, env, daCtx });
if (path.startsWith('/config')) return postConfig({ req, env, daCtx });
if (path.startsWith('/versionsource')) return postVersionSource({ req, env, daCtx });
if (path.startsWith('/comments')) return postComments({ req, env, daCtx });
if (path.startsWith('/copy')) return copyHandler({ req, env, daCtx });
if (path.startsWith('/move')) return moveRoute({ req, env, daCtx });
if (path.startsWith('/logout')) return logout({ env, daCtx });
Expand Down
163 changes: 163 additions & 0 deletions src/helpers/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

const ANCHOR_TYPES = new Set(['text', 'image', 'table']);
const MAX_BODY_LENGTH = 10 * 1024;

export function parseCommentsPath(path) {
// /comments/{org}/{site}/{docId}/threads[/...]
const parts = path.split('/').filter(Boolean);
if (parts[0] !== 'comments') return null;
const [, org, site, docId, ...rest] = parts;
if (!org || !site || !docId) return null;
return {
org, site, docId, rest,
};
}

export function commentsFileKey(site, docId) {
return `${site}/.da/comments/${docId}.json`;
}

export function getAuthor(daCtx) {
const user = daCtx.users?.[0];
if (!user || user.email === 'anonymous') return null;
return {
id: user.ident ?? user.email,
name: user.name ?? user.email,
email: user.email,
};
}

/**
* Validates a comment write body. Returns `{ ok: true }` on success or
* `{ ok: false }` on failure.
*
* @param body - parsed request body (may be null if JSON parse failed)
* @param requireAnchor - true for new threads, false for replies
*/
export function validateCommentBody(body, { requireAnchor }) {
if (!body
|| typeof body.id !== 'string'
|| typeof body.body !== 'string'
|| !Number.isFinite(body.createdAt)) {
return { ok: false };
}
const trimmed = body.body.trim();
if (trimmed.length === 0 || trimmed.length > MAX_BODY_LENGTH) {
return { ok: false };
}
if (requireAnchor && (!body.anchor || !ANCHOR_TYPES.has(body.anchor.anchorType))) {
return { ok: false };
}
return { ok: true };
}

/*
* Mutator builders. Each returns a `mutate(state)` function suitable for
* `atomicMutation`. The mutator either mutates `state` in place and returns a
* success payload, or returns `{ error, status }` to short-circuit the write.
*/

export function addThreadMutator({
id, anchor, body, createdAt, author,
}) {
return (state) => {
if (state.threads[id]) return { error: 'thread_exists', status: 409 };
// eslint-disable-next-line no-param-reassign
state.threads[id] = {
id,
anchorFrom: anchor.anchorFrom,
anchorTo: anchor.anchorTo,
anchorType: anchor.anchorType,
anchorText: anchor.anchorText ?? '',
author,
body,
createdAt,
resolved: false,
resolvedBy: null,
resolvedAt: null,
reopenedBy: null,
reopenedAt: null,
replies: [],
};
return { id };
};
}

export function addReplyMutator({
threadId, id, body, createdAt, author,
}) {
return (state) => {
const thread = state.threads[threadId];
if (!thread) return { error: 'thread_not_found', status: 404 };
if ((thread.replies ?? []).some((r) => r.id === id)) {
return { error: 'reply_exists', status: 409 };
}
thread.replies = [...(thread.replies ?? []), {
id, author, body, createdAt,
}];
return { id };
};
}

export function resolveThreadMutator({ threadId, actor, now = Date.now() }) {
return (state) => {
const thread = state.threads[threadId];
if (!thread) return { error: 'thread_not_found', status: 404 };
Object.assign(thread, {
resolved: true,
resolvedBy: actor,
resolvedAt: now,
reopenedBy: null,
reopenedAt: null,
});
return thread;
};
}

export function unresolveThreadMutator({ threadId, actor, now = Date.now() }) {
return (state) => {
const thread = state.threads[threadId];
if (!thread) return { error: 'thread_not_found', status: 404 };
Object.assign(thread, {
resolved: false,
resolvedBy: null,
resolvedAt: null,
reopenedBy: actor,
reopenedAt: now,
});
return thread;
};
}

export function deleteThreadMutator({ threadId }) {
return (state) => {
if (!state.threads[threadId]) return { error: 'thread_not_found', status: 404 };
// eslint-disable-next-line no-param-reassign
delete state.threads[threadId];
return {};
};
}

export function deleteReplyMutator({ threadId, replyId }) {
return (state) => {
const thread = state.threads[threadId];
if (!thread) return { error: 'thread_not_found', status: 404 };
const replies = thread.replies ?? [];
if (!replies.some((r) => r.id === replyId)) {
return { error: 'reply_not_found', status: 404 };
}
thread.replies = replies.filter((r) => r.id !== replyId);
return {};
};
}
186 changes: 186 additions & 0 deletions src/routes/comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/*
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { atomicMutation } from '../storage/object/comments.js';
import { hasPermission } from '../utils/auth.js';
import {
parseCommentsPath,
commentsFileKey,
getAuthor,
validateCommentBody,
addThreadMutator,
addReplyMutator,
resolveThreadMutator,
unresolveThreadMutator,
deleteThreadMutator,
deleteReplyMutator,
} from '../helpers/comments.js';

function errResponse(status, error) {
return {
status,
body: JSON.stringify({ error }),
contentType: 'application/json',
};
}

function okResponse(status, payload) {
return {
status,
body: JSON.stringify(payload),
contentType: 'application/json',
};
}

/**
* Common auth gate for all comment write endpoints. Returns either
* - `{ actor, fileKey }` on success, OR
* - `{ response }` with a 401/403 error response if the request should be
* short-circuited.
*/
function checkWriteAuth(daCtx, parsed) {
const fileKey = commentsFileKey(parsed.site, parsed.docId);
if (!hasPermission(daCtx, `/${fileKey}`, 'write')) {
return { response: errResponse(403, 'forbidden') };
}
const actor = getAuthor(daCtx);
if (!actor) return { response: errResponse(401, 'unauthenticated') };
return { actor, fileKey };
}

function mapMutationResult(result, successStatus, successPayload) {
if (!result.ok) return errResponse(result.status ?? 500, result.error ?? 'internal_error');
if (successStatus === 204) return { status: 204 };
return okResponse(successStatus, successPayload ?? result.result);
}

async function addThread({
req, env, daCtx, parsed,
}) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const body = await req.json().catch(() => null);
if (!validateCommentBody(body, { requireAnchor: true }).ok) {
return errResponse(400, 'invalid_body');
}

const result = await atomicMutation(env, parsed.org, auth.fileKey, addThreadMutator({
id: body.id,
anchor: body.anchor,
body: body.body,
createdAt: body.createdAt,
author: auth.actor,
}));
return mapMutationResult(result, 201);
}

async function addReply({
req, env, daCtx, parsed,
}) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const body = await req.json().catch(() => null);
if (!validateCommentBody(body, { requireAnchor: false }).ok) {
return errResponse(400, 'invalid_body');
}

const result = await atomicMutation(env, parsed.org, auth.fileKey, addReplyMutator({
threadId: parsed.rest[1],
id: body.id,
body: body.body,
createdAt: body.createdAt,
author: auth.actor,
}));
return mapMutationResult(result, 201);
}

async function resolveThread({ env, daCtx, parsed }) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const result = await atomicMutation(env, parsed.org, auth.fileKey, resolveThreadMutator({
threadId: parsed.rest[1],
actor: auth.actor,
}));
return mapMutationResult(result, 200);
}

async function unresolveThread({ env, daCtx, parsed }) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const result = await atomicMutation(env, parsed.org, auth.fileKey, unresolveThreadMutator({
threadId: parsed.rest[1],
actor: auth.actor,
}));
return mapMutationResult(result, 200);
}

async function deleteThread({ env, daCtx, parsed }) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const result = await atomicMutation(env, parsed.org, auth.fileKey, deleteThreadMutator({
threadId: parsed.rest[1],
}));
return mapMutationResult(result, 204);
}

async function deleteReply({ env, daCtx, parsed }) {
const auth = checkWriteAuth(daCtx, parsed);
if (auth.response) return auth.response;

const result = await atomicMutation(env, parsed.org, auth.fileKey, deleteReplyMutator({
threadId: parsed.rest[1],
replyId: parsed.rest[3],
}));
return mapMutationResult(result, 204);
}

export async function postComments({ req, env, daCtx }) {
const parsed = parseCommentsPath(daCtx.path);
if (!parsed) return errResponse(400, 'invalid_path');

const { rest } = parsed;
if (rest.length === 1 && rest[0] === 'threads') {
return addThread({
req, env, daCtx, parsed,
});
}
if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'replies') {
return addReply({
req, env, daCtx, parsed,
});
}
if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'resolve') {
return resolveThread({ env, daCtx, parsed });
}
if (rest.length === 3 && rest[0] === 'threads' && rest[2] === 'unresolve') {
return unresolveThread({ env, daCtx, parsed });
}
return errResponse(404, 'unknown_endpoint');
}

export async function deleteComments({ env, daCtx }) {
const parsed = parseCommentsPath(daCtx.path);
if (!parsed) return errResponse(400, 'invalid_path');

const { rest } = parsed;
if (rest.length === 2 && rest[0] === 'threads') {
return deleteThread({ env, daCtx, parsed });
}
if (rest.length === 4 && rest[0] === 'threads' && rest[2] === 'replies') {
return deleteReply({ env, daCtx, parsed });
}
return errResponse(404, 'unknown_endpoint');
}
Loading