Conversation
Adds a single shareable URL per project so teams can share all videos at once without sending individual links for every video. - convex/schema.ts: add shareToken field + by_share_token index to projects - convex/projects.ts: add generateShareToken, revokeShareToken, getByShareToken - convex/videos.ts: add listForProjectShare, getForProjectShare (public queries) - convex/comments.ts: add getThreadedForProjectShare (public query) - convex/videoActions.ts: add getProjectSharePlaybackSession action - src/lib/routes.ts: add projectSharePath, projectShareVideoPath helpers - app/routes/project-share.$token.*: new public route tree - app/routes/-project-share.tsx: read-only video grid component - app/routes/-project-share-video.tsx: read-only video player + comments - app/routes/dashboard/-project.tsx: add Share button with popover UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Someone is attempting to deploy a commit to the Ping Labs Team on Vercel. A member of the Team first needs to authorize it. |
WalkthroughAdds token-based project sharing: DB schema and Convex APIs for tokens and public access; new routes, pages, and route-tree entries for shared project and shared-video views; dashboard UI to generate/revoke/copy share URLs; playback/comment endpoints and route helpers. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User / Client
participant Dashboard as Dashboard UI
participant Router as Router / Shared Pages
participant Convex as Convex Backend
participant DB as Database
rect rgba(100,200,100,0.5)
Note over User,DB: Share Token Generation Flow
User->>Dashboard: Click "Share"
Dashboard->>Convex: generateShareToken(projectId)
Convex->>DB: validate access & persist unique token
DB-->>Convex: token stored
Convex-->>Dashboard: return token
Dashboard->>User: display share URL
end
rect rgba(100,150,200,0.5)
Note over User,DB: Shared Project / Video View Flow
User->>Router: GET /project-share/{token}
Router->>Convex: getByShareToken(token)
Convex->>DB: lookup project by shareToken
DB-->>Convex: return project info
Convex-->>Router: project DTO
Router->>User: render project page
User->>Router: open video -> /project-share/{token}/{videoId}
Router->>Convex: getForProjectShare(token, videoId)
Router->>Convex: getThreadedForProjectShare(token, videoId)
Convex->>DB: fetch video & comments
DB-->>Convex: return video+comments
Convex-->>Router: data
Router->>Convex: getProjectSharePlaybackSession(token, videoId)
Convex->>Convex: ensure public playback id & build session
Convex-->>Router: playback session
Router->>User: render player with markers
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
app/routes/dashboard/-project.tsx (1)
236-251: Consider adding user feedback for token generation failure.When
generateProjectSharefails, onlyconsole.erroris called. The user receives no visual feedback that the share operation failed.♻️ Proposed improvement to add error toast
const handleShareProject = useCallback(async () => { if (!resolvedProjectId) return; if (project?.shareToken) { setShowSharePanel(true); return; } setIsGeneratingToken(true); try { await generateProjectShare({ projectId: resolvedProjectId }); setShowSharePanel(true); } catch (error) { console.error("Failed to generate share token:", error); + showShareToast("error", "Could not generate share link"); } finally { setIsGeneratingToken(false); } - }, [project?.shareToken, resolvedProjectId, generateProjectShare]); + }, [project?.shareToken, resolvedProjectId, generateProjectShare, showShareToast]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/routes/dashboard/-project.tsx` around lines 236 - 251, The catch block in handleShareProject currently only logs errors to the console so users get no feedback when generateProjectShare fails; update the catch to show a user-facing error (for example call your app's toast/error notification helper or set an error state) and ensure setIsGeneratingToken(false) still runs in finally; modify handleShareProject to call the notification helper (e.g., toast.error or showError) with a clear message like "Failed to generate share token" and include the error details, while keeping references to generateProjectShare, setShowSharePanel, and setIsGeneratingToken intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/routes/dashboard/-project.tsx`:
- Around line 386-396: Wrap the await revokeProjectShare call in a try/catch
inside the onClick handler for the Revoke link button (referencing
revokeProjectShare, resolvedProjectId, and setShowSharePanel) so failures don’t
silently close the panel; on success keep the current behavior
(setShowSharePanel(false)), on failure keep the panel open and surface the error
to the user by invoking your app’s standard error notification (e.g.,
toast/flash/error handler) and optionally set a local loading state to disable
the button while the mutation is running.
In `@convex/projects.ts`:
- Around line 126-143: The current generateShareToken persists shareToken onto
the projects record (see generateShareToken which calls
ctx.db.patch(args.projectId, { shareToken: token })), which causes existing
project list/get handlers in this file to leak the token to viewers; instead
stop storing the bearer token on the generic project payloads or ensure those
handlers never return it: remove or avoid writing shareToken into the public
projects document and implement a dedicated, member-only read endpoint (e.g.,
getProjectShareToken) that calls requireProjectAccess(ctx, projectId, "member")
and returns the token from a secure store (or from a separate table/field only
accessible via this endpoint) so only members can retrieve it; also update
generateShareToken to persist the token to that secure location (not the public
project fields) and keep list/get handlers unchanged or explicitly redact
shareToken before returning.
In `@convex/videoActions.ts`:
- Around line 490-516: Add an optional shareTokenExpiresAt timestamp to the
projects schema and update the getForProjectShare query and the
getProjectSharePlaybackSession action to verify the token is not expired
(compare now to shareTokenExpiresAt) similar to resolveActiveShareGrant for
video shares; ensure revokeShareToken behavior remains compatible (it can still
clear the token) and add migration/validation so existing tokens without
shareTokenExpiresAt are treated as non-expiring only if you intend that,
otherwise require an expiry when creating a project share token.
In `@convex/videos.ts`:
- Around line 503-505: Public share endpoints treat URL path segments as
untrusted strings; replace the v.id("videos") param with v.string() in
getForProjectShare, getProjectSharePlaybackSession, and
getThreadedForProjectShare, then call ctx.db.normalizeId("videos",
providedVideoId) inside each handler and bail out (return null / not-found) if
normalizeId returns falsy before any DB queries or validation that assumes a
Convex ID; ensure subsequent code uses the normalized ID variable when querying.
---
Nitpick comments:
In `@app/routes/dashboard/-project.tsx`:
- Around line 236-251: The catch block in handleShareProject currently only logs
errors to the console so users get no feedback when generateProjectShare fails;
update the catch to show a user-facing error (for example call your app's
toast/error notification helper or set an error state) and ensure
setIsGeneratingToken(false) still runs in finally; modify handleShareProject to
call the notification helper (e.g., toast.error or showError) with a clear
message like "Failed to generate share token" and include the error details,
while keeping references to generateProjectShare, setShowSharePanel, and
setIsGeneratingToken intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f91a393c-6ea7-430b-86ad-7774ce54ba75
📒 Files selected for processing (13)
app/routeTree.gen.tsapp/routes/-project-share-video.tsxapp/routes/-project-share.tsxapp/routes/dashboard/-project.tsxapp/routes/project-share.$token.$videoId.tsxapp/routes/project-share.$token.index.tsxapp/routes/project-share.$token.tsxconvex/comments.tsconvex/projects.tsconvex/schema.tsconvex/videoActions.tsconvex/videos.tssrc/lib/routes.ts
| export const getProjectSharePlaybackSession = action({ | ||
| args: { shareToken: v.string(), videoId: v.id("videos") }, | ||
| returns: v.object({ | ||
| url: v.string(), | ||
| posterUrl: v.string(), | ||
| }), | ||
| handler: async ( | ||
| ctx, | ||
| args, | ||
| ): Promise<{ url: string; posterUrl: string }> => { | ||
| const result = await ctx.runQuery(api.videos.getForProjectShare, { | ||
| shareToken: args.shareToken, | ||
| videoId: args.videoId, | ||
| }); | ||
|
|
||
| if (!result?.muxPlaybackId) { | ||
| throw new Error("Video not found or not ready"); | ||
| } | ||
|
|
||
| const playbackId = await ensurePublicPlaybackId(ctx, { | ||
| videoId: args.videoId, | ||
| muxAssetId: result.muxAssetId, | ||
| muxPlaybackId: result.muxPlaybackId, | ||
| }); | ||
| return buildPublicPlaybackSession(playbackId); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the schema for projects table - check if expiry field exists
rg -n "shareToken" convex/schema.ts -A 3 -B 3Repository: pingdotgg/lawn
Length of output: 360
🏁 Script executed:
#!/bin/bash
# Get the full projects schema definition
sed -n '59,70p' convex/schema.tsRepository: pingdotgg/lawn
Length of output: 357
🏁 Script executed:
#!/bin/bash
# Search for getForProjectShare implementation to verify the validation logic
rg -n "getForProjectShare" convex/videos.ts -A 25 -B 2Repository: pingdotgg/lawn
Length of output: 985
🏁 Script executed:
#!/bin/bash
# Search for getByShareGrant to compare how it handles expiry
rg -n "getByShareGrant" convex/videos.ts -A 15 -B 2Repository: pingdotgg/lawn
Length of output: 552
🏁 Script executed:
#!/bin/bash
# Look for resolveActiveShareGrant to understand share grant expiry validation
rg -n "resolveActiveShareGrant" -A 10Repository: pingdotgg/lawn
Length of output: 3323
🏁 Script executed:
#!/bin/bash
# Get the full resolveActiveShareGrant implementation
sed -n '61,90p' convex/shareAccess.tsRepository: pingdotgg/lawn
Length of output: 683
🏁 Script executed:
#!/bin/bash
# Check shareAccessGrants schema to see if it has expiry field
rg -n "shareAccessGrants" convex/schema.ts -A 8 -B 2Repository: pingdotgg/lawn
Length of output: 377
🏁 Script executed:
#!/bin/bash
# Check shareLinks schema
rg -n "shareLinks" convex/schema.ts -A 8 -B 2Repository: pingdotgg/lawn
Length of output: 719
🏁 Script executed:
#!/bin/bash
# Check if there's any deletion/revocation mechanism for project share tokens
rg -n "shareToken" convex/videoActions.ts -B 5 -A 5Repository: pingdotgg/lawn
Length of output: 719
🏁 Script executed:
#!/bin/bash
# Check if there's any management query/mutation for revoking project shares
rg -n "shareToken" convex/ -lRepository: pingdotgg/lawn
Length of output: 176
🏁 Script executed:
#!/bin/bash
# Check projects.ts for any mutations or functions related to shareToken management
rg -n "shareToken" convex/projects.ts -B 5 -A 10Repository: pingdotgg/lawn
Length of output: 1235
🏁 Script executed:
#!/bin/bash
# Search for any mutation that might delete or update shareToken
rg -n "mutation\|action" convex/projects.ts | head -20Repository: pingdotgg/lawn
Length of output: 40
Project share tokens lack expiration, creating indefinite access risk.
The getForProjectShare query only validates that the project exists and the video belongs to that project, with no expiration check. Unlike video share grants (which validate expiry via resolveActiveShareGrant), project share tokens have no expiresAt field in the schema.
This means a leaked project share token grants permanent access to all current and future videos in that project until manually revoked via revokeShareToken. This is a significant design inconsistency with the video-level sharing mechanism.
Consider adding an optional shareTokenExpiresAt field to the projects schema to support time-limited sharing, similar to the shareAccessGrants model.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/videoActions.ts` around lines 490 - 516, Add an optional
shareTokenExpiresAt timestamp to the projects schema and update the
getForProjectShare query and the getProjectSharePlaybackSession action to verify
the token is not expired (compare now to shareTokenExpiresAt) similar to
resolveActiveShareGrant for video shares; ensure revokeShareToken behavior
remains compatible (it can still clear the token) and add migration/validation
so existing tokens without shareTokenExpiresAt are treated as non-expiring only
if you intend that, otherwise require an expiry when creating a project share
token.
- Fix hardcoded SEO paths in project-share routes (macroscope): use
dynamic params.token/videoId in seoHead path so og:url and canonical
tags point to the correct URL
- Redact shareToken from projects.list/get queries to prevent leaking
bearer tokens to viewer-role users; add member-only getShareToken
query and update dashboard to use it
- Add try/catch with user-facing toast feedback to revoke button and
generate-token error path in project dashboard
- Change videoId args from v.id("videos") to v.string() + normalizeId
on all public project-share endpoints (getForProjectShare,
getThreadedForProjectShare, getProjectSharePlaybackSession) so
malformed URL segments return null instead of a validation error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
convex/projects.ts (1)
42-44: Use a lint-safe helper for token redaction.Line 42 and Line 106 both strip
shareTokenvia_shareToken, and static analysis is already flagging that binding as unused. In the current ESLint setup this will fail lint even though the redaction logic is correct. A small helper that clones the document and deletesshareTokenavoids the duplicate error in both places.♻️ Suggested refactor
+function omitProjectShareToken<T extends { shareToken?: string }>( + project: T, +): Omit<T, "shareToken"> { + const copy = { ...project }; + delete copy.shareToken; + return copy; +} + ... - const { shareToken: _shareToken, ...projectWithoutToken } = project; + const projectWithoutToken = omitProjectShareToken(project); return { ...projectWithoutToken, videoCount: videos.length, }; ... - const { shareToken: _shareToken, ...projectWithoutToken } = project; + const projectWithoutToken = omitProjectShareToken(project); return { ...projectWithoutToken, role: membership.role };Also applies to: 106-107
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/projects.ts` around lines 42 - 44, Replace the current destructuring pattern that binds an unused _shareToken when returning projectWithoutToken with a small lint-safe helper (e.g., redactShareToken or cloneWithoutShareToken) that takes the project object, shallow-clones it and deletes the shareToken property, then returns the cleaned object; update both places that now create projectWithoutToken to call that helper (referencing project and projectWithoutToken in the diff) so ESLint no longer flags an unused binding.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/routes/-project-share-video.tsx`:
- Around line 28-55: Reset all playback-related state at the start of the effect
and before early-return: when token/videoId/video?.muxPlaybackId change, call
setPlaybackSession(null), setIsLoadingPlayback(false) (or true as appropriate)
and setPlaybackError(null) before returning early and also clear/set them
immediately before calling getPlaybackSession; update the useEffect block that
references getPlaybackSession, token, videoId, video?.muxPlaybackId to ensure
the old playbackSession, loading and error state are cleared so stale video
isn’t shown while the new request resolves.
In `@app/routes/dashboard/-project.tsx`:
- Around line 148-152: The code currently collapses the query result into
shareToken immediately (const shareToken = shareTokenData?.shareToken ?? null),
treating a loading query as “no token” and allowing generateProjectShare to run
and rotate tokens; instead, preserve the raw useQuery result (shareTokenData)
and use its loading/state flags to gate UI/actions: do not derive shareToken to
null while shareTokenData is unresolved, check shareTokenData.isLoading or
similar before enabling the generateProjectShare action/button, and only call
generateProjectShare when the query has settled and shareTokenData.data (or
shareTokenData.shareToken) is explicitly null or present; update the same
pattern wherever shareToken is derived (refs:
useQuery(api.projects.getShareToken), shareTokenData, shareToken, and
generateProjectShare) so generation is disabled until the query resolves.
- Around line 382-387: The popover onClick handler calls
copyTextToClipboard(url) but only chains .then(...), so rejections (e.g.,
navigator.clipboard.writeText denied) are unhandled; update the onClick handler
for the popover to handle promise rejections from copyTextToClipboard by adding
a .catch(...) (or using async/await with try/catch) and call
showShareToast("error", "Could not copy link") on failure; reference the
existing onClick, copyTextToClipboard, showShareToast, projectSharePath and
shareToken symbols so you update the same handler logic.
- Around line 257-258: Remove the invalid ESLint suppression comment for
react-hooks/exhaustive-deps and reorder the local functions so showShareToast is
declared before handleShareProject; specifically, move the showShareToast
function (currently at line ~260) above handleShareProject (currently ~241),
then delete the "// eslint-disable-next-line react-hooks/exhaustive-deps" line
and keep the effect dependency array as [shareToken, resolvedProjectId,
generateProjectShare] since showShareToast is a stable reference with empty
deps.
In `@convex/videoActions.ts`:
- Around line 490-515: getProjectSharePlaybackSession currently returns a
permanent public playback URL by calling ensurePublicPlaybackId and
buildPublicPlaybackSession, so revoking a share token (revokeShareToken) won't
stop already-fetched URLs; change this handler to create and return a
short-lived, revocable/signed playback session (e.g. call a new or existing
helper like createSignedPlaybackSession or ensurePublicPlaybackId({ ephemeral:
true })) instead of persisting a public playback ID on the Video record, and do
not write the playback id to the video in ensurePublicPlaybackId; update
getProjectSharePlaybackSession to use that signed session flow and keep
buildPublicPlaybackSession only for non-revocable public uses.
---
Nitpick comments:
In `@convex/projects.ts`:
- Around line 42-44: Replace the current destructuring pattern that binds an
unused _shareToken when returning projectWithoutToken with a small lint-safe
helper (e.g., redactShareToken or cloneWithoutShareToken) that takes the project
object, shallow-clones it and deletes the shareToken property, then returns the
cleaned object; update both places that now create projectWithoutToken to call
that helper (referencing project and projectWithoutToken in the diff) so ESLint
no longer flags an unused binding.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8af141c0-88e0-4ba3-af41-620f81cfaba8
📒 Files selected for processing (8)
app/routes/-project-share-video.tsxapp/routes/dashboard/-project.tsxapp/routes/project-share.$token.$videoId.tsxapp/routes/project-share.$token.index.tsxconvex/comments.tsconvex/projects.tsconvex/videoActions.tsconvex/videos.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- convex/videos.ts
- convex/comments.ts
| export const getProjectSharePlaybackSession = action({ | ||
| args: { shareToken: v.string(), videoId: v.string() }, | ||
| returns: v.object({ | ||
| url: v.string(), | ||
| posterUrl: v.string(), | ||
| }), | ||
| handler: async ( | ||
| ctx, | ||
| args, | ||
| ): Promise<{ url: string; posterUrl: string }> => { | ||
| const result = await ctx.runQuery(api.videos.getForProjectShare, { | ||
| shareToken: args.shareToken, | ||
| videoId: args.videoId, | ||
| }); | ||
|
|
||
| if (!result?.muxPlaybackId) { | ||
| throw new Error("Video not found or not ready"); | ||
| } | ||
|
|
||
| const playbackId = await ensurePublicPlaybackId(ctx, { | ||
| videoId: result._id, | ||
| muxAssetId: result.muxAssetId, | ||
| muxPlaybackId: result.muxPlaybackId, | ||
| }); | ||
| return buildPublicPlaybackSession(playbackId); | ||
| }, |
There was a problem hiding this comment.
Revoke link does not revoke the stream URL once it has been fetched.
This action returns a public playback URL, and ensurePublicPlaybackId() can also persist a public playback ID onto the video. After revokeShareToken() clears the project token, anyone who already captured session.url can still hit Mux directly, so revocation only hides the app route, not the video itself. Project-share playback needs a revocable/signed session here rather than a bare public playback URL.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/videoActions.ts` around lines 490 - 515,
getProjectSharePlaybackSession currently returns a permanent public playback URL
by calling ensurePublicPlaybackId and buildPublicPlaybackSession, so revoking a
share token (revokeShareToken) won't stop already-fetched URLs; change this
handler to create and return a short-lived, revocable/signed playback session
(e.g. call a new or existing helper like createSignedPlaybackSession or
ensurePublicPlaybackId({ ephemeral: true })) instead of persisting a public
playback ID on the Video record, and do not write the playback id to the video
in ensurePublicPlaybackId; update getProjectSharePlaybackSession to use that
signed session flow and keep buildPublicPlaybackSession only for non-revocable
public uses.
- Reset playback state (session/loading/error) before each new fetch in ProjectShareVideoPage so the old video never shows under new metadata - Guard generateProjectShare against firing while getShareToken is still loading to prevent silently rotating an existing share URL; also disable the Share button while loading - Reorder showShareToast before handleShareProject to eliminate the forward reference and remove the invalid ESLint suppression comment - Add .catch() to clipboard copy handler so rejections surface as an error toast rather than an unhandled promise rejection Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… helper
Replaces the destructuring pattern `{ shareToken: _shareToken, ...rest }`
with a typed helper that shallow-clones the object and deletes the field,
avoiding the ESLint unused-variable binding flagged by CodeRabbit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/routes/-project-share-video.tsx`:
- Around line 152-169: The spinner is always rendered even when playbackError is
present; update the conditional rendering so the spinner element (the div with
classes "h-8 w-8 animate-spin rounded-full ...") only renders when there is no
playbackError and isLoadingPlayback is true, leaving the message paragraph
(which uses playbackError ?? ...) unchanged so errors display without the
loader.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d152d1de-17cb-413c-9529-0c19f65f0051
📒 Files selected for processing (2)
app/routes/-project-share-video.tsxapp/routes/dashboard/-project.tsx
The spinner was unconditionally rendered in the video placeholder, so it spun alongside the error message. Now it only renders when loading and no error is present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
/project-share/$token) so teams can share all videos at once without sending individual links/project-share/$token— no auth required, read-only/project-share/$token/$videoId— Mux playback + read-only comment threadChanges
Backend (Convex)
schema.ts:shareTokenfield +by_share_tokenindex onprojectstableprojects.ts:generateShareToken,revokeShareToken,getByShareTokenvideos.ts:listForProjectShare,getForProjectShare(unauthenticated queries)comments.ts:getThreadedForProjectShare(unauthenticated query)videoActions.ts:getProjectSharePlaybackSessionactionFrontend
project-share.$token→ layout,.index→ video grid,.$videoId→ playerTest plan
🤖 Generated with Claude Code
Note
Add project-level share links by introducing public
/project-share/:tokenand/project-share/:token/:videoIdroutes with dashboard controls to generate and revoke tokens inconvex/projects.tsAdd public project and video pages under
/project-share/:tokenwith video playback and comments, route registrations, and a dashboard share panel with copy and revoke actions. Implement token-backed Convex queries and mutations for projects, videos, comments, and a playback session action, and extend the schema withshareTokenand aby_share_tokenindex.📍Where to Start
Start with token generation and validation in
projects.generateShareToken,projects.getByShareToken, and related queries in convex/projects.ts, then follow public access checks through convex/videos.ts and convex/videoActions.ts, and finally review route wiring in app/routes/project-share.$token.index.tsx and app/routes/project-share.$token.$videoId.tsx.Macroscope summarized 06aa161.
Summary by CodeRabbit