Skip to content
Merged
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
15 changes: 15 additions & 0 deletions apps/dashboard/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,18 @@ BETTER_AUTH_URL=http://localhost:3000
# 3. Set the same URL as the webhook URL in your GitHub App settings,
# appending /api/webhooks/github
DEV_TUNNEL_URL=

# -----------------------------------------------------------------------------
# 6. R2 comment media (optional)
# -----------------------------------------------------------------------------
# Not required for the app to run. Without it, comment image/video uploads stay disabled
# (upload and finalize endpoints return a configuration error).
#
# Set to the public base URL for the SAME bucket Wrangler writes to (custom domain or r2.dev),
# no trailing slash. Example: https://pub-xxxxxxxxxxxxxxxx.r2.dev
#
# Local dev uses Miniflare by default: objects stay under .wrangler/state and do not show in the
# Cloudflare dashboard. wrangler.jsonc enables "remote": true on COMMENT_MEDIA so puts go to the
# real bucket named there (see bucket_name). Your pub URL must be for that bucket — not only
# preview_bucket_name (that name is for local simulation naming when not remote).
R2_PUBLIC_BASE_URL=
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"octokit": "^5.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-dropzone": "^15.0.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
Expand Down
21 changes: 18 additions & 3 deletions apps/dashboard/src/components/details/comment-reply-form.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { CommentIcon } from "@diffkit/icons";
import { MarkdownEditor } from "@diffkit/ui/components/markdown-editor";
import {
MarkdownEditor,
type MarkdownEditorHandle,
} from "@diffkit/ui/components/markdown-editor";
import { toast } from "@diffkit/ui/components/sonner";
import { Spinner } from "@diffkit/ui/components/spinner";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useCommentMediaUpload } from "#/hooks/use-comment-media-upload";
import { createComment } from "#/lib/github.functions";
import { githubQueryKeys } from "#/lib/github.query";
import { checkPermissionWarning } from "#/lib/warning-store";
Expand All @@ -28,6 +32,10 @@ export function CommentReplyForm({
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const queryClient = useQueryClient();
const editorRef = useRef<MarkdownEditorHandle>(null);
const commentActionsRef = useRef<HTMLDivElement>(null);
const { media: mediaUpload, onPaste: onMediaPaste } =
useCommentMediaUpload(editorRef);
Comment on lines +37 to +38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether useCommentMediaUpload exposes pending-state and whether forms gate submit on it.

set -euo pipefail

echo "== Hook definition =="
fd "use-comment-media-upload.ts" apps/dashboard/src --exec sed -n '1,260p' {}

echo
echo "== Reply/Comment form usages =="
rg -n --type=tsx -C3 'useCommentMediaUpload\(|media=\{mediaUpload\}|onPaste=\{onMediaPaste\}|disabled=\{!value\.trim\(\) \|\| isSending\}'

Repository: stylessh/diffkit

Length of output: 5112


🏁 Script executed:

fd "comment-reply-form.tsx" apps/dashboard/src --exec cat -n {}

Repository: stylessh/diffkit

Length of output: 4221


Gate submit button on pending media uploads to prevent submitting with unresolved placeholders.

The disabled={!value.trim() || isSending} condition on line 116 does not account for in-flight uploads. Users can currently submit comments containing unresolved upload placeholders. The useCommentMediaUpload hook does not expose a pending upload state, so either:

  1. The hook should expose a pending uploads count/flag, and the form should gate submit on it, or
  2. The form should validate that no upload placeholders remain before submission

Affected lines: 37-38, 96-97, 116-117


const handleSend = useCallback(async () => {
if (!value.trim()) return;
Expand Down Expand Up @@ -79,12 +87,19 @@ export function CommentReplyForm({
return (
<div className="flex w-full flex-col gap-2 pt-2">
<MarkdownEditor
ref={editorRef}
scrollAnchorRef={commentActionsRef}
value={value}
onChange={setValue}
placeholder={`Reply to @${parentAuthor}...`}
compact
media={mediaUpload}
onPaste={onMediaPaste}
/>
<div className="flex items-center justify-end gap-2">
<div
ref={commentActionsRef}
className="flex items-center justify-end gap-2"
>
<button
type="button"
onClick={() => {
Expand Down
17 changes: 15 additions & 2 deletions apps/dashboard/src/components/details/detail-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
} from "@diffkit/icons";
import {
MarkdownEditor,
type MarkdownEditorHandle,
type MentionCandidate,
} from "@diffkit/ui/components/markdown-editor";
import { toast } from "@diffkit/ui/components/sonner";
import { Spinner } from "@diffkit/ui/components/spinner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useCommentMediaUpload } from "#/hooks/use-comment-media-upload";
import { createComment, updatePullState } from "#/lib/github.functions";
import {
type GitHubQueryScope,
Expand Down Expand Up @@ -60,6 +62,10 @@ export function DetailCommentBox({
const [isTogglingState, setIsTogglingState] = useState(false);
const [mentionActivated, setMentionActivated] = useState(false);
const queryClient = useQueryClient();
const editorRef = useRef<MarkdownEditorHandle>(null);
const commentActionsRef = useRef<HTMLDivElement>(null);
const { media: mediaUpload, onPaste: onMediaPaste } =
useCommentMediaUpload(editorRef);
Comment on lines +67 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disable comment actions while uploads are still pending.

useCommentMediaUpload inserts a temporary upload token into value before the upload/finalize round-trip completes, and this component still treats any non-empty body as sendable. That lets users submit a comment or PR-state change with the raw [[DIFFKIT_UPLOAD:...]] placeholder still in the persisted text. Please expose a pending-upload flag/count from the hook and gate both actions on it.

Also applies to: 176-183

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/details/detail-activity.tsx` around lines 67 -
68, useCommentMediaUpload currently injects temporary upload tokens into the
editor value but the component still allows sends while uploads are in progress;
update the hook (useCommentMediaUpload) to return a pendingUploads count or
boolean (e.g., pendingCount or hasPendingUploads) and then in
detail-activity.tsx gate all actions that submit or change PR state (the
send/comment submit handler and the PR-state change handlers at the other
mentioned block) by checking that pendingUploads is zero/false; disable the
submit UI and early-return from the action handlers when pending uploads exist
so the raw [[DIFFKIT_UPLOAD:...]] placeholder cannot be persisted.


const viewerQuery = useQuery(githubViewerQueryOptions(scope));
const viewerLogin = viewerQuery.data?.login;
Expand Down Expand Up @@ -167,17 +173,24 @@ export function DetailCommentBox({
return (
<div className="flex flex-col gap-2">
<MarkdownEditor
ref={editorRef}
scrollAnchorRef={commentActionsRef}
value={value}
onChange={setValue}
placeholder="Leave a comment..."
compact
media={mediaUpload}
onPaste={onMediaPaste}
mentions={{
candidates: mentionCandidates,
onActivate: () => setMentionActivated(true),
isLoading: collaboratorsQuery.isLoading && mentionActivated,
}}
/>
<div className="flex items-center justify-end gap-2">
<div
ref={commentActionsRef}
className="flex items-center justify-end gap-2 pb-3"
>
{pullState && (
<button
type="button"
Expand Down
10 changes: 10 additions & 0 deletions apps/dashboard/src/entry-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ export default {
return handleWebSocketUpgrade(request, env);
}

if (
url.pathname === "/api/comment-media/upload" &&
request.method === "POST"
) {
const { handleCommentMediaUpload } = await import(
"#/lib/comment-media-upload.handler"
);
return handleCommentMediaUpload(request);
}
Comment on lines +56 to +64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Apply security headers on early API return path too.

The new upload branch (Line 56-Line 64) returns before the global header-setting block, so this endpoint misses SECURITY_HEADERS.

💡 Proposed fix
 		if (
 			url.pathname === "/api/comment-media/upload" &&
 			request.method === "POST"
 		) {
 			const { handleCommentMediaUpload } = await import(
 				"#/lib/comment-media-upload.handler"
 			);
-			return handleCommentMediaUpload(request);
+			const response = await handleCommentMediaUpload(request);
+			for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
+				response.headers.set(key, value);
+			}
+			return response;
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
url.pathname === "/api/comment-media/upload" &&
request.method === "POST"
) {
const { handleCommentMediaUpload } = await import(
"#/lib/comment-media-upload.handler"
);
return handleCommentMediaUpload(request);
}
if (
url.pathname === "/api/comment-media/upload" &&
request.method === "POST"
) {
const { handleCommentMediaUpload } = await import(
"#/lib/comment-media-upload.handler"
);
const response = await handleCommentMediaUpload(request);
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
response.headers.set(key, value);
}
return response;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/entry-worker.ts` around lines 56 - 64, The early return
for the upload branch bypasses the global header-setting, so change the branch
around handleCommentMediaUpload to call the handler, capture its Response, and
then merge/apply the SECURITY_HEADERS before returning; specifically, replace
the direct return of handleCommentMediaUpload(request) with awaiting
handleCommentMediaUpload(request) into a response variable and copying/appending
the SECURITY_HEADERS into that response (matching how the global header-setting
block applies SECURITY_HEADERS) and then return the modified response.


// TanStack Start's type only declares (request, env?) but the runtime
// handler created by @cloudflare/vite-plugin passes (request, env, ctx)
// through to the underlying Worker fetch signature.
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

declare namespace Cloudflare {
interface Env {
/** Public base URL for R2 comment media (custom domain or r2.dev). */
R2_PUBLIC_BASE_URL?: string;
GITHUB_OAUTH_CLIENT_ID?: string;
GITHUB_OAUTH_CLIENT_SECRET?: string;
GITHUB_APP_CLIENT_ID?: string;
Expand Down
206 changes: 206 additions & 0 deletions apps/dashboard/src/hooks/use-comment-media-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {
getCommentMediaUploadPlaceholderText,
type MarkdownEditorHandle,
type MarkdownEditorMediaUpload,
} from "@diffkit/ui/components/markdown-editor";
import { toast } from "@diffkit/ui/components/sonner";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import type { CommentMediaKind } from "#/lib/comment-media.server";
import {
type FinalizeCommentMediaResult,
finalizeCommentMediaUpload,
} from "#/lib/media.functions";

async function probeImageDimensions(file: File): Promise<{
width: number;
height: number;
}> {
const url = URL.createObjectURL(file);
try {
return await new Promise<{ width: number; height: number }>(
(resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () =>
reject(new Error("Could not read image dimensions"));
img.src = url;
},
);
} finally {
URL.revokeObjectURL(url);
}
}

async function probeVideoDimensions(file: File): Promise<{
width: number;
height: number;
}> {
const url = URL.createObjectURL(file);
try {
return await new Promise<{ width: number; height: number }>(
(resolve, reject) => {
const video = document.createElement("video");
video.preload = "metadata";
video.onloadedmetadata = () => {
resolve({ width: video.videoWidth, height: video.videoHeight });
};
video.onerror = () =>
reject(new Error("Could not read video dimensions"));
video.src = url;
},
);
} finally {
URL.revokeObjectURL(url);
}
}

async function probeDimensions(
file: File,
kind: CommentMediaKind,
): Promise<{ width: number; height: number }> {
try {
return kind === "image"
? await probeImageDimensions(file)
: await probeVideoDimensions(file);
} catch {
return { width: 1, height: 1 };
}
}

type UploadJson = {
key: string;
publicUrl: string;
kind: CommentMediaKind;
contentType: string;
};

export function useCommentMediaUpload(
editorRef: React.RefObject<MarkdownEditorHandle | null>,
) {
const uploadAndInsert = useCallback(
async (file: File) => {
const id = crypto.randomUUID();
const placeholder = getCommentMediaUploadPlaceholderText(id);
editorRef.current?.insertAtCaret(`${placeholder}\n`);

const formData = new FormData();
formData.append("file", file);

try {
const response = await fetch("/api/comment-media/upload", {
method: "POST",
body: formData,
credentials: "include",
});

if (!response.ok) {
let message = "Upload failed";
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// ignore
}
throw new Error(message);
}

const payload = (await response.json()) as UploadJson;
const dimensions = await probeDimensions(file, payload.kind);

const finalized: FinalizeCommentMediaResult =
await finalizeCommentMediaUpload({
data: {
key: payload.key,
width: dimensions.width,
height: dimensions.height,
kind: payload.kind,
fileName: file.name,
},
});

if (!finalized.ok) {
throw new Error(finalized.error);
}

editorRef.current?.replaceUploadPlaceholder(id, `${finalized.html}\n`);
} catch (error) {
const message =
error instanceof Error ? error.message : "Upload failed";
toast.error(message);
editorRef.current?.replaceUploadPlaceholder(
id,
`*Could not upload "${file.name}": ${message}*\n`,
);
}
},
[editorRef],
);

const processFiles = useCallback(
async (files: File[]) => {
if (files.length === 0) return;
for (const file of files) {
await uploadAndInsert(file);
}
},
[uploadAndInsert],
);

const onDrop = useCallback(
(acceptedFiles: File[]) => {
void processFiles(acceptedFiles);
},
[processFiles],
);

const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
noClick: true,
noKeyboard: true,
accept: {
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
"video/*": [".mp4", ".webm", ".mov"],
},
});

const onPaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = event.clipboardData?.items;
if (!items) return;

const files: File[] = [];
for (const item of items) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (!file) continue;
if (
!file.type.startsWith("image/") &&
!file.type.startsWith("video/")
) {
continue;
}
files.push(file);
}

if (files.length === 0) return;

event.preventDefault();
void processFiles(files);
},
[processFiles],
);

const media: MarkdownEditorMediaUpload = {
isDragActive,
rootProps: getRootProps(),
inputProps: getInputProps(),
onToolbarAttach: () => {
open();
},
};

return { media, onPaste };
}
Loading
Loading