fix: decduck's code review

This commit is contained in:
DecDuck
2025-03-10 11:39:45 +11:00
parent 31aaec74af
commit 1ce707788d
17 changed files with 274 additions and 94 deletions

View File

@ -0,0 +1,52 @@
/*
Warnings:
- You are about to drop the `news` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "news" DROP CONSTRAINT "news_authorId_fkey";
-- DropTable
DROP TABLE "news";
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Article" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"content" TEXT NOT NULL,
"image" TEXT,
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT,
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_ArticleToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ArticleToTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B");
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[token]` on the table `APIToken` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "APIToken_token_key" ON "APIToken"("token");

View File

@ -29,7 +29,7 @@ enum APITokenMode {
model APIToken { model APIToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @default(uuid()) token String @default(uuid()) @unique
mode APITokenMode mode APITokenMode
name String name String

View File

@ -1,13 +1,21 @@
model News { model Tag {
id String @id @default(uuid()) id String @id @default(uuid())
title String name String @unique
content String @db.Text
excerpt String
tags String[]
image String?
publishedAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
authorId String
@@map("news") articles Article[]
}
model Article {
id String @id @default(uuid())
title String
description String
content String @db.Text
tags Tag[]
image String? // Object ID
publishedAt DateTime @default(now())
author User? @relation(fields: [authorId], references: [id]) // Optional, if no user, it's a system post
authorId String?
} }

View File

@ -6,7 +6,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["prismaSchemaFolder", "omitApi"] previewFeatures = ["prismaSchemaFolder", "omitApi", "fullTextSearchPostgres"]
} }
datasource db { datasource db {

View File

@ -11,7 +11,7 @@ model User {
clients Client[] clients Client[]
notifications Notification[] notifications Notification[]
collections Collection[] collections Collection[]
news News[] articles Article[]
tokens APIToken[] tokens APIToken[]
} }

View File

@ -0,0 +1,27 @@
import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
if (!allowed)
throw createError({
statusCode: 403,
});
const id = h3.context.params?.id;
if (!id)
throw createError({
statusCode: 400,
message: "Missing news ID",
});
const news = await newsManager.fetchById(id);
if (!news)
throw createError({
statusCode: 404,
message: "News article not found",
});
return news;
});

View File

@ -0,0 +1,36 @@
import { defineEventHandler, getQuery } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["news:read"]);
if (!allowed)
throw createError({
statusCode: 403,
});
const query = getQuery(h3);
const orderBy = query.order as "asc" | "desc";
if (orderBy) {
if (typeof orderBy !== "string" || !["asc", "desc"].includes(orderBy))
throw createError({ statusCode: 400, statusMessage: "Invalid order" });
}
const tags = query.tags as string[] | undefined;
if (tags) {
if (typeof tags !== "object" || !Array.isArray(tags))
throw createError({ statusCode: 400, statusMessage: "Invalid tags" });
}
const options = {
take: parseInt(query.limit as string),
skip: parseInt(query.skip as string),
orderBy: orderBy,
tags: tags?.map((e) => e.toString()),
search: query.search as string,
};
const news = await newsManager.fetch(options);
return news;
});

View File

@ -1,21 +1,20 @@
import { defineEventHandler, createError, readBody } from "h3"; import { defineEventHandler, createError, readBody } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (h3) => {
const body = await readBody(event); const allowed = await aclManager.allowSystemACL(h3, ["news:create"]);
if (!allowed) throw createError({ statusCode: 403 });
if (!body.authorId) { const body = await readBody(h3);
throw createError({
statusCode: 400,
message: 'Author ID is required'
});
}
const article = await newsManager.create({ const article = await newsManager.create({
title: body.title, title: body.title,
description: body.description,
content: body.content, content: body.content,
excerpt: body.excerpt,
tags: body.tags, tags: body.tags,
image: body.image, image: body.image,
authorId: body.authorId, authorId: body.authorId,
}); });

View File

@ -93,7 +93,7 @@ export default defineEventHandler(async (h3) => {
profilePictureId, profilePictureId,
async () => jdenticon.toPng(username, 256), async () => jdenticon.toPng(username, 256),
{}, {},
[`anonymous:read`, `${userId}:write`] [`internal:read`, `${userId}:write`]
); );
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {

View File

@ -1,22 +1,30 @@
import { defineEventHandler, createError } from "h3"; import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (h3) => {
const id = event.context.params?.id; const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!id) { if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const id = h3.context.params?.id;
if (!id)
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: "Missing news ID", message: "Missing news ID",
}); });
}
const news = await newsManager.getById(id);
if (!news) { const news = await newsManager.fetchById(id);
if (!news)
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
message: "News article not found", message: "News article not found",
}); });
}
return news; return news;
}); });

View File

@ -1,17 +1,37 @@
import { defineEventHandler, getQuery } from "h3"; import { defineEventHandler, getQuery } from "h3";
import aclManager from "~/server/internal/acls";
import newsManager from "~/server/internal/news"; import newsManager from "~/server/internal/news";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (h3) => {
const query = getQuery(event); const userId = await aclManager.getUserIdACL(h3, ["news:read"]);
if (!userId)
throw createError({
statusCode: 403,
statusMessage: "Requires authentication",
});
const query = getQuery(h3);
const orderBy = query.order as "asc" | "desc";
if (orderBy) {
if (typeof orderBy !== "string" || !["asc", "desc"].includes(orderBy))
throw createError({ statusCode: 400, statusMessage: "Invalid order" });
}
const tags = query.tags as string[] | undefined;
if (tags) {
if (typeof tags !== "object" || !Array.isArray(tags))
throw createError({ statusCode: 400, statusMessage: "Invalid tags" });
}
const options = { const options = {
take: query.limit ? parseInt(query.limit as string) : undefined, take: parseInt(query.limit as string),
skip: query.skip ? parseInt(query.skip as string) : undefined, skip: parseInt(query.skip as string),
orderBy: query.order as 'asc' | 'desc', orderBy: orderBy,
tags: query.tags ? (query.tags as string).split(',') : undefined, tags: tags?.map((e) => e.toString()),
search: query.search as string, search: query.search as string,
}; };
const news = await newsManager.getAll(options); const news = await newsManager.fetch(options);
return news; return news;
}); });

View File

@ -30,6 +30,8 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"Remove a game from any collection (excluding library).", "Remove a game from any collection (excluding library).",
"library:add": "Add a game to your library.", "library:add": "Add a game to your library.",
"library:remove": "Remove a game from your library.", "library:remove": "Remove a game from your library.",
"news:read": "Read the server's news articles.",
}; };
export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = { export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
@ -55,4 +57,8 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"import:game:new": "Import a game.", "import:game:new": "Import a game.",
"user:read": "Fetch any user's information.", "user:read": "Fetch any user's information.",
"news:read": "Read news articles.",
"news:create": "Create a new news article.",
"news:delete": "Delete a news article."
}; };

View File

@ -53,6 +53,10 @@ export const systemACLs = [
"import:game:new", "import:game:new",
"user:read", "user:read",
"news:read",
"news:create",
"news:delete",
] as const; ] as const;
const systemACLPrefix = "system:"; const systemACLPrefix = "system:";

View File

@ -1,70 +1,72 @@
import { triggerAsyncId } from "async_hooks";
import prisma from "../db/database"; import prisma from "../db/database";
class NewsManager { class NewsManager {
async create(data: { async create(data: {
title: string; title: string;
content: string; content: string;
excerpt: string; description: string;
tags: string[]; tags: string[];
authorId: string; authorId: string;
image?: string; image?: string;
}) { }) {
return await prisma.news.create({ return await prisma.article.create({
data: { data: {
title: data.title, title: data.title,
description: data.description,
content: data.content, content: data.content,
excerpt: data.excerpt,
tags: data.tags, tags: {
connectOrCreate: data.tags.map((e) => ({
where: { name: e },
create: { name: e },
})),
},
image: data.image, image: data.image,
author: { author: {
connect: { connect: {
id: data.authorId, id: data.authorId,
}, },
}, },
publishedAt: new Date(),
}, },
}); });
} }
async getAll(options?: { async fetch(
take?: number; options: {
skip?: number; take?: number;
orderBy?: 'asc' | 'desc'; skip?: number;
tags?: string[]; orderBy?: "asc" | "desc";
search?: string; tags?: string[];
}) { search?: string;
const where = { } = {}
AND: [ ) {
options?.tags?.length ? { return await prisma.article.findMany({
tags: { where: {
hasSome: options.tags, AND: [
{
tags: {
some: { OR: options.tags?.map((e) => ({ name: e })) ?? [] },
},
}, },
} : {}, {
options?.search ? { title: {
OR: [ search: options.search
{
title: {
contains: options.search,
mode: 'insensitive' as const,
},
}, },
{ description: {
content: { search: options.search
contains: options.search,
mode: 'insensitive' as const,
},
}, },
], content: {
} : {}, search: options.search
], }
}; }
],
return await prisma.news.findMany({ },
where, take: options?.take || 10,
take: options?.take, skip: options?.skip || 0,
skip: options?.skip,
orderBy: { orderBy: {
publishedAt: options?.orderBy || 'desc', publishedAt: options?.orderBy || "desc",
}, },
include: { include: {
author: { author: {
@ -77,8 +79,8 @@ class NewsManager {
}); });
} }
async getById(id: string) { async fetchById(id: string) {
return await prisma.news.findUnique({ return await prisma.article.findUnique({
where: { id }, where: { id },
include: { include: {
author: { author: {
@ -91,21 +93,23 @@ class NewsManager {
}); });
} }
async update(id: string, data: { async update(
title?: string; id: string,
content?: string; data: {
excerpt?: string; title?: string;
tags?: string[]; content?: string;
image?: string; excerpt?: string;
}) { image?: string;
return await prisma.news.update({ }
) {
return await prisma.article.update({
where: { id }, where: { id },
data, data,
}); });
} }
async delete(id: string) { async delete(id: string) {
return await prisma.news.delete({ return await prisma.article.delete({
where: { id }, where: { id },
}); });
} }