Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f7492fe
docs(specs): widget 3-mode launcher, gallery, recording & share links…
Ripwords Jul 17, 2026
f121e60
docs(plans): implementation plan for widget modes, gallery, recording…
Ripwords Jul 17, 2026
128ad93
feat(shared): media attachment kind, media-meta contract, share-link …
Ripwords Jul 17, 2026
6e5eb6e
feat(sdk-utils): shared formatBytes and media selection limits
Ripwords Jul 17, 2026
da08221
feat(core): screen recording session with auto-stop, cancel and snapshot
Ripwords Jul 17, 2026
dbc6406
fix(core): fix first-termination-trigger race in screen recorder
Ripwords Jul 17, 2026
793ec71
feat(ui): IndexedDB gallery store with quota eviction and thumbnail h…
Ripwords Jul 17, 2026
906d77c
feat(ui): recording control bar, metadata trim screen and outcome bar
Ripwords Jul 17, 2026
eba439b
test(ui): cover trim-screen CSP fallback and objectURL revoke
Ripwords Jul 17, 2026
b7db239
fix(intake): stop 413ing video attachments the widget itself accepts
Ripwords Jul 17, 2026
917e7b0
feat(ui): launcher menu, capture flow with outcome bar, gallery view
Ripwords Jul 17, 2026
d3592c2
feat(ui): two-step report wizard fed by the gallery media picker
Ripwords Jul 17, 2026
80879c0
fix(ui): prune stale preselected media ids from the report wizard
Ripwords Jul 17, 2026
cd4eb23
feat(dashboard): schema for media attachments, share settings and sha…
Ripwords Jul 17, 2026
0e6d10e
feat(intake): accept gallery media parts with trim metadata
Ripwords Jul 17, 2026
298d009
fix(intake): reject duplicate or non-contiguous media part indices
Ripwords Jul 17, 2026
1d25f51
feat(storage): range-aware getStream on local-disk and s3 adapters
Ripwords Jul 17, 2026
f671e01
fix(storage): resolve full object size when s3 backends omit ContentR…
Ripwords Jul 17, 2026
5960f6e
feat(dashboard): public share-link mint endpoint for gallery recordings
Ripwords Jul 17, 2026
80e39f2
fix(dashboard): enforce endMs after startMs on share-mint trim
Ripwords Jul 17, 2026
b70c7d3
feat(dashboard): public shared-media meta and range-streaming blob ro…
Ripwords Jul 17, 2026
b6fa2f5
feat(dashboard): public /s/:token share page with trim-aware playback…
Ripwords Jul 17, 2026
ea036ae
fix(dashboard): cap shared-media cache at 5 minutes so revocation pro…
Ripwords Jul 17, 2026
e846a4a
fix(dashboard): make share-page trim clamping loop-proof against malf…
Ripwords Jul 17, 2026
7011629
feat(sdk): 3-mode launcher menu, recording flow wiring, media intake …
Ripwords Jul 17, 2026
e14767c
fix(ui): release recording stream and listeners on widget unmount
Ripwords Jul 17, 2026
1ab1bcb
feat(sdk): mint share links from the gallery with graceful old-server…
Ripwords Jul 17, 2026
cc39d2d
fix(ui): re-mint expired share links instead of copying dead URLs
Ripwords Jul 17, 2026
d9ae9d9
feat(dashboard): render report media attachments with trim-aware play…
Ripwords Jul 17, 2026
e2e4ced
feat(dashboard): sharing settings tab with shared-link list and revoke
Ripwords Jul 17, 2026
3d5ebbd
feat(dashboard): nightly purge of expired and revoked shared media
Ripwords Jul 17, 2026
55ff8e7
test(extension): smoke the widget mode flows
Ripwords Jul 17, 2026
48ed3e9
fix(sdk): report container mime from screen recordings
Ripwords Jul 17, 2026
0b22810
fix(intake): cap request bodies before buffering
Ripwords Jul 17, 2026
1c1c1cf
fix: close media validation and widget re-entry gaps from final review
Ripwords Jul 17, 2026
ac03d80
fix(ci): run per-file SDK tests from the package cwd so bun finds eac…
Ripwords Jul 17, 2026
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
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,15 @@ DB_IDLE_TX_TIMEOUT_MS=10000
INTAKE_RATE_PER_KEY=60
INTAKE_RATE_PER_IP=20
INTAKE_RATE_PER_KEY_ANON=10
INTAKE_MAX_BYTES=5242880
# Whole-request ceiling — keep above the per-file/total attachment caps below
# (10 MB file + 25 MB total) plus the gallery media caps (100 MB video) or
# attachments the widget accepts will 413. (Was 41943040 / 40 MB before
# gallery media support raised the ceiling to fit one max-size video.)
# The intake handlers reject an oversized declared Content-Length before
# buffering the body, but Content-Length can be absent or lie on chunked
# bodies — put a reverse-proxy body-size cap (e.g. nginx client_max_body_size)
# in front of the app to bound streamed/chunked requests too.
INTAKE_MAX_BYTES=157286400
INTAKE_REQUIRE_DWELL=true
INTAKE_MIN_DWELL_MS=1500

