feat: add project fork id, add project permission check

This commit is contained in:
Khairul Hidayat 2024-02-29 15:56:26 +00:00
parent 543260479a
commit 110bdd88e6
11 changed files with 530 additions and 29 deletions

View File

@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"dev": "HOST=0.0.0.0 tsx server/index.ts", "dev": "HOST=0.0.0.0 PORT=3001 tsx server/index.ts",
"dev:watch": "tsx watch --ignore *.mjs server/index.ts", "dev:watch": "tsx watch --ignore *.mjs server/index.ts",
"start": "NODE_ENV=production tsx server/index.ts", "start": "NODE_ENV=production tsx server/index.ts",
"build": "vite build", "build": "vite build",
@ -23,6 +23,7 @@
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.3.9", "@swc/cli": "^0.3.9",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/better-sqlite3": "^7.6.9",
"@types/cookie-parser": "^1.4.6", "@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",

View File

@ -17,11 +17,9 @@ import { useData } from "~/renderer/hooks";
import { Data } from "../+data"; import { Data } from "../+data";
import { useBreakpoint } from "~/hooks/useBreakpoint"; import { useBreakpoint } from "~/hooks/useBreakpoint";
import StatusBar from "./status-bar"; import StatusBar from "./status-bar";
import { FiServer, FiTerminal } from "react-icons/fi"; import { FiTerminal } from "react-icons/fi";
import SettingsDialog from "./settings-dialog"; import SettingsDialog from "./settings-dialog";
import FileIcon from "~/components/ui/file-icon"; import FileIcon from "~/components/ui/file-icon";
import APIManager from "./api-manager";
import { api } from "~/lib/api";
const Editor = () => { const Editor = () => {
const { project, initialFiles } = useData<Data>(); const { project, initialFiles } = useData<Data>();

View File

@ -1,11 +1,12 @@
import { getFileExt } from "~/lib/utils"; import { getFileExt } from "~/lib/utils";
import CodeEditor from "../../../../components/ui/code-editor";
import trpc from "~/lib/trpc"; import trpc from "~/lib/trpc";
import { useData } from "~/renderer/hooks"; import { useData } from "~/renderer/hooks";
import { Data } from "../+data"; import { Data } from "../+data";
import Spinner from "~/components/ui/spinner"; import Spinner from "~/components/ui/spinner";
import { previewStore } from "../stores/web-preview"; import { previewStore } from "../stores/web-preview";
import { useProjectContext } from "../context/project"; import { useProjectContext } from "../context/project";
import { Suspense, lazy } from "react";
const CodeEditor = lazy(() => import("~/components/ui/code-editor"));
type Props = { type Props = {
id: number; id: number;
@ -46,14 +47,20 @@ const FileViewer = ({ id }: Props) => {
const ext = getFileExt(filename); const ext = getFileExt(filename);
return ( return (
<Suspense fallback={<LoadingLayout />}>
<CodeEditor <CodeEditor
lang={ext} lang={ext}
value={data?.content || ""} value={data?.content || ""}
formatOnSave formatOnSave
onChange={(val) => onChange={(val) =>
updateFileContent.mutate({ projectId: project.id, id, content: val }) updateFileContent.mutate({
projectId: project.id,
id,
content: val,
})
} }
/> />
</Suspense>
); );
} }

15
pnpm-lock.yaml generated
View File

@ -94,7 +94,7 @@ dependencies:
version: 16.4.5 version: 16.4.5
drizzle-orm: drizzle-orm:
specifier: ^0.29.3 specifier: ^0.29.3
version: 0.29.3(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0) version: 0.29.3(@types/better-sqlite3@7.6.9)(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0)
drizzle-zod: drizzle-zod:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1(drizzle-orm@0.29.3)(zod@3.22.4) version: 0.5.1(drizzle-orm@0.29.3)(zod@3.22.4)
@ -172,6 +172,9 @@ devDependencies:
'@types/bcrypt': '@types/bcrypt':
specifier: ^5.0.2 specifier: ^5.0.2
version: 5.0.2 version: 5.0.2
'@types/better-sqlite3':
specifier: ^7.6.9
version: 7.6.9
'@types/cookie-parser': '@types/cookie-parser':
specifier: ^1.4.6 specifier: ^1.4.6
version: 1.4.6 version: 1.4.6
@ -2267,6 +2270,11 @@ packages:
'@types/node': 20.11.19 '@types/node': 20.11.19
dev: true dev: true
/@types/better-sqlite3@7.6.9:
resolution: {integrity: sha512-FvktcujPDj9XKMJQWFcl2vVl7OdRIqsSRX9b0acWwTmwLK9CF2eqo/FRcmMLNpugKoX/avA6pb7TorDLmpgTnQ==}
dependencies:
'@types/node': 20.11.19
/@types/body-parser@1.19.5: /@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies: dependencies:
@ -3489,7 +3497,7 @@ packages:
- supports-color - supports-color
dev: true dev: true
/drizzle-orm@0.29.3(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0): /drizzle-orm@0.29.3(@types/better-sqlite3@7.6.9)(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0):
resolution: {integrity: sha512-uSE027csliGSGYD0pqtM+SAQATMREb3eSM/U8s6r+Y0RFwTKwftnwwSkqx3oS65UBgqDOM0gMTl5UGNpt6lW0A==} resolution: {integrity: sha512-uSE027csliGSGYD0pqtM+SAQATMREb3eSM/U8s6r+Y0RFwTKwftnwwSkqx3oS65UBgqDOM0gMTl5UGNpt6lW0A==}
peerDependencies: peerDependencies:
'@aws-sdk/client-rds-data': '>=3' '@aws-sdk/client-rds-data': '>=3'
@ -3560,6 +3568,7 @@ packages:
sqlite3: sqlite3:
optional: true optional: true
dependencies: dependencies:
'@types/better-sqlite3': 7.6.9
'@types/react': 18.2.57 '@types/react': 18.2.57
better-sqlite3: 9.4.2 better-sqlite3: 9.4.2
react: 18.2.0 react: 18.2.0
@ -3571,7 +3580,7 @@ packages:
drizzle-orm: '>=0.23.13' drizzle-orm: '>=0.23.13'
zod: '*' zod: '*'
dependencies: dependencies:
drizzle-orm: 0.29.3(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0) drizzle-orm: 0.29.3(@types/better-sqlite3@7.6.9)(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0)
zod: 3.22.4 zod: 3.22.4
dev: false dev: false

View File

@ -0,0 +1,44 @@
CREATE TABLE `files` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`project_id` integer NOT NULL,
`parent_id` integer,
`path` text NOT NULL,
`filename` text NOT NULL,
`is_directory` integer DEFAULT false NOT NULL,
`is_file` integer DEFAULT false NOT NULL,
`is_pinned` integer DEFAULT false NOT NULL,
`content` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`parent_id`) REFERENCES `files`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`fork_id` integer;
`user_id` integer NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`visibility` text DEFAULT 'private',
`settings` text DEFAULT [object Object],
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` text,
FOREIGN KEY (`fork_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`password` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` text
);
--> statement-breakpoint
CREATE INDEX `file_path_idx` ON `files` (`path`);--> statement-breakpoint
CREATE INDEX `file_name_idx` ON `files` (`filename`);--> statement-breakpoint
CREATE UNIQUE INDEX `projects_slug_unique` ON `projects` (`slug`);--> statement-breakpoint
CREATE INDEX `project_visibility_idx` ON `projects` (`visibility`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

@ -0,0 +1,332 @@
{
"version": "5",
"dialect": "sqlite",
"id": "4d5af5ab-31e5-4202-9c7e-3264e985bf20",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"files": {
"name": "files",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"project_id": {
"name": "project_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_directory": {
"name": "is_directory",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_file": {
"name": "is_file",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"is_pinned": {
"name": "is_pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"file_path_idx": {
"name": "file_path_idx",
"columns": [
"path"
],
"isUnique": false
},
"file_name_idx": {
"name": "file_name_idx",
"columns": [
"filename"
],
"isUnique": false
}
},
"foreignKeys": {
"files_project_id_projects_id_fk": {
"name": "files_project_id_projects_id_fk",
"tableFrom": "files",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"parent_id_fk": {
"name": "parent_id_fk",
"tableFrom": "files",
"tableTo": "files",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fork_id": {
"name": "fork_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'private'"
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": {
"css": {
"preprocessor": null,
"tailwindcss": false
},
"js": {
"transpiler": null,
"packages": []
}
}
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
},
"project_visibility_idx": {
"name": "project_visibility_idx",
"columns": [
"visibility"
],
"isUnique": false
}
},
"foreignKeys": {
"projects_user_id_users_id_fk": {
"name": "projects_user_id_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"project_fork_id_fk": {
"name": "project_fork_id_fk",
"tableFrom": "projects",
"tableTo": "projects",
"columnsFrom": [
"fork_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"deleted_at": {
"name": "deleted_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -0,0 +1 @@
{"version":"5","dialect":"sqlite","entries":[{"idx":0,"version":"5","when":1709221275637,"tag":"0000_swift_mandroid","breakpoints":true}]}

View File

@ -1,5 +1,9 @@
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import db from "./index"; import db from "./index";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
migrate(db, { migrationsFolder: __dirname + "/drizzle" }); migrate(db, { migrationsFolder: __dirname + "/drizzle" });
process.exit(); process.exit();

View File

@ -1,5 +1,11 @@
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import {
foreignKey,
index,
integer,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
import { user } from "./user"; import { user } from "./user";
@ -23,6 +29,7 @@ export const project = sqliteTable(
userId: integer("user_id") userId: integer("user_id")
.notNull() .notNull()
.references(() => user.id), .references(() => user.id),
forkId: integer("fork_id"),
slug: text("slug").notNull().unique(), slug: text("slug").notNull().unique(),
title: text("title").notNull(), title: text("title").notNull(),
@ -40,12 +47,21 @@ export const project = sqliteTable(
deletedAt: text("deleted_at"), deletedAt: text("deleted_at"),
}, },
(table) => ({ (table) => ({
forkIdFk: foreignKey({
columns: [table.forkId],
foreignColumns: [table.id],
name: "project_fork_id_fk",
}),
visibilityEnum: index("project_visibility_idx").on(table.visibility), visibilityEnum: index("project_visibility_idx").on(table.visibility),
}) })
); );
export const projectRelations = relations(project, ({ one, many }) => ({ export const projectRelations = relations(project, ({ one, many }) => ({
files: many(file), files: many(file),
fork: one(project, {
fields: [project.forkId],
references: [project.id],
}),
user: one(user, { user: one(user, {
fields: [project.userId], fields: [project.userId],
references: [user.id], references: [user.id],

View File

@ -4,6 +4,7 @@ import { procedure, router } from "../api/trpc";
import { file, insertFileSchema, selectFileSchema } from "../db/schema/file"; import { file, insertFileSchema, selectFileSchema } from "../db/schema/file";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { getProjectById, hasPermission } from "./project";
const fileRouter = router({ const fileRouter = router({
getAll: procedure getAll: procedure
@ -14,7 +15,13 @@ const fileRouter = router({
isPinned: z.boolean().optional(), isPinned: z.boolean().optional(),
}) })
) )
.query(async ({ input: opt }) => { .query(async ({ ctx, input: opt }) => {
const project = await getProjectById(opt.projectId);
if (!hasPermission(ctx, project, "r")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const files = await db.query.file.findMany({ const files = await db.query.file.findMany({
where: and( where: and(
eq(file.projectId, opt.projectId), eq(file.projectId, opt.projectId),
@ -29,10 +36,21 @@ const fileRouter = router({
return files; return files;
}), }),
getById: procedure.input(z.number()).query(async ({ input }) => { getById: procedure.input(z.number()).query(async ({ ctx, input }) => {
return db.query.file.findFirst({ const result = await db.query.file.findFirst({
where: and(eq(file.id, input), isNull(file.deletedAt)), where: and(eq(file.id, input), isNull(file.deletedAt)),
with: { project: { columns: { visibility: true, userId: true } } },
}); });
if (!result) {
return undefined;
}
if (!hasPermission(ctx, result?.project, "r")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return result;
}), }),
create: procedure create: procedure
@ -44,7 +62,13 @@ const fileRouter = router({
isDirectory: true, isDirectory: true,
}) })
) )
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const project = await getProjectById(input.projectId);
if (!hasPermission(ctx, project, "w")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
let basePath = ""; let basePath = "";
if (input.parentId) { if (input.parentId) {
const parent = await db.query.file.findFirst({ const parent = await db.query.file.findFirst({
@ -58,7 +82,7 @@ const fileRouter = router({
} }
const data: z.infer<typeof insertFileSchema> = { const data: z.infer<typeof insertFileSchema> = {
projectId: input.projectId, projectId: project.id,
parentId: input.parentId, parentId: input.parentId,
path: basePath + input.filename, path: basePath + input.filename,
filename: input.filename, filename: input.filename,
@ -71,17 +95,22 @@ const fileRouter = router({
update: procedure update: procedure
.input(selectFileSchema.partial().required({ id: true, projectId: true })) .input(selectFileSchema.partial().required({ id: true, projectId: true }))
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const fileData = await db.query.file.findFirst({ const fileData = await db.query.file.findFirst({
where: and( where: and(
eq(file.projectId, input.projectId), eq(file.projectId, input.projectId),
eq(file.id, input.id), eq(file.id, input.id),
isNull(file.deletedAt) isNull(file.deletedAt)
), ),
with: { project: { columns: { visibility: true, userId: true } } },
}); });
if (!fileData) { if (!fileData) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
if (!hasPermission(ctx, fileData.project, "w")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const data = { ...input }; const data = { ...input };
@ -109,13 +138,17 @@ const fileRouter = router({
}); });
}), }),
delete: procedure.input(z.number()).mutation(async ({ input }) => { delete: procedure.input(z.number()).mutation(async ({ ctx, input }) => {
const fileData = await db.query.file.findFirst({ const fileData = await db.query.file.findFirst({
where: and(eq(file.id, input), isNull(file.deletedAt)), where: and(eq(file.id, input), isNull(file.deletedAt)),
with: { project: { columns: { visibility: true, userId: true } } },
}); });
if (!fileData) { if (!fileData) {
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
if (!hasPermission(ctx, fileData.project, "w")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [result] = await tx const [result] = await tx

View File

@ -5,6 +5,7 @@ import {
project, project,
insertProjectSchema, insertProjectSchema,
selectProjectSchema, selectProjectSchema,
ProjectSchema,
} from "../db/schema/project"; } from "../db/schema/project";
import { z } from "zod"; import { z } from "zod";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@ -15,6 +16,7 @@ import { ucwords } from "~/lib/utils";
import { hashPassword } from "../lib/crypto"; import { hashPassword } from "../lib/crypto";
import { createToken } from "../lib/jwt"; import { createToken } from "../lib/jwt";
import { file } from "../db/schema/file"; import { file } from "../db/schema/file";
import { Context } from "../api/trpc/context";
const projectRouter = router({ const projectRouter = router({
getAll: procedure getAll: procedure
@ -45,7 +47,7 @@ const projectRouter = router({
getById: procedure getById: procedure
.input(z.number().or(z.string())) .input(z.number().or(z.string()))
.query(async ({ input }) => { .query(async ({ ctx, input }) => {
const where = and( const where = and(
typeof input === "string" typeof input === "string"
? eq(project.slug, input) ? eq(project.slug, input)
@ -53,12 +55,20 @@ const projectRouter = router({
isNull(project.deletedAt) isNull(project.deletedAt)
); );
return db.query.project.findFirst({ const result = await db.query.project.findFirst({
where, where,
with: { with: {
user: { columns: { password: false } }, user: { columns: { password: false } },
}, },
}); });
if (!hasPermission(ctx, result, "r")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const isMutable = hasPermission(ctx, result, "w");
return { ...result, isMutable };
}), }),
create: procedure create: procedure
@ -127,6 +137,10 @@ const projectRouter = router({
throw new Error("Fork Project not found!"); throw new Error("Fork Project not found!");
} }
if (!hasPermission(ctx, forkProject, "r")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const forkFiles = await tx.query.file.findMany({ const forkFiles = await tx.query.file.findMany({
where: and( where: and(
eq(file.projectId, input.forkFromId), eq(file.projectId, input.forkFromId),
@ -146,7 +160,7 @@ const projectRouter = router({
await tx await tx
.update(project) .update(project)
.set({ settings: forkProject.settings }) .set({ settings: forkProject.settings, forkId: forkProject.id })
.where(eq(project.id, projectData.id)); .where(eq(project.id, projectData.id));
} else { } else {
await tx.insert(file).values([ await tx.insert(file).values([
@ -171,7 +185,7 @@ const projectRouter = router({
.omit({ slug: true, userId: true }) .omit({ slug: true, userId: true })
.required({ id: true }) .required({ id: true })
) )
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
const data = { ...input }; const data = { ...input };
const projectData = await db.query.project.findFirst({ const projectData = await db.query.project.findFirst({
@ -181,6 +195,10 @@ const projectRouter = router({
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
if (!hasPermission(ctx, projectData, "w")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (data.settings) { if (data.settings) {
data.settings = Object.assign( data.settings = Object.assign(
projectData.settings || {}, projectData.settings || {},
@ -197,7 +215,7 @@ const projectRouter = router({
return result; return result;
}), }),
delete: procedure.input(z.number()).mutation(async ({ input }) => { delete: procedure.input(z.number()).mutation(async ({ ctx, input }) => {
const projectData = await db.query.project.findFirst({ const projectData = await db.query.project.findFirst({
where: and(eq(project.id, input), isNull(project.deletedAt)), where: and(eq(project.id, input), isNull(project.deletedAt)),
}); });
@ -205,6 +223,10 @@ const projectRouter = router({
throw new TRPCError({ code: "NOT_FOUND" }); throw new TRPCError({ code: "NOT_FOUND" });
} }
if (!hasPermission(ctx, projectData, "r")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const [result] = await db const [result] = await db
.update(project) .update(project)
.set({ deletedAt: sql`CURRENT_TIMESTAMP` }) .set({ deletedAt: sql`CURRENT_TIMESTAMP` })
@ -232,4 +254,38 @@ const projectRouter = router({
}), }),
}); });
export function hasPermission(
ctx: Context,
project: Pick<ProjectSchema, "userId" | "visibility"> | undefined,
permission: "r" | "w"
) {
if (!project) {
return false;
}
let read = false,
write = false;
if (ctx.user?.id === project.userId) {
read = true;
write = true;
}
if (["public", "unlisted"].includes(project.visibility as never)) {
read = true;
}
return permission === "r" ? read : write;
}
export async function getProjectById(id: number) {
const projectData = await db.query.project.findFirst({
where: and(eq(project.id, id), isNull(project.deletedAt)),
});
if (!projectData) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return projectData;
}
export default projectRouter; export default projectRouter;