diff --git a/components/DeleteNewsModal.vue b/components/DeleteNewsModal.vue new file mode 100644 index 0000000..475c8bd --- /dev/null +++ b/components/DeleteNewsModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/components/NewsArticleCreate.vue b/components/NewsArticleCreate.vue new file mode 100644 index 0000000..7759062 --- /dev/null +++ b/components/NewsArticleCreate.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/components/NewsDirectory.vue b/components/NewsDirectory.vue new file mode 100644 index 0000000..2bff2ad --- /dev/null +++ b/components/NewsDirectory.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/composables/useNews.ts b/composables/useNews.ts new file mode 100644 index 0000000..f5f2093 --- /dev/null +++ b/composables/useNews.ts @@ -0,0 +1,50 @@ +export const useNews = () => { + const getAll = async (options?: { + limit?: number; + skip?: number; + orderBy?: 'asc' | 'desc'; + tags?: string[]; + search?: string; + }) => { + const query = new URLSearchParams(); + + if (options?.limit) query.set('limit', options.limit.toString()); + if (options?.skip) query.set('skip', options.skip.toString()); + if (options?.orderBy) query.set('order', options.orderBy); + if (options?.tags?.length) query.set('tags', options.tags.join(',')); + if (options?.search) query.set('search', options.search); + + return await useFetch(`/api/v1/news?${query.toString()}`); + }; + + const getById = async (id: string) => { + return await useFetch(`/api/v1/news/${id}`); + }; + + const create = async (article: { + title: string; + excerpt: string; + content: string; + image?: string; + tags: string[]; + authorId: string; + }) => { + return await $fetch('/api/v1/news', { + method: 'POST', + body: article + }); + }; + + const remove = async (id: string) => { + return await $fetch(`/api/v1/news/${id}`, { + method: 'DELETE' + }); + }; + + return { + getAll, + getById, + create, + remove + }; +}; diff --git a/drop-base b/drop-base index 01fd41c..637b4e1 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 01fd41c65ae288eb19bbc92b2625733afe51c101 +Subproject commit 637b4e1e9b943605e9f25234dd1f879d5a58b493 diff --git a/pages/news.vue b/pages/news.vue new file mode 100644 index 0000000..8f00aa7 --- /dev/null +++ b/pages/news.vue @@ -0,0 +1,156 @@ + + + + + + diff --git a/pages/news/article/[id]/index.vue b/pages/news/article/[id]/index.vue new file mode 100644 index 0000000..112beb1 --- /dev/null +++ b/pages/news/article/[id]/index.vue @@ -0,0 +1,150 @@ + + + + + + + diff --git a/pages/news/index.vue b/pages/news/index.vue new file mode 100644 index 0000000..9ae7fc9 --- /dev/null +++ b/pages/news/index.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/prisma/migrations/20250128102738_add_news/migration.sql b/prisma/migrations/20250128102738_add_news/migration.sql new file mode 100644 index 0000000..68b2aca --- /dev/null +++ b/prisma/migrations/20250128102738_add_news/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "news" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "excerpt" TEXT NOT NULL, + "tags" TEXT[], + "image" TEXT, + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "authorId" TEXT NOT NULL, + + CONSTRAINT "news_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "news" ADD CONSTRAINT "news_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 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 new file mode 100644 index 0000000..d9ef63c --- /dev/null +++ b/prisma/schema/news.prisma @@ -0,0 +1,21 @@ +model Tag { + id String @id @default(uuid()) + name String @unique + + 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 0c478e1..a664f50 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -11,6 +11,7 @@ model User { clients Client[] notifications Notification[] collections Collection[] + articles Article[] tokens APIToken[] } diff --git a/server/api/v1/admin/news/[id]/index.delete.ts b/server/api/v1/admin/news/[id]/index.delete.ts new file mode 100644 index 0000000..09949df --- /dev/null +++ b/server/api/v1/admin/news/[id]/index.delete.ts @@ -0,0 +1,23 @@ +import { defineEventHandler, createError } from "h3"; +import newsManager from "~/server/internal/news"; + +export default defineEventHandler(async (event) => { + const userId = await event.context.session.getUserId(event); + if (!userId) { + throw createError({ + statusCode: 401, + message: "Unauthorized", + }); + } + + const id = event.context.params?.id; + if (!id) { + throw createError({ + statusCode: 400, + message: "Missing news ID", + }); + } + + await newsManager.delete(id); + return { success: true }; +}); 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 new file mode 100644 index 0000000..16afed1 --- /dev/null +++ b/server/api/v1/admin/news/index.post.ts @@ -0,0 +1,23 @@ +import { defineEventHandler, createError, readBody } 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: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, + + tags: body.tags, + + image: body.image, + authorId: body.authorId, + }); + + return article; +}); 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 new file mode 100644 index 0000000..f83047f --- /dev/null +++ b/server/api/v1/news/[id]/index.get.ts @@ -0,0 +1,30 @@ +import { defineEventHandler, createError } from "h3"; +import aclManager from "~/server/internal/acls"; +import newsManager from "~/server/internal/news"; + +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.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 new file mode 100644 index 0000000..5f846f2 --- /dev/null +++ b/server/api/v1/news/index.get.ts @@ -0,0 +1,37 @@ +import { defineEventHandler, getQuery } from "h3"; +import aclManager from "~/server/internal/acls"; +import newsManager from "~/server/internal/news"; + +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: 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/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 2e747ca..02641a1 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -25,6 +25,11 @@ export const userACLs = [ "collections:remove", "library:add", "library:remove", +<<<<<<< HEAD +======= + + "news:read", +>>>>>>> AdenMGB-develop ] as const; const userACLPrefix = "user:"; @@ -51,6 +56,13 @@ export const systemACLs = [ "import:game:new", "user:read", +<<<<<<< HEAD +======= + + "news:read", + "news:create", + "news:delete", +>>>>>>> AdenMGB-develop ] as const; const systemACLPrefix = "system:"; diff --git a/server/internal/news/index.ts b/server/internal/news/index.ts new file mode 100644 index 0000000..94d3a88 --- /dev/null +++ b/server/internal/news/index.ts @@ -0,0 +1,118 @@ +import { triggerAsyncId } from "async_hooks"; +import prisma from "../db/database"; + +class NewsManager { + async create(data: { + title: string; + content: string; + description: string; + tags: string[]; + authorId: string; + image?: string; + }) { + return await prisma.article.create({ + data: { + title: data.title, + description: data.description, + content: data.content, + + tags: { + connectOrCreate: data.tags.map((e) => ({ + where: { name: e }, + create: { name: e }, + })), + }, + + image: data.image, + author: { + connect: { + id: data.authorId, + }, + }, + }, + }); + } + + 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 })) ?? [] }, + }, + }, + { + title: { + search: options.search + }, + description: { + search: options.search + }, + content: { + search: options.search + } + } + ], + }, + take: options?.take || 10, + skip: options?.skip || 0, + orderBy: { + publishedAt: options?.orderBy || "desc", + }, + include: { + author: { + select: { + id: true, + displayName: true, + }, + }, + }, + }); + } + + async fetchById(id: string) { + return await prisma.article.findUnique({ + where: { id }, + include: { + author: { + select: { + id: true, + displayName: true, + }, + }, + }, + }); + } + + 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.article.delete({ + where: { id }, + }); + } +} + +export default new NewsManager();