diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts index 372453981..d02a7e45a 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts @@ -14,6 +14,7 @@ import { createHttpError, toErrorResponse } from "@/lib/api-error"; import mergeWith from "lodash.mergewith"; import { buildCommitTokens, resolveCommitIdentity, resolveCommitMessage } from "@/lib/commit-message"; import { requireApiUserSession } from "@/lib/session-server"; +import { githubSaveFile } from "@/lib/utils/github-save-file"; /** * Create, update and delete individual files in a GitHub repository. @@ -160,28 +161,6 @@ export async function POST( } } break; - case "media": - if (!data.name) throw new Error(`"name" is required for media.`); - - schema = getSchemaByName(config?.object, data.name, "media"); - if (!schema) throw new Error(`Media schema not found for ${data.name}.`); - schemaCommitTemplates = schema?.commit?.templates; - schemaCommitIdentity = schema?.commit?.identity; - - if (!normalizedPath.startsWith(schema.input)) throw new Error(`Invalid path "${params.path}" for media "${data.name}".`); - - if (getFileName(normalizedPath) === ".gitkeep") { - // Folder creation - contentBase64 = ""; - } else { - if ( - schema.extensions?.length > 0 && - !schema.extensions.includes(getFileExtension(normalizedPath)) - ) throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for media.`); - - contentBase64 = data.content; - } - break; case "settings": assertGithubIdentity(user, "Only GitHub users can manage settings."); if (normalizedPath !== ".pages.yml") throw new Error(`Invalid path "${params.path}" for settings.`); @@ -289,163 +268,6 @@ export async function POST( } }; -// Helper function to save a file to GitHub (with retry logic for new files) -const githubSaveFile = async ( - token: string, - owner: string, - repo: string, - branch: string, - path: string, - contentBase64: string, - sha?: string, - options?: { - configObject?: Record; - templatesOverride?: Record; - contentName?: string; - user?: string; - onConflict?: "rename" | "error"; - committer?: { name: string; email: string }; - }, -) => { - // We disable retries for 409 errors as it means the file has changed (conflict on SHA) - const octokit = createOctokitInstance(token, { retry: { doNotRetry: [409] } }); - - const message = resolveCommitMessage({ - configObject: options?.configObject, - templatesOverride: options?.templatesOverride, - action: sha ? "update" : "create", - tokens: buildCommitTokens({ - action: sha ? "update" : "create", - owner, - repo, - branch, - path, - contentName: options?.contentName, - user: options?.user, - userName: options?.committer?.name, - userEmail: options?.committer?.email, - }), - }); - - try { - // First attempt: try with original path - const response = await octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo, - path, - message, - content: contentBase64, - branch, - sha: sha || undefined, - committer: options?.committer, - }); - - if (response.data.content && response.data.commit) { - return response; - } - throw new Error("Invalid response structure"); - } catch (error: any) { - const githubMessage = typeof error?.response?.data?.message === "string" - ? error.response.data.message - : undefined; - - if (error.status === 409) { - if (githubMessage?.includes("Repository rule violations found")) { - throw createHttpError( - "This repository requires changes through a pull request. Save to a different branch or fork, or ask a maintainer to relax the repository rule for direct edits.", - 409, - ); - } - - if (sha) { - throw createHttpError( - "File has changed since you last loaded it. Please refresh the page and try again.", - 409, - ); - } - } - - // Only handle 422 errors for new files (no sha) - if (error.status === 422 && !sha) { - if (options?.onConflict === "error") { - throw createHttpError(`File \"${path}\" already exists.`, 409); - } - - // Get directory contents to find next available name - const parentDir = getParentPath(path); - const { data } = await octokit.rest.repos.getContent({ - owner, - repo, - path: parentDir || '.', - ref: branch, - }); - - if (!Array.isArray(data)) { - throw new Error('Expected directory listing'); - } - - const basename = path.split('/').pop() || ""; - const lastDotIndex = basename.lastIndexOf("."); - const filename = lastDotIndex > 0 ? basename.slice(0, lastDotIndex) : basename; - const extension = lastDotIndex > 0 ? basename.slice(lastDotIndex + 1) : ""; - const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const escapedFilename = escapeRegExp(filename); - const escapedExtension = escapeRegExp(extension); - const pattern = extension - ? new RegExp(`^${escapedFilename}-(\\d+)\\.${escapedExtension}$`) - : new RegExp(`^${escapedFilename}-(\\d+)$`); - const maxNumber = Math.max(0, ...data - .map(file => { - const match = file.name.match(pattern); - return match ? parseInt(match[1], 10) : 0; - })); - - // Try up to 3 times with incrementing numbers - for (let i = 1; i <= 3; i++) { - const candidateFilename = extension - ? `${filename}-${maxNumber + i}.${extension}` - : `${filename}-${maxNumber + i}`; - const newPath = `${parentDir ? parentDir + '/' : ''}${candidateFilename}`; - const fallbackMessage = resolveCommitMessage({ - configObject: options?.configObject, - templatesOverride: options?.templatesOverride, - action: "create", - tokens: buildCommitTokens({ - action: "create", - owner, - repo, - branch, - path: newPath, - contentName: options?.contentName, - user: options?.user, - userName: options?.committer?.name, - userEmail: options?.committer?.email, - }), - }); - try { - const response = await octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo, - path: newPath, - message: fallbackMessage, - content: contentBase64, - branch, - committer: options?.committer, - }); - - if (response.data.content && response.data.commit) { - return response; - } - } catch (error: any) { - if (i === 3 || error.status !== 422) throw error; - // Continue to next attempt if 422 (file already exists) - } - } - } - throw error; - } -}; - export async function DELETE( request: NextRequest, context: { params: Promise<{ owner: string, repo: string, branch: string, path: string }> } diff --git a/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/chunk/route.ts b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/chunk/route.ts new file mode 100644 index 000000000..58ecba5fc --- /dev/null +++ b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/chunk/route.ts @@ -0,0 +1,54 @@ +import { db } from "@/db"; +import { uploadChunkTable } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { requireApiUserSession } from "@/lib/session-server"; +import { createHttpError, toErrorResponse } from "@/lib/api-error"; +import { CHUNK_BYTES, MAX_TOTAL_BYTES } from "@/lib/utils/upload-media"; + +// chunk 0 rides inline in finalize, chunks 1..MAX_CHUNK_IDX go here +const MAX_CHUNK_IDX = Math.ceil(MAX_TOTAL_BYTES / CHUNK_BYTES) - 1; + +export async function POST(request: Request) { + try { + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + + const formData = await request.formData(); + const uploadId = formData.get("uploadId"); + const idxRaw = formData.get("idx"); + const chunk = formData.get("chunk"); + + if (typeof uploadId !== "string" || uploadId.length === 0 || uploadId.length > 64) { + throw createHttpError(`Invalid "uploadId".`, 400); + } + const idx = typeof idxRaw === "string" ? parseInt(idxRaw, 10) : NaN; + if (!Number.isInteger(idx) || idx < 1 || idx > MAX_CHUNK_IDX) { + throw createHttpError(`"idx" must be between 1 and ${MAX_CHUNK_IDX}.`, 400); + } + if (!(chunk instanceof Blob)) { + throw createHttpError(`Invalid "chunk".`, 400); + } + if (chunk.size === 0 || chunk.size > CHUNK_BYTES) { + throw createHttpError(`Chunk size must be between 1 and ${CHUNK_BYTES} bytes.`, 413); + } + + const buffer = Buffer.from(await chunk.arrayBuffer()); + + await db.insert(uploadChunkTable).values({ + uploadId, + userId: user.id, + chunkIdx: idx, + data: buffer, + }).onConflictDoUpdate({ + target: [uploadChunkTable.uploadId, uploadChunkTable.chunkIdx], + set: { data: buffer, createdAt: new Date() }, + setWhere: eq(uploadChunkTable.userId, user.id), + }); + + return Response.json({ status: "success" }); + } catch (error: any) { + if (!error?.status || error.status >= 500) console.error(error); + return toErrorResponse(error); + } +} diff --git a/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts index 64f13ee1b..aefe91c5d 100644 --- a/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts @@ -1,13 +1,28 @@ +import { after } from "next/server"; +import { db } from "@/db"; +import { uploadChunkTable } from "@/db/schema"; +import { and, asc, eq, lt, or } from "drizzle-orm"; import { getRepoReadContext } from "@/lib/api-repo-context"; -import { getFileExtension, normalizePath } from "@/lib/utils/file"; -import { getMediaCache } from "@/lib/github-cache-file"; +import { getFileExtension, getFileName, normalizePath } from "@/lib/utils/file"; +import { getMediaCache, updateFileCache } from "@/lib/github-cache-file"; import { createHttpError, toErrorResponse } from "@/lib/api-error"; +import { requireApiUserSession } from "@/lib/session-server"; +import { getToken } from "@/lib/token"; +import { getConfig } from "@/lib/config-store"; +import { getSchemaByName } from "@/lib/schema"; +import { resolveCommitIdentity } from "@/lib/commit-message"; +import { githubSaveFile } from "@/lib/utils/github-save-file"; +import { CHUNK_BYTES, MAX_TOTAL_BYTES } from "@/lib/utils/upload-media"; + +const STALE_CHUNK_AGE_MS = 10 * 60 * 1000; /** - * Get the list of media files in a directory. + * GET: list media files in a directory. + * POST: finalize a chunked media upload (chunks staged via the /chunk sub-route). + * + * GET /api/[owner]/[repo]/[branch]/media/[name]/[path] + * POST /api/[owner]/[repo]/[branch]/media/[name]/[path] * - * GET /api/[owner]/[repo]/[branch]/media/[path] - * * Requires authentication. */ @@ -84,6 +99,187 @@ export async function GET( } } +export async function POST( + request: Request, + context: { params: Promise<{ owner: string, repo: string, branch: string, name: string, path: string }> } +) { + try { + const sessionResult = await requireApiUserSession(); + if ("response" in sessionResult) return sessionResult.response; + const user = sessionResult.user; + + const { owner, repo, branch, name, path } = await context.params; + + const form = await request.formData(); + const uploadId = typeof form.get("uploadId") === "string" ? form.get("uploadId") as string : ""; + const totalChunksRaw = form.get("totalChunks"); + const totalChunks = typeof totalChunksRaw === "string" ? parseInt(totalChunksRaw, 10) : NaN; + const shaRaw = form.get("sha"); + const sha = typeof shaRaw === "string" && shaRaw.length > 0 ? shaRaw : undefined; + const onConflict = form.get("onConflict") === "error" ? "error" : "rename"; + const firstChunk = form.get("firstChunk"); + + if (!uploadId || uploadId.length > 64) throw createHttpError(`Invalid "uploadId".`, 400); + if (!Number.isInteger(totalChunks) || totalChunks < 1) { + throw createHttpError(`"totalChunks" must be a positive integer.`, 400); + } + if (!name) throw createHttpError(`Missing "name".`, 400); + if (!(firstChunk instanceof Blob)) { + throw createHttpError(`Missing "firstChunk".`, 400); + } + if (firstChunk.size > CHUNK_BYTES) { + throw createHttpError(`"firstChunk" too large.`, 413); + } + + const { token } = await getToken(user, owner, repo, true); + if (!token) throw new Error("Token not found"); + + const normalizedPath = normalizePath(path); + const isFolderMarker = getFileName(normalizedPath) === ".gitkeep"; + + if (firstChunk.size === 0 && !isFolderMarker) { + throw createHttpError(`Empty "firstChunk" only allowed for .gitkeep folder markers.`, 400); + } + + const config = await getConfig(owner, repo, branch, { getToken: async () => token }); + if (!config) throw new Error(`Configuration not found for ${owner}/${repo}/${branch}.`); + + const schema = getSchemaByName(config.object, name, "media"); + if (!schema) throw new Error(`Media schema not found for ${name}.`); + if (!normalizedPath.startsWith(schema.input)) { + throw new Error(`Invalid path "${path}" for media "${name}".`); + } + if ( + !isFolderMarker && + schema.extensions?.length > 0 && + !schema.extensions.includes(getFileExtension(normalizedPath)) + ) { + throw new Error(`Invalid extension "${getFileExtension(normalizedPath)}" for media.`); + } + + const expectedFromDb = totalChunks - 1; + const chunksFromDb = expectedFromDb > 0 + ? await db + .select({ chunkIdx: uploadChunkTable.chunkIdx, data: uploadChunkTable.data }) + .from(uploadChunkTable) + .where(and( + eq(uploadChunkTable.uploadId, uploadId), + eq(uploadChunkTable.userId, user.id), + )) + .orderBy(asc(uploadChunkTable.chunkIdx)) + : []; + + if (chunksFromDb.length !== expectedFromDb) { + throw createHttpError( + `Expected ${expectedFromDb} staged chunks but found ${chunksFromDb.length}.`, + 400, + ); + } + for (let i = 0; i < chunksFromDb.length; i++) { + if (chunksFromDb[i].chunkIdx !== i + 1) { + throw createHttpError(`Missing chunk at index ${i + 1}.`, 400); + } + } + + const buffers = [ + Buffer.from(await firstChunk.arrayBuffer()), + ...chunksFromDb.map(c => c.data), + ]; + const totalSize = buffers.reduce((acc, b) => acc + b.length, 0); + if (totalSize > MAX_TOTAL_BYTES) { + throw createHttpError( + `File too large (${totalSize} bytes). Max ${MAX_TOTAL_BYTES} bytes.`, + 413, + ); + } + const contentBase64 = Buffer.concat(buffers).toString("base64"); + + const schemaCommitTemplates = schema?.commit?.templates; + const schemaCommitIdentity = schema?.commit?.identity; + const commitIdentity = resolveCommitIdentity({ + configObject: config.object, + identityOverride: schemaCommitIdentity, + }); + const committer = (commitIdentity === "user" && user.email) + ? { name: user.name?.trim() || user.email, email: user.email } + : undefined; + + const response = await githubSaveFile( + token, + owner, + repo, + branch, + normalizedPath, + contentBase64, + sha, + { + configObject: config.object, + templatesOverride: schemaCommitTemplates, + contentName: name, + user: user.email || user.name || String(user.id || ""), + onConflict, + committer, + } + ); + + const savedPath = response?.data.content?.path; + + if (response?.data.content && response?.data.commit) { + await updateFileCache( + 'media', + owner, + repo, + branch, + { + type: sha ? 'modify' : 'add', + path: response.data.content.path!, + sha: response.data.content.sha!, + content: Buffer.from(contentBase64, 'base64').toString('utf-8'), + size: response.data.content.size, + downloadUrl: response.data.content.download_url, + commit: { + sha: response.data.commit.sha!, + timestamp: new Date(response.data.commit.committer?.date ?? new Date().toISOString()).getTime() + } + } + ); + } + + after(async () => { + try { + await db.delete(uploadChunkTable).where(or( + and( + eq(uploadChunkTable.uploadId, uploadId), + eq(uploadChunkTable.userId, user.id), + ), + lt(uploadChunkTable.createdAt, new Date(Date.now() - STALE_CHUNK_AGE_MS)), + )); + } catch (error) { + console.error("Chunk cleanup after finalize failed", error); + } + }); + + return Response.json({ + status: "success", + message: savedPath !== normalizedPath + ? `File "${normalizedPath}" saved successfully but renamed to "${savedPath}" to avoid naming conflict.` + : `File "${normalizedPath}" saved successfully.`, + data: { + type: response?.data.content?.type, + sha: response?.data.content?.sha, + name: response?.data.content?.name, + path: savedPath, + extension: getFileExtension(response?.data.content?.name || ""), + size: response?.data.content?.size, + url: response?.data.content?.download_url, + } + }); + } catch (error: any) { + if (!error?.status || error.status >= 500) console.error(error); + return toErrorResponse(error); + } +} + const normalizeMediaPath = ( rawPath: string, owner: string, diff --git a/components/folder-create.tsx b/components/folder-create.tsx index a26075d10..d6bda3b23 100644 --- a/components/folder-create.tsx +++ b/components/folder-create.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { uploadMediaChunked } from "@/lib/utils/upload-media"; type FolderCreateResult = { path: string; @@ -56,35 +57,49 @@ const FolderCreate = ({ setIsSubmitting(true); try { - const createPromise: Promise<{ + let createPromise: Promise<{ status: string; message?: string; data: FolderCreateResult; - }> = fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(fullNewPath + "/.gitkeep")}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type, - name, - content: "", + }>; + + if (type === "media") { + createPromise = uploadMediaChunked({ + file: new File([], ".gitkeep"), + owner: config.owner, + repo: config.repo, + branch: config.branch, + mediaName: name!, + targetPath: `${fullNewPath}/.gitkeep`, onConflict: "error", - }), - }).then(async (response) => { - const payload = await response.json().catch(() => null); - if (!response.ok) { - if (response.status === 409) { - throw new Error(`Folder \"${fullNewPath}\" already exists.`); - } + }).then((data) => ({ status: "success", data: data as FolderCreateResult })); + } else { + createPromise = fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(fullNewPath + "/.gitkeep")}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type, + name, + content: "", + onConflict: "error", + }), + }).then(async (response) => { + const payload = await response.json().catch(() => null); + if (!response.ok) { + if (response.status === 409) { + throw new Error(`Folder \"${fullNewPath}\" already exists.`); + } - throw new Error(payload?.message || "Failed to create folder"); - } + throw new Error(payload?.message || "Failed to create folder"); + } - if (!payload || payload.status !== "success") { - throw new Error(payload?.message || "Failed to create folder"); - } + if (!payload || payload.status !== "success") { + throw new Error(payload?.message || "Failed to create folder"); + } - return payload; - }); + return payload; + }); + } await toast.promise(createPromise, { loading: `Creating folder "${fullNewPath}"`, diff --git a/components/media/media-upload.tsx b/components/media/media-upload.tsx index a9d4ce289..ffa275fe3 100644 --- a/components/media/media-upload.tsx +++ b/components/media/media-upload.tsx @@ -6,7 +6,7 @@ import { getUploadFileName, joinPathSegments } from "@/lib/utils/file"; import { toast } from "sonner"; import { getSchemaByName } from "@/lib/schema"; import { cn } from "@/lib/utils"; -import { requireApiSuccess } from "@/lib/api-client"; +import { uploadMediaChunked } from "@/lib/utils/upload-media"; import type { FileSaveData } from "@/types/api"; interface MediaUploadContextValue { @@ -70,40 +70,20 @@ function MediaUploadRoot({ children, path, onUpload, media, extensions, multiple file.name, rename ?? configMedia?.rename, ); - - const uploadPromise = (async () => { - const content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const base64Content = (reader.result as string).replace(/^(.+,)/, ""); - resolve(base64Content); - }; - reader.onerror = () => reject(new Error("Failed to read file")); - reader.readAsDataURL(file); - }); - - const fullPath = joinPathSegments([path ?? "", uploadFilename]); - const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(fullPath)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "media", - name: configMedia.name, - content, - }), - }); - - const data = await requireApiSuccess( - response, - "Failed to upload file", - ); - - return data.data as FileSaveData; - })(); + const fullPath = joinPathSegments([path ?? "", uploadFilename]); + + const uploadPromise = uploadMediaChunked({ + file, + owner: config.owner, + repo: config.repo, + branch: config.branch, + mediaName: configMedia.name, + targetPath: fullPath, + }); await toast.promise(uploadPromise, { loading: `Uploading ${file.name}`, - success: (savedEntry) => { + success: (savedEntry: FileSaveData) => { onUpload?.(savedEntry); return `Uploaded ${file.name}`; }, diff --git a/db/migrations/0013_upload_chunks.sql b/db/migrations/0013_upload_chunks.sql new file mode 100644 index 000000000..5c5c75c29 --- /dev/null +++ b/db/migrations/0013_upload_chunks.sql @@ -0,0 +1,12 @@ +CREATE TABLE "upload_chunk" ( + "id" serial PRIMARY KEY NOT NULL, + "upload_id" text NOT NULL, + "user_id" text NOT NULL, + "chunk_idx" integer NOT NULL, + "data" bytea NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "upload_chunk" ADD CONSTRAINT "upload_chunk_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_upload_chunk_uploadId_chunkIdx" ON "upload_chunk" USING btree ("upload_id","chunk_idx");--> statement-breakpoint +CREATE INDEX "idx_upload_chunk_createdAt" ON "upload_chunk" USING btree ("created_at"); \ No newline at end of file diff --git a/db/migrations/meta/0013_snapshot.json b/db/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..ad3148d18 --- /dev/null +++ b/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,1611 @@ +{ + "id": "cd186a8b-62c3-4425-9190-ae26f6e46d0b", + "prevId": "4655a2e1-f322-41b1-b587-9fa5d038d98a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_account_userId": { + "name": "idx_account_userId", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_providerId": { + "name": "idx_account_providerId", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_run": { + "name": "action_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_ref": { + "name": "workflow_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_name": { + "name": "action_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_type": { + "name": "context_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_name": { + "name": "context_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_path": { + "name": "context_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_run_id": { + "name": "workflow_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_action_run_owner_repo_createdAt": { + "name": "idx_action_run_owner_repo_createdAt", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_action_run_owner_repo_actionName": { + "name": "idx_action_run_owner_repo_actionName", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_action_run_owner_repo_status": { + "name": "idx_action_run_owner_repo_status", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_action_run_context": { + "name": "idx_action_run_context", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_action_run_workflowRunId": { + "name": "idx_action_run_workflowRunId", + "columns": [ + { + "expression": "workflow_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache_file_meta": { + "name": "cache_file_meta", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'branch'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_timestamp": { + "name": "commit_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ok'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_cache_file_meta_owner_repo_branch_path_context": { + "name": "idx_cache_file_meta_owner_repo_branch_path_context", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache_file": { + "name": "cache_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'collection'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_path": { + "name": "parent_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "download_url": { + "name": "download_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_timestamp": { + "name": "commit_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_cache_file_owner_repo_branch_parentPath": { + "name": "idx_cache_file_owner_repo_branch_parentPath", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cache_file_owner_repo_branch_path": { + "name": "idx_cache_file_owner_repo_branch_path", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cache_permission": { + "name": "cache_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_cache_permission_githubId_owner_repo": { + "name": "idx_cache_permission_githubId_owner_repo", + "columns": [ + { + "expression": "github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collaborator_invite": { + "name": "collaborator_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_collaborator_invite_token": { + "name": "uq_collaborator_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collaborator_invite_owner_repo_email": { + "name": "idx_collaborator_invite_owner_repo_email", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_collaborator_invite_owner_repo_email_ci": { + "name": "uq_collaborator_invite_owner_repo_email_ci", + "columns": [ + { + "expression": "lower(\"owner\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"repo\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collaborator": { + "name": "collaborator", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_collaborator_owner_repo_email": { + "name": "idx_collaborator_owner_repo_email", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_collaborator_userId": { + "name": "idx_collaborator_userId", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_collaborator_owner_repo_email_ci": { + "name": "uq_collaborator_owner_repo_email_ci", + "columns": [ + { + "expression": "lower(\"owner\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"repo\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collaborator_user_id_user_id_fk": { + "name": "collaborator_user_id_user_id_fk", + "tableFrom": "collaborator", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collaborator_invited_by_user_id_fk": { + "name": "collaborator_invited_by_user_id_fk", + "tableFrom": "collaborator", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.config": { + "name": "config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_config_owner_repo_branch": { + "name": "idx_config_owner_repo_branch", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_token": { + "name": "github_installation_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "uq_github_installation_token_installationId": { + "name": "uq_github_installation_token_installationId", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_userId": { + "name": "idx_session_userId", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_chunk": { + "name": "upload_chunk", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_idx": { + "name": "chunk_idx", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_upload_chunk_uploadId_chunkIdx": { + "name": "uq_upload_chunk_uploadId_chunkIdx", + "columns": [ + { + "expression": "upload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_idx", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_upload_chunk_createdAt": { + "name": "idx_upload_chunk_createdAt", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upload_chunk_user_id_user_id_fk": { + "name": "upload_chunk_user_id_user_id_fk", + "tableFrom": "upload_chunk", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 8f67a9031..5dd089df7 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1778731770759, "tag": "0012_collaborator_invites", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1782298049440, + "tag": "0013_upload_chunks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index b301fa740..fadd0c4bf 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -9,9 +9,16 @@ import { index, uniqueIndex, jsonb, + customType, } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; +const bytea = customType<{ data: Buffer; driverData: Buffer; notNull: true; default: false }>({ + dataType() { + return "bytea"; + }, +}); + const userTable = pgTable("user", { id: text("id").notNull().primaryKey(), name: text("name").notNull(), @@ -209,6 +216,18 @@ const actionRunTable = pgTable("action_run", { idx_action_run_workflowRunId: uniqueIndex("idx_action_run_workflowRunId").on(table.workflowRunId), })); +const uploadChunkTable = pgTable("upload_chunk", { + id: serial("id").primaryKey(), + uploadId: text("upload_id").notNull(), + userId: text("user_id").notNull().references(() => userTable.id, { onDelete: "cascade" }), + chunkIdx: integer("chunk_idx").notNull(), + data: bytea("data").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), +}, (table) => [ + uniqueIndex("uq_upload_chunk_uploadId_chunkIdx").on(table.uploadId, table.chunkIdx), + index("idx_upload_chunk_createdAt").on(table.createdAt), +]); + export { userTable, sessionTable, @@ -221,5 +240,6 @@ export { cacheFileTable, cacheFileMetaTable, cachePermissionTable, - actionRunTable + actionRunTable, + uploadChunkTable }; diff --git a/fields/core/rich-text/edit-component.tsx b/fields/core/rich-text/edit-component.tsx index e18e67cce..77b31b6d2 100644 --- a/fields/core/rich-text/edit-component.tsx +++ b/fields/core/rich-text/edit-component.tsx @@ -38,7 +38,7 @@ import { normalizeMediaPath, normalizePath, } from "@/lib/utils/file"; -import type { ApiResponse, FileSaveData } from "@/types/api"; +import { uploadMediaChunked } from "@/lib/utils/upload-media"; import type { Field } from "@/types/field"; import "./edit-component.css"; @@ -772,14 +772,6 @@ const EditComponent = forwardRef( ); } - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result ?? "")); - reader.onerror = () => - reject(new Error("Failed to read image file.")); - reader.readAsDataURL(file); - }); - const content = dataUrl.replace(/^(.+,)/, ""); const uploadFilename = getUploadFileName( file.name, options.rename ?? mediaConfig.rename, @@ -789,30 +781,16 @@ const EditComponent = forwardRef( uploadFilename, ]); - const response = await fetch( - `/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(targetPath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - type: "media", - name: mediaConfig.name, - content, - }), - }, - ); - if (!response.ok) { - throw new Error( - `Failed to upload file: ${response.status} ${response.statusText}`, - ); - } - - const payload = (await response.json()) as ApiResponse; - if (payload.status !== "success") { - throw new Error(payload.message); - } + const saved = await uploadMediaChunked({ + file, + owner: config.owner, + repo: config.repo, + branch: config.branch, + mediaName: mediaConfig.name, + targetPath, + }); - const uploadedPath = payload.data.path || targetPath; + const uploadedPath = saved.path || targetPath; const src = await toDisplayImageUrl(uploadedPath); return { src, diff --git a/lib/utils/github-save-file.ts b/lib/utils/github-save-file.ts new file mode 100644 index 000000000..8e53323f7 --- /dev/null +++ b/lib/utils/github-save-file.ts @@ -0,0 +1,156 @@ +import { createOctokitInstance } from "@/lib/utils/octokit"; +import { createHttpError } from "@/lib/api-error"; +import { getParentPath } from "@/lib/utils/file"; +import { buildCommitTokens, resolveCommitMessage } from "@/lib/commit-message"; + +type GithubSaveFileOptions = { + configObject?: Record; + templatesOverride?: Record; + contentName?: string; + user?: string; + onConflict?: "rename" | "error"; + committer?: { name: string; email: string }; +}; + +export const githubSaveFile = async ( + token: string, + owner: string, + repo: string, + branch: string, + path: string, + contentBase64: string, + sha?: string, + options?: GithubSaveFileOptions, +) => { + const octokit = createOctokitInstance(token, { retry: { doNotRetry: [409] } }); + + const message = resolveCommitMessage({ + configObject: options?.configObject, + templatesOverride: options?.templatesOverride, + action: sha ? "update" : "create", + tokens: buildCommitTokens({ + action: sha ? "update" : "create", + owner, + repo, + branch, + path, + contentName: options?.contentName, + user: options?.user, + userName: options?.committer?.name, + userEmail: options?.committer?.email, + }), + }); + + try { + const response = await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path, + message, + content: contentBase64, + branch, + sha: sha || undefined, + committer: options?.committer, + }); + + if (response.data.content && response.data.commit) { + return response; + } + throw new Error("Invalid response structure"); + } catch (error: any) { + const githubMessage = typeof error?.response?.data?.message === "string" + ? error.response.data.message + : undefined; + + if (error.status === 409) { + if (githubMessage?.includes("Repository rule violations found")) { + throw createHttpError( + "This repository requires changes through a pull request. Save to a different branch or fork, or ask a maintainer to relax the repository rule for direct edits.", + 409, + ); + } + + if (sha) { + throw createHttpError( + "File has changed since you last loaded it. Please refresh the page and try again.", + 409, + ); + } + } + + if (error.status === 422 && !sha) { + if (options?.onConflict === "error") { + throw createHttpError(`File \"${path}\" already exists.`, 409); + } + + const parentDir = getParentPath(path); + const { data } = await octokit.rest.repos.getContent({ + owner, + repo, + path: parentDir || '.', + ref: branch, + }); + + if (!Array.isArray(data)) { + throw new Error('Expected directory listing'); + } + + const basename = path.split('/').pop() || ""; + const lastDotIndex = basename.lastIndexOf("."); + const filename = lastDotIndex > 0 ? basename.slice(0, lastDotIndex) : basename; + const extension = lastDotIndex > 0 ? basename.slice(lastDotIndex + 1) : ""; + const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escapedFilename = escapeRegExp(filename); + const escapedExtension = escapeRegExp(extension); + const pattern = extension + ? new RegExp(`^${escapedFilename}-(\\d+)\\.${escapedExtension}$`) + : new RegExp(`^${escapedFilename}-(\\d+)$`); + const maxNumber = Math.max(0, ...data + .map(file => { + const match = file.name.match(pattern); + return match ? parseInt(match[1], 10) : 0; + })); + + for (let i = 1; i <= 3; i++) { + const candidateFilename = extension + ? `${filename}-${maxNumber + i}.${extension}` + : `${filename}-${maxNumber + i}`; + const newPath = `${parentDir ? parentDir + '/' : ''}${candidateFilename}`; + const fallbackMessage = resolveCommitMessage({ + configObject: options?.configObject, + templatesOverride: options?.templatesOverride, + action: "create", + tokens: buildCommitTokens({ + action: "create", + owner, + repo, + branch, + path: newPath, + contentName: options?.contentName, + user: options?.user, + userName: options?.committer?.name, + userEmail: options?.committer?.email, + }), + }); + try { + const response = await octokit.rest.repos.createOrUpdateFileContents({ + owner, + repo, + path: newPath, + message: fallbackMessage, + content: contentBase64, + branch, + committer: options?.committer, + }); + + if (response.data.content && response.data.commit) { + return response; + } + } catch (error: any) { + if (i === 3 || error.status !== 422) throw error; + } + } + } + throw error; + } +}; diff --git a/lib/utils/upload-media.ts b/lib/utils/upload-media.ts new file mode 100644 index 000000000..64578ea24 --- /dev/null +++ b/lib/utils/upload-media.ts @@ -0,0 +1,60 @@ +import { requireApiSuccess } from "@/lib/api-client"; +import type { FileSaveData } from "@/types/api"; + +// 4 MB binary fits in multipart body (overhead < 1 KB); raise above 4 MB at your own risk +export const CHUNK_BYTES = 4 * 1024 * 1024; +export const MAX_TOTAL_BYTES = 15 * 1024 * 1024; + +const CHUNK_CONCURRENCY = 4; + +export async function uploadMediaChunked(opts: { + file: File; + owner: string; + repo: string; + branch: string; + mediaName: string; + targetPath: string; + onConflict?: "error" | "rename"; +}): Promise { + const { file, owner, repo, branch, mediaName, targetPath, onConflict } = opts; + + if (file.size > MAX_TOTAL_BYTES) { + throw new Error(`File too large. Max ${Math.floor(MAX_TOTAL_BYTES / 1024 / 1024)} MB.`); + } + + const uploadId = crypto.randomUUID(); + const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_BYTES)); + const baseUrl = `/api/${owner}/${repo}/${encodeURIComponent(branch)}/media/${encodeURIComponent(mediaName)}/${encodeURIComponent(targetPath)}`; + + const uploadChunk = async (idx: number) => { + const start = idx * CHUNK_BYTES; + const end = Math.min(start + CHUNK_BYTES, file.size); + const blob = file.slice(start, end); + const form = new FormData(); + form.set("uploadId", uploadId); + form.set("idx", String(idx)); + form.set("chunk", blob); + const chunkResponse = await fetch(`${baseUrl}/chunk`, { method: "POST", body: form }); + await requireApiSuccess(chunkResponse, `Failed to upload chunk ${idx + 1}/${totalChunks}`); + }; + + // batched parallelism (4); switch to rolling pool if uneven chunk times matter + for (let i = 1; i < totalChunks; i += CHUNK_CONCURRENCY) { + const batch = []; + for (let j = i; j < Math.min(i + CHUNK_CONCURRENCY, totalChunks); j++) { + batch.push(uploadChunk(j)); + } + await Promise.all(batch); + } + + const firstBlob = file.slice(0, Math.min(CHUNK_BYTES, file.size)); + const finalizeForm = new FormData(); + finalizeForm.set("uploadId", uploadId); + finalizeForm.set("totalChunks", String(totalChunks)); + finalizeForm.set("firstChunk", firstBlob); + if (onConflict) finalizeForm.set("onConflict", onConflict); + + const response = await fetch(baseUrl, { method: "POST", body: finalizeForm }); + const data = await requireApiSuccess(response, "Failed to upload file"); + return data.data as FileSaveData; +}