* fix: scope access link revocation to the owning user
The revokeAccessLink mutation only required a valid session and then
revoked whichever access link ID it was given, so any authenticated user
could revoke another user's links by guessing or learning an ID.
Add revoke_user_access_link, which looks the link up by both ID and the
caller's user ID and fails with "Access link not found" otherwise, and
route the GraphQL mutation through it. The unscoped revoke_access_link
stays for the background job that revokes expired or exhausted links,
which legitimately acts without a user context.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: enforce collection ownership and membership checks
Three collection operations trusted client-supplied identifiers without
verifying the caller's relationship to the collection:
- collectionContents returned any collection by ID, leaking its contents
to other tenants. It now requires the caller to be a member (the owner
is always recorded as a collaborator).
- createOrUpdateCollection with updateId overwrote any collection by ID,
including replacing its collaborators. It now requires ownership.
- deployRemoveEntitiesFromCollectionJob resolved the collection from the
creator ID in the input alone, so any user could delete entities from
another user's collection. It now resolves by owner and name and
requires the caller to be a member.
Failures return "Collection not found" so that collection IDs cannot be
probed, and the unwrap() calls on these lookups, which could panic, are
replaced with errors. A shared is_collection_member helper backs the
checks; collection-service gains a dependency on dependent-collection-utils.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: add regression tests for access link and collection authorization
Cover the four reported cross-user flaws end to end: revoking another
user's access link, reading another user's collection, taking over a
collection via updateId, and removing entities from another user's
collection. Owner reads bust the per-user contents cache so a stale
cache cannot mask a successful attack.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: accept multipart webhook requests by reading only the payload part
The integration webhook route extracted the whole body as a String, so any
request containing binary data was rejected with a 400 before a job was
queued. Plex sends multipart/form-data with a JPEG thumbnail next to the
JSON payload on media.play, which made every play event fail (#1826).
Multipart requests are now parsed and only the "payload" part is read; a
multipart request without one is a 400. Other content types keep the
previous behaviour of treating the body as UTF-8 text, so the other
integrations are unaffected.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: do not drop progress when a webhook creates the first seen record
When no in-progress cache entry existed, the progress handler created an
in-progress seen at 0% and returned, discarding the incoming progress. If
a completion signal (e.g. a Plex scrobble) was the first event to succeed,
the item stayed at 0% forever, and that stuck record then blocked every
other episode of the same show with "An in-progress record already
exists" (#1826).
A 100% event with no in-progress record now creates a completed seen
directly and sets the completed cache. For lower progress, a first event
with progress above zero applies that progress after creating the record.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: handle Plex events that carry no viewOffset
Plex omits Metadata.viewOffset when the position is zero or the playback
has ended, so media.play and media.stop were failing with "No position
associated with this media". Scrobble, which Plex fires at roughly 90%
watched, now always means completed, even when an offset is present.
media.play without an offset starts at 0%, and pause/resume/stop without
an offset carry no position and are ignored instead of raising errors.
Unit tests cover each of these cases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: add end-to-end tests for the Plex sink webhook
Cover multipart requests carrying a binary thumbnail, a multipart request
without a payload part, and a scrobble-only flow that must end as a single
completed seen at 100%, the regression reported in #1826.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: replace MinIO with RustFS as the S3 test dependency
The minio/minio image can no longer be pulled from Docker Hub, which
stopped the e2e suite before any test ran. The orchestrator already had a
RustFS code path behind TEST_S3_IMAGE; make it the only one. The container
now defaults to rustfs/rustfs:latest, is health-checked on /health, and all
MinIO naming in the orchestrator and the contributing docs is switched to
RustFS or neutral "storage" names.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: fix Plex webhook e2e tests so they run against a real backend
Webhook routes are served under /backend, so the tests were getting 404s.
The test integration also needs minimumProgress and maximumProgress, since
the webhook handler unwraps minimum_progress and panics without it. The
scrobble flow needs TMDb to fetch metadata, so it only runs when
MOVIES_AND_SHOWS_TMDB_ACCESS_TOKEN is set instead of failing in
environments without a token.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: accept a null isRevoked for unrevoked access links
The API returns null, not false, for a link that was never revoked, so
the exact-false assertion failed even though the attacker was correctly
rejected. Assert the link is simply not revoked.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: bound the webhook body read by the configured request size limit
Reading non-multipart webhook bodies with to_bytes(usize::MAX) bypassed
DefaultBodyLimit, which the previous String extractor honored, so an
oversized request could be buffered without limit. The read is now capped
at the server's max file size, matching the router-wide limit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: keep start and finish dates when a webhook completes directly
The direct-completion path always used WithoutDates, so a completed seen
created from a webhook lost the started_on and ended_on the handler had
set. The date handling is extracted into a helper shared with the import
path so both pick the right dated variant.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: skip TMDb lookups for Plex events that carry no position
Progress is now computed before the media lookup, so stop, pause and
resume events without a viewOffset return early instead of resolving the
show on TMDb only to be ignored.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* test: wait for RustFS readiness instead of liveness
/health only reports that the process is up; /health/ready also waits for
storage initialization, so the bucket is created only once the store can
accept it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix: match the multipart content type case-insensitively
Media type tokens are case-insensitive (RFC 9110), so a request sent as
"Multipart/Form-Data" fell into the plain-text branch and was rejected
instead of having its payload part extracted. The check now parses the
media type before the parameters and compares it ignoring case.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>