fix(news): enforce shared comment publication and moderation rules
This commit is contained in:
1 parent
8aefb415b6
commit
fe34d4ac93
7 files changed
+548
-120
No files matched your search
@@ -28,4 +28,4 @@ The additional `withNitroStaff` code and its tests mentioned in the supplied por
|
|||||||
|
|
||||||
## Follow-up identified during verification
|
## Follow-up identified during verification
|
||||||
|
|
||||||
The article-comment REST endpoint needs a separate review of publication visibility and moderation parity with the website form. This was discovered while enumerating bearer consumers; it is not silently treated as covered by the supplied review.
|
The separate comment review is now implemented: both the website form and REST API use one submission service, require a published article whose publication time is due, apply the same moderation, and share a five-attempt/30-second per-user quota. The publication check locks the current article in the insertion transaction. Regression tests cover both entrypoints; real MariaDB/Redis coverage includes publication eligibility, word filtering and alternating submissions. Moderation retains its existing fail-open behavior on service outages. Form input beyond 255 characters is now rejected instead of truncated, and temporary API storage failures return 503. Real integration execution remains a required CI check.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
||||||
|
|
||||||
Fourteen tests exercise the production database commands, news actions, public article query and delivery worker against MariaDB 11.4.5 and Redis 7.4.2:
|
Sixteen database tests exercise the production database commands, news actions, public article query and delivery worker against MariaDB 11.4.5 and Redis 7.4.2:
|
||||||
|
|
||||||
- Migration CLI replay/status, committed catalog bulk edits and undo history, complete rollback after an audit insert fails, and competing catalog previews.
|
- Migration CLI replay/status, committed catalog bulk edits and undo history, complete rollback after an audit insert fails, and competing catalog previews.
|
||||||
- Real Redis expiry metadata and cache-key isolation, concurrent request idempotency, operation/outbox rollback, and exclusive delivery claims.
|
- Real Redis expiry metadata and cache-key isolation, concurrent request idempotency, operation/outbox rollback, and exclusive delivery claims.
|
||||||
@@ -20,4 +20,4 @@ The fixture models the emulator's catalog/audit baseline plus the legacy article
|
|||||||
|
|
||||||
These are application-service integration tests, not browser or HTTP end-to-end tests: they do not start a Next.js server, render the news page, verify login/ACL behavior, or send external webhooks. The cache outage test closes and restores the actual application Redis connection; it does not stop the Redis server or model a multi-host network partition. Passing TypeScript or unit tests without Docker is not a passing result for this suite.
|
These are application-service integration tests, not browser or HTTP end-to-end tests: they do not start a Next.js server, render the news page, verify login/ACL behavior, or send external webhooks. The cache outage test closes and restores the actual application Redis connection; it does not stop the Redis server or model a multi-host network partition. Passing TypeScript or unit tests without Docker is not a passing result for this suite.
|
||||||
|
|
||||||
Public comment pagination and reaction aggregation are covered by unit tests using the production Drizzle query builder with a substituted database transport, plus server-rendered page tests with substituted service results. This integration fixture does not create the users, article-comments or article-reactions tables and does not execute those two queries against MariaDB. The article-cache upgrade test verifies that legacy cached absence is ignored under the versioned key; it is a unit test, separate from the real Redis publication and delivery checks above.
|
Public comment pagination and reaction aggregation are covered by unit tests using the production Drizzle query builder with a substituted database transport, plus server-rendered page tests with substituted service results. This integration fixture does not create users or article-reactions tables and does not execute the public pagination and aggregation queries against MariaDB; its article-comments table is used for submission tests. Comment submission now additionally uses real MariaDB and Redis to verify publication eligibility, filtering and the shared quota across the actual form/API handlers, with authentication replaced at the boundary. The article-cache upgrade test verifies that legacy cached absence is ignored under the versioned key; it is a unit test, separate from the real Redis publication and delivery checks above.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,60 +1,52 @@
|
|||||||
// Public REST API — post a comment on an article as the Bearer-authed user.
|
// Public REST API: comments belong to the authenticated Bearer token's user.
|
||||||
//
|
import { apiError, apiJson, apiUnavailable } from "@/lib/api";
|
||||||
// POST /api/articles/:slug/comment — looks up the website_article by slug for
|
|
||||||
// its id, then inserts a website_article_comments row owned by the user behind
|
|
||||||
// the Authorization: Bearer token. Comment is required, non-empty, max 255
|
|
||||||
// chars (matches the VARCHAR(255) column). Fails soft — never returns a 500 for
|
|
||||||
// DB issues, just a generic error envelope.
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { apiError, apiJson } from "@/lib/api";
|
|
||||||
import { bearerUserId } from "@/lib/api-auth";
|
import { bearerUserId } from "@/lib/api-auth";
|
||||||
import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db";
|
import { logger } from "@/lib/logger";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { submitArticleComment } from "@/lib/services/article-comment-submission";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: Request,
|
req: Request,
|
||||||
{ params }: { params: Promise<{ slug: string }> },
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
const uid = await bearerUserId(req, ["articles:write"]);
|
const uid = await bearerUserId(req, ["articles:write"]);
|
||||||
if (!uid) return apiError("Unauthorized", 401);
|
if (!uid) return apiError("Unauthorized", 401);
|
||||||
|
|
||||||
if (!(await rateLimit(`article-comment:${uid}`, 10, 60_000)).ok) {
|
|
||||||
return apiError("Too many comments. Please wait a minute.", 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const body: unknown = await req.json().catch(() => null);
|
||||||
const body = (await req.json().catch(() => ({}))) as { comment?: unknown };
|
const result = await submitArticleComment({
|
||||||
const comment = typeof body.comment === "string" ? body.comment.trim() : "";
|
userId: uid,
|
||||||
if (!comment) {
|
target: { slug },
|
||||||
|
comment:
|
||||||
|
body && typeof body === "object" && "comment" in body
|
||||||
|
? body.comment
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
if (result.ok) return apiJson({ ok: true });
|
||||||
|
switch (result.reason) {
|
||||||
|
case "ratelimit": {
|
||||||
|
const response = apiError(
|
||||||
|
"Too many comments. Please wait a minute.",
|
||||||
|
429,
|
||||||
|
);
|
||||||
|
response.headers.set("Retry-After", String(result.retryAfter ?? 30));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
case "empty":
|
||||||
return apiError("Comment is required", 422);
|
return apiError("Comment is required", 422);
|
||||||
}
|
case "too_long":
|
||||||
if (comment.length > 255) {
|
|
||||||
return apiError("Comment may not be longer than 255 characters", 422);
|
return apiError("Comment may not be longer than 255 characters", 422);
|
||||||
}
|
case "moderated":
|
||||||
|
return apiError("Comment was blocked by moderation", 422);
|
||||||
try {
|
case "invalid":
|
||||||
const [article] = await db
|
case "not_found":
|
||||||
.select({ id: WebsiteArticles.id })
|
|
||||||
.from(WebsiteArticles)
|
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
|
||||||
.limit(1);
|
|
||||||
if (!article) {
|
|
||||||
return apiError("Article not found", 404);
|
return apiError("Article not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await db.insert(WebsiteArticleComments).values({
|
|
||||||
articleId: article.id,
|
|
||||||
userId: uid,
|
|
||||||
comment,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
return apiJson({ ok: true });
|
|
||||||
} catch {
|
} catch {
|
||||||
return apiError("Could not post comment", 400);
|
// Driver errors may contain SQL and user content; log a safe diagnostic.
|
||||||
|
logger.error("Article comment submission failed", {
|
||||||
|
module: "article-comments",
|
||||||
|
channel: "api",
|
||||||
|
});
|
||||||
|
return apiUnavailable("Could not post comment");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { and, eq, or, sql } from "drizzle-orm";
|
||||||
|
import { db, WebsiteArticleComments, WebsiteArticles } from "@/lib/db";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { isAllowed } from "@/lib/services/moderation";
|
||||||
|
|
||||||
|
export type ArticleCommentTarget = { id: string } | { slug: string };
|
||||||
|
export type ArticleCommentResult =
|
||||||
|
| { ok: true; slug: string }
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason:
|
||||||
|
| "empty"
|
||||||
|
| "too_long"
|
||||||
|
| "invalid"
|
||||||
|
| "moderated"
|
||||||
|
| "ratelimit"
|
||||||
|
| "not_found";
|
||||||
|
retryAfter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Authenticated callers share the same quota and uncached publication check. */
|
||||||
|
export async function submitArticleComment({
|
||||||
|
userId,
|
||||||
|
target,
|
||||||
|
comment: rawComment,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
target: ArticleCommentTarget;
|
||||||
|
comment: unknown;
|
||||||
|
}): Promise<ArticleCommentResult> {
|
||||||
|
if (!Number.isSafeInteger(userId) || userId <= 0)
|
||||||
|
return { ok: false, reason: "invalid" };
|
||||||
|
// Preserve the site's five-attempt burst; API and form consume one bucket.
|
||||||
|
const limit = await rateLimit(`comment:${userId}`, 5, 30_000);
|
||||||
|
if (!limit.ok)
|
||||||
|
return { ok: false, reason: "ratelimit", retryAfter: limit.retryAfter };
|
||||||
|
|
||||||
|
const comment =
|
||||||
|
typeof rawComment === "string" ? rawComment.normalize("NFC").trim() : "";
|
||||||
|
if (!comment) return { ok: false, reason: "empty" };
|
||||||
|
if (comment.length > 255) return { ok: false, reason: "too_long" };
|
||||||
|
|
||||||
|
let articleId = 0n;
|
||||||
|
if ("id" in target) {
|
||||||
|
if (!/^\d{1,20}$/.test(target.id)) return { ok: false, reason: "invalid" };
|
||||||
|
articleId = BigInt(target.id);
|
||||||
|
if (articleId <= 0n || articleId > 18446744073709551615n)
|
||||||
|
return { ok: false, reason: "invalid" };
|
||||||
|
} else if (!target.slug || target.slug.length > 255) {
|
||||||
|
return { ok: false, reason: "invalid" };
|
||||||
|
}
|
||||||
|
if (!(await isAllowed(comment)).ok) return { ok: false, reason: "moderated" };
|
||||||
|
|
||||||
|
// Moderate before locking: the optional remote check can take several seconds.
|
||||||
|
// A locking read sees current publication state and serializes withdrawal/deletion
|
||||||
|
// with this insert, rather than trusting a prior page or cached article lookup.
|
||||||
|
return db.transaction(async (tx): Promise<ArticleCommentResult> => {
|
||||||
|
const [article] = await tx
|
||||||
|
.select({ id: WebsiteArticles.id, slug: WebsiteArticles.slug })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
"slug" in target
|
||||||
|
? eq(WebsiteArticles.slug, target.slug)
|
||||||
|
: eq(WebsiteArticles.id, articleId),
|
||||||
|
eq(WebsiteArticles.status, "published"),
|
||||||
|
or(
|
||||||
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
||||||
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.for("update");
|
||||||
|
if (!article) return { ok: false, reason: "not_found" };
|
||||||
|
const now = new Date();
|
||||||
|
await tx.insert(WebsiteArticleComments).values({
|
||||||
|
articleId: article.id,
|
||||||
|
userId,
|
||||||
|
comment,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return { ok: true, slug: article.slug };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user