Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 1 addition & 179 deletions app/api/[owner]/[repo]/[branch]/files/[path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.`);
Expand Down Expand Up @@ -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<string, any>;
templatesOverride?: Record<string, string>;
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 }> }
Expand Down
54 changes: 54 additions & 0 deletions app/api/[owner]/[repo]/[branch]/media/[name]/[path]/chunk/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading