diff --git a/README.md b/README.md index 65f4f58..ef3634e 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ A WIP project hosting/distribution platform for Scratch Everywhere! - [ ] Reports - [ ] Comments - [ ] Projects - - [ ] Admin/Mod dashboard - - [ ] Easy way to make people mods/admins + - [x] Admin/Mod dashboard + - [x] Easy way to make people mods/admins - [ ] Allow mods to edit project info - [ ] Make them give a reason - [x] TOS/Rules diff --git a/app/app.vue b/app/app.vue index 9031e50..82905a9 100644 --- a/app/app.vue +++ b/app/app.vue @@ -74,6 +74,9 @@ body.theme-light { --color-primary-text: #fff; --color-text: #000; --color-blockquote: #d4d4d4; + --color-error: #c33; + --color-error-background: #fcc; + --color-card-background: #fff; } body.theme-dark { @@ -83,6 +86,9 @@ body.theme-dark { --color-primary-text: #fff; --color-text: #ccc; --color-blockquote: #333; + --color-error: #c33; + --color-error-background: #422; + --color-card-background: #2c2c2c; } .fade-enter-active, diff --git a/app/components/navbar.vue b/app/components/navbar.vue index d6cab06..e730864 100644 --- a/app/components/navbar.vue +++ b/app/components/navbar.vue @@ -40,6 +40,17 @@ const createProject = async () => { ); }; +let userRoles: string[] = []; +if (user.loggedIn) { + try { + const res = await $fetch(`/api/user/${user.username}/roles`, { + method: "GET", + headers: useRequestHeaders(["cookie"]), + }); + userRoles = res ?? []; + } catch {} +} + const isDropdownOpen = ref(false); const dropdownRef = ref(null); @@ -86,6 +97,7 @@ onUnmounted(() => { Create Explore + Moderate +const user = await useCurrentUser(); + +let userRoles: string[] = []; +if (user.loggedIn) { + try { + const res = await $fetch(`/api/user/${user.username}/roles`, { + method: "GET", + headers: useRequestHeaders(["cookie"]), + }); + userRoles = res ?? []; + } catch {} +} +else { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); +} + +if (!userRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); +} + +interface RoleReturn { + role: string; + user: string; + expiresAt: string | null; + description: string | null; +} + +const allRoles = ref(await $fetch("/api/user/roles", { + method: "GET", + headers: useRequestHeaders(["cookie"]), +})); + +const roleProfiles = ref([]); + +watch(() => allRoles.value, async () => { + if (allRoles.value.length === 0) { + roleProfiles.value = []; + return; + } + + roleProfiles.value = await Promise.all( + allRoles.value.map((r) => + getProfilePicture(r.user).catch(() => null) // handle errors per request + ), + ) as string[]; +}, { immediate: true }); + +const deleteForm = ref(null); +const deleteFormOpen = ref(false); +watch (deleteForm, async () => { + deleteFormOpen.value = deleteForm.value !== null; +}); + +const createFormOpen = ref(false); + +const setSelectedRole = (index: number) => { + const role = allRoles.value[index]; + formState.targetUsername = role!.user; + formState.selectedRole = role!.role; + deleteForm.value = index; +}; + +interface RoleFormState { + targetUsername: string; + selectedRole: string; + expiryDate: number | null; + isLoading: boolean; + errorMessage: string; + description: string; +} + +const formState = reactive({ + targetUsername: "", + selectedRole: "", + expiryDate: null, + description: "", + isLoading: false, + errorMessage: "", +}); + +const resetForm = () => { + formState.targetUsername = ""; + formState.selectedRole = ""; + formState.expiryDate = null; + formState.description = ""; + createFormOpen.value = false; + deleteForm.value = null; + formState.errorMessage = ""; +}; + +const submitRole = async (remove: boolean) => { + formState.errorMessage = ""; + + if (!formState.targetUsername.trim()) { + formState.errorMessage = "Please enter a username"; + return; + } + if (!formState.selectedRole) { + formState.errorMessage = "Please select a role"; + return; + } + if (formState.expiryDate && new Date(formState.expiryDate).getTime() < Date.now()) { + formState.errorMessage = "Expiry date cannot be in the past"; + return; + } + + formState.isLoading = true; + + let res; + try { + res = await $fetch(`/api/user/${formState.targetUsername}/roles`, { + method: remove ? "DELETE" : "POST", + headers: useRequestHeaders(["cookie"]), + body: {role: formState.selectedRole, expiresAt: formState.expiryDate, description: formState.description}, + }); + if (res) { + if (deleteFormOpen.value) { + const deletedIndex = deleteForm.value!; + allRoles.value.splice(deletedIndex, 1); + roleProfiles.value.splice(deletedIndex, 1); + } else if (createFormOpen.value) { + const newRole: RoleReturn = { + role: formState.selectedRole, + user: formState.targetUsername, + expiresAt: formState.expiryDate ? new Date(formState.expiryDate).toISOString() : null, + description: formState.description || null, + }; + allRoles.value.push(newRole); + + const profilePic = await getProfilePicture(formState.targetUsername).catch(() => null); + roleProfiles.value.push(profilePic as string); + } + resetForm(); + } + else { + formState.errorMessage = remove + ? `${formState.targetUsername} does not have the '${formState.selectedRole}' role` + : `Failed to assign role`; + } + } catch { + formState.errorMessage = `Failed to ${remove ? "remove" : "assign"} role`; + } finally { + formState.isLoading = false; + } +}; + +const formatExpiryDate = (dateString: string) => { + const date = new Date(dateString); + const day = date.getDate(); + const getDaySuffix = (d: number) => { + if (d > 3 && d < 21) return "th"; + switch (d % 10) { + case 1: return "st"; + case 2: return "nd"; + case 3: return "rd"; + default: return "th"; + } + }; + const month = date.toLocaleDateString(undefined, { month: "long" }); + const year = date.getFullYear(); + const hours = date.getHours() % 12 || 12; + const minutes = date.getMinutes().toString().padStart(2, "0"); + const ampm = date.getHours() >= 12 ? "PM" : "AM"; + + return `${day}${getDaySuffix(day)} ${month} ${year}, ${hours}:${minutes} ${ampm}`; +}; + + + + \ No newline at end of file diff --git a/server/api/user/[name]/roles.delete.ts b/server/api/user/[name]/roles.delete.ts new file mode 100644 index 0000000..57c04ab --- /dev/null +++ b/server/api/user/[name]/roles.delete.ts @@ -0,0 +1,66 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq, and, or, isNull, gt } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + let decoded: string | JwtPayload; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch (e) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + if (!tokenRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); + } + + const role: string = (JSON.parse(await readRawBody(event) as string)).role; + if (!role) { + throw createError({ + statusCode: 400, + statusMessage: "No role provided", + }); + } + + // set expiresAt to now + // means role is no longer active but we still have ban history + const result = await db + .update(schema.userRoles).set({ expiresAt: new Date() }) + .where( + and( + eq(schema.userRoles.user, user), + eq(schema.userRoles.role, role), + or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + ), + ) + ); + + return result.changes > 0; +}); \ No newline at end of file diff --git a/server/api/user/[name]/roles.get.ts b/server/api/user/[name]/roles.get.ts new file mode 100644 index 0000000..d8c13a5 --- /dev/null +++ b/server/api/user/[name]/roles.get.ts @@ -0,0 +1,29 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq, gt, and, or, isNull } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + let decoded: string | JwtPayload | undefined; + + if (!token) return []; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch { + return []; + } + + const roles = ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(and(eq(schema.userRoles.user, user), or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + ))) + ).map(r => r.role); + + return roles; +}); diff --git a/server/api/user/[name]/roles.post.ts b/server/api/user/[name]/roles.post.ts new file mode 100644 index 0000000..bcff599 --- /dev/null +++ b/server/api/user/[name]/roles.post.ts @@ -0,0 +1,87 @@ +import { db } from "../../../utils/drizzle"; +import * as schema from "../../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { eq, sql } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const user = getRouterParam(event, "name") as string; + const token = getCookie(event, "SB_TOKEN"); + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + let decoded: string | JwtPayload; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch (e) { + throw createError({ + statusCode: 401, + statusMessage: "Unauthorized", + }); + } + + const tokenUser = typeof decoded !== "string" ? decoded.username : null; + const tokenRoles = typeof decoded !== "string" ? ( + await db + .select({role: schema.userRoles.role}) + .from(schema.userRoles) + .where(eq(schema.userRoles.user, tokenUser)) + ).map(r => r.role) : []; + + if (!tokenRoles.includes("admin")) { + throw createError({ + statusCode: 403, + statusMessage: "Forbidden", + }); + } + + let body: { + role: string; + expiresAt?: number | null; + description?: string | null; + }; + try { + body = JSON.parse((await readRawBody(event)) as string); + } catch { + throw createError({ + statusCode: 400, + statusMessage: "Invalid body structure" + }); + } + if (!body) { + throw createError({ + statusCode: 400, + statusMessage: "No request body provided", + }); + } + const { role, expiresAt, description } = body; + + if (expiresAt && new Date(expiresAt).getTime() < Date.now()) { + throw createError({ + statusCode: 400, + statusMessage: "Expiry date cannot be in the past", + }); + } + + const result = await db.insert(schema.userRoles) + .values({ + user, + role, + expiresAt: expiresAt ? new Date(expiresAt) : null, + description, + }) + .onConflictDoUpdate({ + target: [schema.userRoles.user, schema.userRoles.role], + set: { + addedAt: new Date(), + expiresAt: expiresAt ? new Date(expiresAt) : null, + description, + } + }); + + return true; +}); \ No newline at end of file diff --git a/server/api/user/roles.get.ts b/server/api/user/roles.get.ts new file mode 100644 index 0000000..f9e11bc --- /dev/null +++ b/server/api/user/roles.get.ts @@ -0,0 +1,31 @@ +import { db } from "../../utils/drizzle"; +import * as schema from "../../database/schema"; +import jwt, { JwtPayload } from "jsonwebtoken"; +import { gt, or, isNull } from "drizzle-orm"; + +export default defineEventHandler(async (event) => { + const token = getCookie(event, "SB_TOKEN"); + let decoded: string | JwtPayload | undefined; + + if (!token) return []; + try { + decoded = jwt.verify(token, useRuntimeConfig().jwtSecret); + } catch { + return []; + } + + const roles = await db + .select({ + user: schema.userRoles.user, + role: schema.userRoles.role, + expiresAt: schema.userRoles.expiresAt, + description: schema.userRoles.description, + }) + .from(schema.userRoles) + .where(or( + isNull(schema.userRoles.expiresAt), + gt(schema.userRoles.expiresAt, new Date()) + )); + + return roles; +}); diff --git a/server/database/migrations/0008_init_roles.sql b/server/database/migrations/0008_init_roles.sql new file mode 100644 index 0000000..536caf7 --- /dev/null +++ b/server/database/migrations/0008_init_roles.sql @@ -0,0 +1,8 @@ +CREATE TABLE `user_roles` ( + `user` text NOT NULL, + `role` text NOT NULL, + `added_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL, + `expires_at` integer, + `description` text, + PRIMARY KEY(`user`, `role`) +); diff --git a/server/database/migrations/meta/0008_snapshot.json b/server/database/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..fe024e6 --- /dev/null +++ b/server/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,307 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6ad7aa06-2209-4788-b654-7e096424834e", + "prevId": "21a289a5-59e9-4760-9170-9c06ac8e285f", + "tables": { + "project_comments": { + "name": "project_comments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "original_id": { + "name": "original_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "project_comments_project_id_projects_id_fk": { + "name": "project_comments_project_id_projects_id_fk", + "tableFrom": "project_comments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_likes": { + "name": "project_likes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_likes_project_id_projects_id_fk": { + "name": "project_likes_project_id_projects_id_fk", + "tableFrom": "project_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_likes_project_id_user_pk": { + "columns": [ + "project_id", + "user" + ], + "name": "project_likes_project_id_user_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_platforms": { + "name": "project_platforms", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_platforms_project_id_projects_id_fk": { + "name": "project_platforms_project_id_projects_id_fk", + "tableFrom": "project_platforms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_platforms_project_id_platform_pk": { + "columns": [ + "project_id", + "platform" + ], + "name": "project_platforms_project_id_platform_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private": { + "name": "private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unistore_data": { + "name": "unistore_data", + "columns": { + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_roles_user_role_pk": { + "columns": [ + "user", + "role" + ], + "name": "user_roles_user_role_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/database/migrations/meta/_journal.json b/server/database/migrations/meta/_journal.json index 19d4ca6..22b3098 100644 --- a/server/database/migrations/meta/_journal.json +++ b/server/database/migrations/meta/_journal.json @@ -47,6 +47,13 @@ "when": 1767219427039, "tag": "0007_comments_editing", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1767307020651, + "tag": "0008_init_roles", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/database/schema.ts b/server/database/schema.ts index 56fcfd0..edf8870 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -44,3 +44,17 @@ export const projectComments = sqliteTable("project_comments", { export const unistoreData = sqliteTable("unistore_data", { revision: integer("revision").notNull(), }); + +export const userRoles = sqliteTable("user_roles", { + user: text("user").notNull(), + role: text("role").notNull(), + addedAt: integer("added_at", { mode: "timestamp" }).default( + sql`(strftime('%s', 'now'))`, + ).notNull(), + + // optional fields for bans, but no reason they couldn't be used for other roles + expiresAt: integer("expires_at", { mode: "timestamp" }), + description: text("description"), +}, (t) => [ + primaryKey({ columns: [t.user, t.role] }), +]); \ No newline at end of file