diff --git a/package.json b/package.json index 54b6748..64b5322 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "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", "start": "NODE_ENV=production tsx server/index.ts", "build": "vite build", @@ -23,6 +23,7 @@ "devDependencies": { "@swc/cli": "^0.3.9", "@types/bcrypt": "^5.0.2", + "@types/better-sqlite3": "^7.6.9", "@types/cookie-parser": "^1.4.6", "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.5", diff --git a/pages/project/@slug/components/editor.tsx b/pages/project/@slug/components/editor.tsx index 999ecc9..59e5778 100644 --- a/pages/project/@slug/components/editor.tsx +++ b/pages/project/@slug/components/editor.tsx @@ -17,11 +17,9 @@ import { useData } from "~/renderer/hooks"; import { Data } from "../+data"; import { useBreakpoint } from "~/hooks/useBreakpoint"; import StatusBar from "./status-bar"; -import { FiServer, FiTerminal } from "react-icons/fi"; +import { FiTerminal } from "react-icons/fi"; import SettingsDialog from "./settings-dialog"; import FileIcon from "~/components/ui/file-icon"; -import APIManager from "./api-manager"; -import { api } from "~/lib/api"; const Editor = () => { const { project, initialFiles } = useData(); diff --git a/pages/project/@slug/components/file-viewer.tsx b/pages/project/@slug/components/file-viewer.tsx index e9a2534..637ace8 100644 --- a/pages/project/@slug/components/file-viewer.tsx +++ b/pages/project/@slug/components/file-viewer.tsx @@ -1,11 +1,12 @@ import { getFileExt } from "~/lib/utils"; -import CodeEditor from "../../../../components/ui/code-editor"; import trpc from "~/lib/trpc"; import { useData } from "~/renderer/hooks"; import { Data } from "../+data"; import Spinner from "~/components/ui/spinner"; import { previewStore } from "../stores/web-preview"; import { useProjectContext } from "../context/project"; +import { Suspense, lazy } from "react"; +const CodeEditor = lazy(() => import("~/components/ui/code-editor")); type Props = { id: number; @@ -46,14 +47,20 @@ const FileViewer = ({ id }: Props) => { const ext = getFileExt(filename); return ( - - updateFileContent.mutate({ projectId: project.id, id, content: val }) - } - /> + }> + + updateFileContent.mutate({ + projectId: project.id, + id, + content: val, + }) + } + /> + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab06543..7f4cb02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,7 +94,7 @@ dependencies: version: 16.4.5 drizzle-orm: 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: specifier: ^0.5.1 version: 0.5.1(drizzle-orm@0.29.3)(zod@3.22.4) @@ -172,6 +172,9 @@ devDependencies: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/better-sqlite3': + specifier: ^7.6.9 + version: 7.6.9 '@types/cookie-parser': specifier: ^1.4.6 version: 1.4.6 @@ -2267,6 +2270,11 @@ packages: '@types/node': 20.11.19 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: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: @@ -3489,7 +3497,7 @@ packages: - supports-color 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==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' @@ -3560,6 +3568,7 @@ packages: sqlite3: optional: true dependencies: + '@types/better-sqlite3': 7.6.9 '@types/react': 18.2.57 better-sqlite3: 9.4.2 react: 18.2.0 @@ -3571,7 +3580,7 @@ packages: drizzle-orm: '>=0.23.13' zod: '*' 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 dev: false diff --git a/server/db/drizzle/0000_swift_mandroid.sql b/server/db/drizzle/0000_swift_mandroid.sql new file mode 100644 index 0000000..d8cd3b5 --- /dev/null +++ b/server/db/drizzle/0000_swift_mandroid.sql @@ -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`); \ No newline at end of file diff --git a/server/db/drizzle/meta/0000_snapshot.json b/server/db/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..b021366 --- /dev/null +++ b/server/db/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/server/db/drizzle/meta/_journal.json b/server/db/drizzle/meta/_journal.json new file mode 100644 index 0000000..607012d --- /dev/null +++ b/server/db/drizzle/meta/_journal.json @@ -0,0 +1 @@ +{"version":"5","dialect":"sqlite","entries":[{"idx":0,"version":"5","when":1709221275637,"tag":"0000_swift_mandroid","breakpoints":true}]} \ No newline at end of file diff --git a/server/db/migrate.ts b/server/db/migrate.ts index 538b9e5..88c9927 100644 --- a/server/db/migrate.ts +++ b/server/db/migrate.ts @@ -1,5 +1,9 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; 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" }); process.exit(); diff --git a/server/db/schema/project.ts b/server/db/schema/project.ts index fd0ee06..f0cba0c 100644 --- a/server/db/schema/project.ts +++ b/server/db/schema/project.ts @@ -1,5 +1,11 @@ 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 { z } from "zod"; import { user } from "./user"; @@ -23,6 +29,7 @@ export const project = sqliteTable( userId: integer("user_id") .notNull() .references(() => user.id), + forkId: integer("fork_id"), slug: text("slug").notNull().unique(), title: text("title").notNull(), @@ -40,12 +47,21 @@ export const project = sqliteTable( deletedAt: text("deleted_at"), }, (table) => ({ + forkIdFk: foreignKey({ + columns: [table.forkId], + foreignColumns: [table.id], + name: "project_fork_id_fk", + }), visibilityEnum: index("project_visibility_idx").on(table.visibility), }) ); export const projectRelations = relations(project, ({ one, many }) => ({ files: many(file), + fork: one(project, { + fields: [project.forkId], + references: [project.id], + }), user: one(user, { fields: [project.userId], references: [user.id], diff --git a/server/routers/file.ts b/server/routers/file.ts index 05d579f..3338f8f 100644 --- a/server/routers/file.ts +++ b/server/routers/file.ts @@ -4,6 +4,7 @@ import { procedure, router } from "../api/trpc"; import { file, insertFileSchema, selectFileSchema } from "../db/schema/file"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; +import { getProjectById, hasPermission } from "./project"; const fileRouter = router({ getAll: procedure @@ -14,7 +15,13 @@ const fileRouter = router({ 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({ where: and( eq(file.projectId, opt.projectId), @@ -29,10 +36,21 @@ const fileRouter = router({ return files; }), - getById: procedure.input(z.number()).query(async ({ input }) => { - return db.query.file.findFirst({ + getById: procedure.input(z.number()).query(async ({ ctx, input }) => { + const result = await db.query.file.findFirst({ 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 @@ -44,7 +62,13 @@ const fileRouter = router({ 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 = ""; if (input.parentId) { const parent = await db.query.file.findFirst({ @@ -58,7 +82,7 @@ const fileRouter = router({ } const data: z.infer = { - projectId: input.projectId, + projectId: project.id, parentId: input.parentId, path: basePath + input.filename, filename: input.filename, @@ -71,17 +95,22 @@ const fileRouter = router({ update: procedure .input(selectFileSchema.partial().required({ id: true, projectId: true })) - .mutation(async ({ input }) => { + .mutation(async ({ ctx, input }) => { const fileData = await db.query.file.findFirst({ where: and( eq(file.projectId, input.projectId), eq(file.id, input.id), isNull(file.deletedAt) ), + with: { project: { columns: { visibility: true, userId: true } } }, }); + if (!fileData) { throw new TRPCError({ code: "NOT_FOUND" }); } + if (!hasPermission(ctx, fileData.project, "w")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } return db.transaction(async (tx) => { 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({ where: and(eq(file.id, input), isNull(file.deletedAt)), + with: { project: { columns: { visibility: true, userId: true } } }, }); if (!fileData) { throw new TRPCError({ code: "NOT_FOUND" }); } + if (!hasPermission(ctx, fileData.project, "w")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } return db.transaction(async (tx) => { const [result] = await tx diff --git a/server/routers/project.ts b/server/routers/project.ts index 844deb0..a4652ec 100644 --- a/server/routers/project.ts +++ b/server/routers/project.ts @@ -5,6 +5,7 @@ import { project, insertProjectSchema, selectProjectSchema, + ProjectSchema, } from "../db/schema/project"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; @@ -15,6 +16,7 @@ import { ucwords } from "~/lib/utils"; import { hashPassword } from "../lib/crypto"; import { createToken } from "../lib/jwt"; import { file } from "../db/schema/file"; +import { Context } from "../api/trpc/context"; const projectRouter = router({ getAll: procedure @@ -45,7 +47,7 @@ const projectRouter = router({ getById: procedure .input(z.number().or(z.string())) - .query(async ({ input }) => { + .query(async ({ ctx, input }) => { const where = and( typeof input === "string" ? eq(project.slug, input) @@ -53,12 +55,20 @@ const projectRouter = router({ isNull(project.deletedAt) ); - return db.query.project.findFirst({ + const result = await db.query.project.findFirst({ where, with: { 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 @@ -127,6 +137,10 @@ const projectRouter = router({ throw new Error("Fork Project not found!"); } + if (!hasPermission(ctx, forkProject, "r")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const forkFiles = await tx.query.file.findMany({ where: and( eq(file.projectId, input.forkFromId), @@ -146,7 +160,7 @@ const projectRouter = router({ await tx .update(project) - .set({ settings: forkProject.settings }) + .set({ settings: forkProject.settings, forkId: forkProject.id }) .where(eq(project.id, projectData.id)); } else { await tx.insert(file).values([ @@ -171,7 +185,7 @@ const projectRouter = router({ .omit({ slug: true, userId: true }) .required({ id: true }) ) - .mutation(async ({ input }) => { + .mutation(async ({ ctx, input }) => { const data = { ...input }; const projectData = await db.query.project.findFirst({ @@ -181,6 +195,10 @@ const projectRouter = router({ throw new TRPCError({ code: "NOT_FOUND" }); } + if (!hasPermission(ctx, projectData, "w")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + if (data.settings) { data.settings = Object.assign( projectData.settings || {}, @@ -197,7 +215,7 @@ const projectRouter = router({ 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({ where: and(eq(project.id, input), isNull(project.deletedAt)), }); @@ -205,6 +223,10 @@ const projectRouter = router({ throw new TRPCError({ code: "NOT_FOUND" }); } + if (!hasPermission(ctx, projectData, "r")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const [result] = await db .update(project) .set({ deletedAt: sql`CURRENT_TIMESTAMP` }) @@ -232,4 +254,38 @@ const projectRouter = router({ }), }); +export function hasPermission( + ctx: Context, + project: Pick | 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;