Expand All @@ -148,6 +156,12 @@ INTAKE_USER_FILE_MAX_BYTES=10485760
INTAKE_USER_FILES_TOTAL_MAX_BYTES=26214400
INTAKE_USER_FILES_MAX_COUNT=5

# Gallery media caps (media[N] + mediaMeta parts: screenshots + trimmed
# recordings from the SDK's capture flow)
INTAKE_MEDIA_MAX_COUNT=3
INTAKE_MEDIA_IMAGE_MAX_BYTES=10485760
INTAKE_MEDIA_VIDEO_MAX_BYTES=104857600

# Virus scan via the bundled clamav sidecar. Off by default — flip to true
# AFTER `docker compose up -d` and the clamav service has finished pulling
# its signature DB (~500MB, watch with `docker compose logs -f clamav`
Expand Down
59 changes: 58 additions & 1 deletion apps/dashboard/app/components/report-drawer/attachments-tab.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
<!-- apps/dashboard/app/components/report-drawer/attachments-tab.vue -->
<script setup lang="ts">
import type { AttachmentDTO } from "@reprojs/shared"
import TrimVideo from "~/components/report-drawer/trim-video.vue"

const props = defineProps<{
attachments: AttachmentDTO[]
}>()

// The tab covers two attachment kinds: "user-file" (generic uploads, shown
// as Images/Files below) and "media" (widget gallery screenshots + trimmed
// recordings, shown in their own Media section further down).
const userFiles = computed(() => props.attachments.filter((a) => a.kind === "user-file"))
const images = computed(() => userFiles.value.filter((a) => a.contentType.startsWith("image/")))
const others = computed(() => userFiles.value.filter((a) => !a.contentType.startsWith("image/")))

const media = computed(() => props.attachments.filter((a) => a.kind === "media"))
const mediaImages = computed(() => media.value.filter((a) => a.contentType.startsWith("image/")))
const mediaVideos = computed(() => media.value.filter((a) => a.contentType.startsWith("video/")))

