diff --git a/prisma/migrations/20250309234300_news_articles/migration.sql b/prisma/migrations/20250309234300_news_articles/migration.sql new file mode 100644 index 0000000..23a25c0 --- /dev/null +++ b/prisma/migrations/20250309234300_news_articles/migration.sql @@ -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; diff --git a/prisma/migrations/20250309234801_make_tags_unique/migration.sql b/prisma/migrations/20250309234801_make_tags_unique/migration.sql new file mode 100644 index 0000000..918aee1 --- /dev/null +++ b/prisma/migrations/20250309234801_make_tags_unique/migration.sql @@ -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"); diff --git a/prisma/migrations/20250309234846_make_tokens_unique/migration.sql b/prisma/migrations/20250309234846_make_tokens_unique/migration.sql new file mode 100644 index 0000000..a09d63c --- /dev/null +++ b/prisma/migrations/20250309234846_make_tokens_unique/migration.sql @@ -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"); diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma index b321ac5..ac3b5d0 100644 --- a/prisma/schema/auth.prisma +++ b/prisma/schema/auth.prisma @@ -29,7 +29,7 @@ enum APITokenMode { model APIToken { id String @id @default(uuid()) - token String @default(uuid()) + token String @default(uuid()) @unique mode APITokenMode name String diff --git a/prisma/schema/news.prisma b/prisma/schema/news.prisma index dd2bde6..d9ef63c 100644 --- a/prisma/schema/news.prisma +++ b/prisma/schema/news.prisma @@ -1,13 +1,21 @@ -model News { - id String @id @default(uuid()) - title String - content String @db.Text - excerpt String - tags String[] - image String? - publishedAt DateTime @default(now()) - author User @relation(fields: [authorId], references: [id]) - authorId String +model Tag { + id String @id @default(uuid()) + name String @unique - @@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? +} diff --git a/prisma/schema/schema.prisma b/prisma/schema/schema.prisma index 37dc089..3d004c4 100644 --- a/prisma/schema/schema.prisma +++ b/prisma/schema/schema.prisma @@ -6,7 +6,7 @@ generator client { provider = "prisma-client-js" - previewFeatures = ["prismaSchemaFolder", "omitApi"] + previewFeatures = ["prismaSchemaFolder", "omitApi", "fullTextSearchPostgres"] } datasource db { diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 30173fe..a664f50 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -11,7 +11,7 @@ model User { clients Client[] notifications Notification[] collections Collection[] - news News[] + articles Article[] tokens APIToken[] } diff --git a/server/api/v1/admin/news/[id].delete.ts b/server/api/v1/admin/news/[id]/index.delete.ts similarity index 100% rename from server/api/v1/admin/news/[id].delete.ts rename to server/api/v1/admin/news/[id]/index.delete.ts diff --git a/server/api/v1/admin/news/[id]/index.get.ts b/server/api/v1/admin/news/[id]/index.get.ts new file mode 100644 index 0000000..50161e3 --- /dev/null +++ b/server/api/v1/admin/news/[id]/index.get.ts @@ -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; +}); diff --git a/server/api/v1/admin/news/index.get.ts b/server/api/v1/admin/news/index.get.ts new file mode 100644 index 0000000..f314917 --- /dev/null +++ b/server/api/v1/admin/news/index.get.ts @@ -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; +}); diff --git a/server/api/v1/admin/news/index.post.ts b/server/api/v1/admin/news/index.post.ts index 61c4d92..16afed1 100644 --- a/server/api/v1/admin/news/index.post.ts +++ b/server/api/v1/admin/news/index.post.ts @@ -1,21 +1,20 @@ import { defineEventHandler, createError, readBody } from "h3"; +import aclManager from "~/server/internal/acls"; import newsManager from "~/server/internal/news"; -export default defineEventHandler(async (event) => { - const body = await readBody(event); - - if (!body.authorId) { - throw createError({ - statusCode: 400, - message: 'Author ID is required' - }); - } +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["news:create"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readBody(h3); const article = await newsManager.create({ title: body.title, + description: body.description, content: body.content, - excerpt: body.excerpt, + tags: body.tags, + image: body.image, authorId: body.authorId, }); diff --git a/server/api/v1/auth/signup/simple.post.ts b/server/api/v1/auth/signup/simple.post.ts index f6e27fa..895ec51 100644 --- a/server/api/v1/auth/signup/simple.post.ts +++ b/server/api/v1/auth/signup/simple.post.ts @@ -93,7 +93,7 @@ export default defineEventHandler(async (h3) => { profilePictureId, async () => jdenticon.toPng(username, 256), {}, - [`anonymous:read`, `${userId}:write`] + [`internal:read`, `${userId}:write`] ); const user = await prisma.user.create({ data: { diff --git a/server/api/v1/news/[id]/index.get.ts b/server/api/v1/news/[id]/index.get.ts index 07c62df..f83047f 100644 --- a/server/api/v1/news/[id]/index.get.ts +++ b/server/api/v1/news/[id]/index.get.ts @@ -1,22 +1,30 @@ import { defineEventHandler, createError } from "h3"; +import aclManager from "~/server/internal/acls"; import newsManager from "~/server/internal/news"; -export default defineEventHandler(async (event) => { - const id = event.context.params?.id; - if (!id) { +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["news:read"]); + if (!userId) + throw createError({ + statusCode: 403, + statusMessage: "Requires authentication", + }); + + const id = h3.context.params?.id; + if (!id) throw createError({ statusCode: 400, message: "Missing news ID", }); - } + - const news = await newsManager.getById(id); - if (!news) { + const news = await newsManager.fetchById(id); + if (!news) throw createError({ statusCode: 404, message: "News article not found", }); - } + return news; -}); +}); diff --git a/server/api/v1/news/index.get.ts b/server/api/v1/news/index.get.ts index 3bc4108..5f846f2 100644 --- a/server/api/v1/news/index.get.ts +++ b/server/api/v1/news/index.get.ts @@ -1,17 +1,37 @@ import { defineEventHandler, getQuery } from "h3"; +import aclManager from "~/server/internal/acls"; import newsManager from "~/server/internal/news"; -export default defineEventHandler(async (event) => { - const query = getQuery(event); - +export default defineEventHandler(async (h3) => { + 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 = { - take: query.limit ? parseInt(query.limit as string) : undefined, - skip: query.skip ? parseInt(query.skip as string) : undefined, - orderBy: query.order as 'asc' | 'desc', - tags: query.tags ? (query.tags as string).split(',') : undefined, + 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.getAll(options); + const news = await newsManager.fetch(options); return news; }); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 7b3bad5..dde3aa1 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -30,6 +30,8 @@ export const userACLDescriptions: ObjectFromList = { "Remove a game from any collection (excluding library).", "library:add": "Add a game to your library.", "library:remove": "Remove a game from your library.", + + "news:read": "Read the server's news articles.", }; export const systemACLDescriptions: ObjectFromList = { @@ -55,4 +57,8 @@ export const systemACLDescriptions: ObjectFromList = { "import:game:new": "Import a game.", "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." }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 65b4664..d191d59 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -53,6 +53,10 @@ export const systemACLs = [ "import:game:new", "user:read", + + "news:read", + "news:create", + "news:delete", ] as const; const systemACLPrefix = "system:"; diff --git a/server/internal/news/index.ts b/server/internal/news/index.ts index e1f9119..94d3a88 100644 --- a/server/internal/news/index.ts +++ b/server/internal/news/index.ts @@ -1,70 +1,72 @@ +import { triggerAsyncId } from "async_hooks"; import prisma from "../db/database"; class NewsManager { async create(data: { title: string; content: string; - excerpt: string; + description: string; tags: string[]; authorId: string; image?: string; }) { - return await prisma.news.create({ + return await prisma.article.create({ data: { title: data.title, + description: data.description, content: data.content, - excerpt: data.excerpt, - tags: data.tags, + + tags: { + connectOrCreate: data.tags.map((e) => ({ + where: { name: e }, + create: { name: e }, + })), + }, + image: data.image, author: { connect: { id: data.authorId, }, }, - publishedAt: new Date(), }, }); } - async getAll(options?: { - take?: number; - skip?: number; - orderBy?: 'asc' | 'desc'; - tags?: string[]; - search?: string; - }) { - const where = { - AND: [ - options?.tags?.length ? { - tags: { - hasSome: options.tags, + async fetch( + options: { + take?: number; + skip?: number; + orderBy?: "asc" | "desc"; + tags?: string[]; + search?: string; + } = {} + ) { + return await prisma.article.findMany({ + where: { + AND: [ + { + tags: { + some: { OR: options.tags?.map((e) => ({ name: e })) ?? [] }, + }, }, - } : {}, - options?.search ? { - OR: [ - { - title: { - contains: options.search, - mode: 'insensitive' as const, - }, + { + title: { + search: options.search }, - { - content: { - contains: options.search, - mode: 'insensitive' as const, - }, + description: { + search: options.search }, - ], - } : {}, - ], - }; - - return await prisma.news.findMany({ - where, - take: options?.take, - skip: options?.skip, + content: { + search: options.search + } + } + ], + }, + take: options?.take || 10, + skip: options?.skip || 0, orderBy: { - publishedAt: options?.orderBy || 'desc', + publishedAt: options?.orderBy || "desc", }, include: { author: { @@ -77,8 +79,8 @@ class NewsManager { }); } - async getById(id: string) { - return await prisma.news.findUnique({ + async fetchById(id: string) { + return await prisma.article.findUnique({ where: { id }, include: { author: { @@ -91,24 +93,26 @@ class NewsManager { }); } - async update(id: string, data: { - title?: string; - content?: string; - excerpt?: string; - tags?: string[]; - image?: string; - }) { - return await prisma.news.update({ + async update( + id: string, + data: { + title?: string; + content?: string; + excerpt?: string; + image?: string; + } + ) { + return await prisma.article.update({ where: { id }, data, }); } async delete(id: string) { - return await prisma.news.delete({ + return await prisma.article.delete({ where: { id }, }); } } -export default new NewsManager(); +export default new NewsManager();