function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
Expand Down Expand Up @@ -66,10 +74,59 @@ function scanBadge(file: AttachmentDTO): ScanBadge {

<template>
<div class="space-y-6 p-4">
<p v-if="userFiles.length === 0" class="text-sm text-muted italic">
<p v-if="userFiles.length === 0 && media.length === 0" class="text-sm text-muted italic">
No additional attachments on this report.
</p>

<section v-if="mediaImages.length > 0 || mediaVideos.length > 0" class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Media ({{ media.length }})
</h3>
<div v-if="mediaImages.length > 0" class="grid grid-cols-3 gap-3">
<div v-for="img in mediaImages" :key="img.id" class="space-y-1.5">
<a
:href="img.url"
target="_blank"
rel="noopener"
class="block aspect-square overflow-hidden rounded-md border border-default bg-elevated/40"
:title="img.filename ?? ''"
>
<img
:src="img.url"
:alt="img.filename ?? 'attachment'"
class="h-full w-full object-cover"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</a>
<div
class="flex items-center gap-1 text-[11px]"
:class="scanBadge(img).tone === 'success' ? 'text-success' : 'text-muted'"
:title="scanBadge(img).detail"
>
<UIcon
:name="
scanBadge(img).status === 'clean'
? 'i-heroicons-shield-check'
: 'i-heroicons-shield-exclamation'
"
class="size-3 shrink-0"
/>
<span class="truncate">{{ scanBadge(img).label }}</span>
</div>
</div>
</div>
<div v-if="mediaVideos.length > 0" class="space-y-4">
<TrimVideo
v-for="vid in mediaVideos"
:key="vid.id"
:src="vid.url"
:trim-start-ms="vid.trimStartMs"
:trim-end-ms="vid.trimEndMs"
:download-name="vid.filename ?? 'recording'"
/>
</div>
</section>

<section v-if="images.length > 0" class="space-y-3">
<h3 class="text-xs font-semibold uppercase tracking-wide text-muted">
Images ({{ images.length }})
Expand Down
60 changes: 60 additions & 0 deletions apps/dashboard/app/components/report-drawer/trim-video.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!-- apps/dashboard/app/components/report-drawer/trim-video.vue -->
<script setup lang="ts">
import { clampPlayback } from "~/utils/clamp-playback"

const props = defineProps<{
src: string
trimStartMs: number | null
trimEndMs: number | null
downloadName?: string
}>()

const video = ref<HTMLVideoElement | null>(null)

// Same adapter shape as app/pages/s/[token].vue — clampPlayback is the pure,
// loop-proof helper; this handler only translates its verdict into DOM
// mutations. Do not reimplement the clamp math inline here.
function clampToTrim() {
const v = video.value
if (!v) return
const action = clampPlayback({
currentTime: v.currentTime,
duration: v.duration,
trimStartMs: props.trimStartMs,
trimEndMs: props.trimEndMs,
})
if (action.type === "seek") {
v.currentTime = action.to
} else if (action.type === "pause-and-reset") {
v.pause()
v.currentTime = action.to
}
}
</script>

<template>
<div>
<video
ref="video"
controls
preload="metadata"
:src="src"
class="w-full rounded-lg"
@loadedmetadata="clampToTrim"
@timeupdate="clampToTrim"
/>
<div class="mt-1 flex items-center justify-between text-xs text-muted">
<span v-if="trimStartMs != null || trimEndMs != null"
>Trimmed view · full file via download</span
>
<span v-else />
<a
:href="src"
:download="downloadName ?? 'recording'"
class="font-medium text-primary-600 dark:text-primary-400 hover:underline"
>
Download full recording
</a>
</div>
</div>
</template>
2 changes: 1 addition & 1 deletion apps/dashboard/app/middleware/auth.global.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export default defineNuxtRouteMiddleware(async (to) => {
const publicPaths = ["/auth/sign-in"]
const publicPaths = ["/auth/sign-in", "/s/"]
if (publicPaths.some((p) => to.path.startsWith(p))) return

// useRequestFetch() forwards the incoming request's cookie during SSR.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ const consoleHasData = computed(
const networkHasData = computed(() => logs.value !== null && logs.value.network.length > 0)
const cookiesHasData = computed(() => (report.value?.context?.cookies?.length ?? 0) > 0)
const userFileCount = computed(
() => (report.value?.attachments ?? []).filter((a) => a.kind === "user-file").length,
() =>
(report.value?.attachments ?? []).filter((a) => a.kind === "user-file" || a.kind === "media")
.length,
)

const tabs = computed(() => {
Expand Down
Loading
Loading