From d2cc45763bc8eba9198fc0f39a25a1cb5c6e5c23 Mon Sep 17 00:00:00 2001 From: soerennb Date: Tue, 8 Sep 2026 14:03:01 +0200 Subject: [PATCH] feat: integrate collaborative review identity and feedback --- .github/workflows/ci.yml | 11 +- CHANGELOG.md | 23 +- README.md | 2 + appinfo/info.xml | 2 +- docs/DEVELOPMENT.md | 55 +++ docs/PRIVACY.md | 10 +- docs/USER-GUIDE.md | 21 + docs/de/administrationshandbuch.md | 14 + docs/de/benutzerhandbuch.md | 22 ++ docs/en/admin-guide.md | 13 + docs/en/user-guide.md | 21 + eslint.config.js | 5 + l10n/de.js | 30 +- l10n/de.json | 30 +- l10n/en.js | 30 +- l10n/en.json | 30 +- lib/Controller/CollaborationController.php | 111 ++++-- lib/Controller/GuestController.php | 29 +- lib/Controller/PublicReviewController.php | 31 +- lib/Db/ActivityRepository.php | 4 +- lib/Db/CollaborationRepository.php | 220 +++++++---- lib/Db/Guest.php | 3 +- lib/Db/GuestRating.php | 15 +- lib/Db/GuestRatingMapper.php | 38 +- lib/Db/PurgeRepository.php | 128 +++++- lib/Db/ReviewRoundRepository.php | 30 +- lib/Domain/CollaborationActor.php | 70 ++++ lib/Domain/CollaborationReadScope.php | 12 + lib/Dto/Settings/ReviewSettings.php | 2 +- lib/Migration/Version000130Date20260903.php | 93 +++++ lib/Migration/Version000140Date20260908.php | 27 ++ lib/Service/ActivityService.php | 27 +- .../AuthenticatedCollaborationSession.php | 53 +++ lib/Service/CollaborationService.php | 219 ++++++---- lib/Service/GuestRatingAggregator.php | 8 +- lib/Service/GuestRatingService.php | 56 ++- lib/Service/PrivacyService.php | 2 +- lib/Service/ReviewWorkflowService.php | 18 +- lib/Share/PublicShareTemplateProvider.php | 9 + lib/UserMigration/ProofingGalleryMigrator.php | 3 +- package-lock.json | 4 +- package.json | 2 +- scripts/prepare-public-history.sh | 13 +- scripts/test-public-history-sanitizer.sh | 19 + scripts/test-upgrade.sh | 10 + src/PublicApp.vue | 48 ++- src/components/AdminDocumentation.vue | 6 +- src/components/AdminSettingsApp.vue | 5 +- src/components/CullingWorkspace.vue | 8 +- src/components/EventDeliveryWorkspace.vue | 2 +- src/components/EventRecipientLedger.vue | 2 +- src/components/HelpView.vue | 4 +- src/components/NotificationPanel.vue | 4 +- src/components/PersonalSettingsApp.vue | 3 +- src/components/PublicCollaborationSheet.vue | 17 +- src/components/PublicGuestDialog.spec.ts | 51 +++ src/components/PublicGuestDialog.vue | 85 ++-- src/components/PublicGuestIdentity.vue | 2 +- src/components/PublicLightbox.vue | 374 +++++++++--------- .../PublicLightboxAnnotations.spec.ts | 85 +++- src/components/PublicLightboxAnnotations.vue | 117 ++++-- src/components/PublicLightboxComments.spec.ts | 106 +++++ src/components/PublicLightboxComments.vue | 152 +++++++ src/components/PublicLightboxFeedbackTabs.vue | 82 ++++ .../PublicLightboxGeneralFeedback.spec.ts | 46 +++ .../PublicLightboxGeneralFeedback.vue | 163 ++++++++ src/components/PublicLightboxMetadata.vue | 5 +- .../PublicLightboxPinThreads.spec.ts | 54 +++ src/components/PublicLightboxPinThreads.vue | 135 +++++++ src/components/PublicLinkManager.vue | 52 ++- src/components/SelectionManager.vue | 2 +- .../styles/PublicGalleryControls.css | 2 +- src/components/styles/PublicLightbox.css | 129 ++---- .../styles/PublicLightboxGeneralFeedback.css | 94 +++++ .../workspaces/GalleryOverviewWorkspace.vue | 2 +- .../usePublicLightboxAnnotations.spec.ts | 181 ++++++++- .../usePublicLightboxAnnotations.ts | 192 ++++++++- .../usePublicLightboxZoomSurface.spec.ts | 59 +++ .../usePublicLightboxZoomSurface.ts | 228 +++++++++++ src/domain/collaboration.ts | 9 +- src/domain/gallerySettings.spec.ts | 2 +- src/domain/gallerySettings.ts | 8 +- src/domain/lightboxReview.spec.ts | 55 ++- src/domain/lightboxReview.ts | 112 +++++- src/domain/publicGalleryThemeStyle.spec.ts | 19 + src/domain/publicGalleryThemeStyle.ts | 6 + src/public.ts | 1 + src/publicTypes.ts | 6 +- src/types.ts | 2 +- .../Db/CollaborationSelectionIdentityTest.php | 58 +++ tests/Unit/Db/PurgeRepositoryContractTest.php | 10 + tests/Unit/Domain/CollaborationActorTest.php | 39 ++ .../Domain/CollaborationReadScopeTest.php | 7 + tests/Unit/Dto/GallerySettingsTest.php | 2 +- .../Integration/IntegrationContractTest.php | 9 + .../AuthenticatedCollaborationSessionTest.php | 57 +++ tests/Unit/Service/GuestRatingServiceTest.php | 23 +- .../Service/PortableManifestObjectTest.php | 32 ++ tests/e2e/gallery.spec.ts | 108 +++-- tests/e2e/regression.spec.ts | 10 +- tests/e2e/review-identity.spec.ts | 140 +++++++ tests/e2e/review-viewer.spec.ts | 116 ++++++ tests/smoke/CollaborationSelectionSync.php | 67 ++++ tests/smoke/UpgradeCollaboration.php | 65 +++ tests/smoke/UserMigrationExport.php | 4 + tests/smoke/UserMigrationImport.php | 4 + tsconfig.json | 1 + 107 files changed, 4265 insertions(+), 779 deletions(-) create mode 100644 lib/Domain/CollaborationActor.php create mode 100644 lib/Migration/Version000130Date20260903.php create mode 100644 lib/Migration/Version000140Date20260908.php create mode 100644 lib/Service/AuthenticatedCollaborationSession.php create mode 100644 src/components/PublicGuestDialog.spec.ts create mode 100644 src/components/PublicLightboxComments.spec.ts create mode 100644 src/components/PublicLightboxComments.vue create mode 100644 src/components/PublicLightboxFeedbackTabs.vue create mode 100644 src/components/PublicLightboxGeneralFeedback.spec.ts create mode 100644 src/components/PublicLightboxGeneralFeedback.vue create mode 100644 src/components/PublicLightboxPinThreads.spec.ts create mode 100644 src/components/PublicLightboxPinThreads.vue create mode 100644 src/components/styles/PublicLightboxGeneralFeedback.css create mode 100644 src/composables/usePublicLightboxZoomSurface.spec.ts create mode 100644 src/composables/usePublicLightboxZoomSurface.ts create mode 100644 src/domain/publicGalleryThemeStyle.spec.ts create mode 100644 src/domain/publicGalleryThemeStyle.ts create mode 100644 tests/Unit/Db/CollaborationSelectionIdentityTest.php create mode 100644 tests/Unit/Domain/CollaborationActorTest.php create mode 100644 tests/Unit/Service/AuthenticatedCollaborationSessionTest.php create mode 100644 tests/Unit/Service/PortableManifestObjectTest.php create mode 100644 tests/e2e/review-identity.spec.ts create mode 100644 tests/e2e/review-viewer.spec.ts create mode 100644 tests/smoke/CollaborationSelectionSync.php create mode 100644 tests/smoke/UpgradeCollaboration.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f5a2cf..95c4032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,9 +253,9 @@ jobs: matrix: include: - suite: ui - specs: tests/e2e/collection.spec.ts tests/e2e/event.spec.ts tests/e2e/gallery.spec.ts + specs: tests/e2e/collection.spec.ts tests/e2e/event.spec.ts tests/e2e/gallery.spec.ts tests/e2e/review-viewer.spec.ts - suite: api - specs: tests/e2e/ecosystem.spec.ts tests/e2e/regression.spec.ts tests/e2e/security.spec.ts + specs: tests/e2e/ecosystem.spec.ts tests/e2e/regression.spec.ts tests/e2e/security.spec.ts tests/e2e/review-identity.spec.ts steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -272,11 +272,11 @@ jobs: key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-playwright- - - name: Install Chromium and runner dependencies + - name: Install browsers and runner dependencies run: | - if ! timeout --kill-after=30s 5m npx playwright install --with-deps chromium; then + if ! timeout --kill-after=30s 5m npx playwright install --with-deps chromium firefox webkit; then echo "::warning::System dependency installation stalled; using runner-provided browser libraries." - npx playwright install chromium + npx playwright install chromium firefox webkit fi - run: make dev-up - run: make occ CMD="app:list --enabled --output=json" | grep -q '"proofing_gallery"' @@ -287,6 +287,7 @@ jobs: run: | ./scripts/test-context-agent.sh ./scripts/test-user-migration.sh + docker compose exec -T --user www-data nextcloud php /var/www/html/custom_apps/proofing_gallery/tests/smoke/CollaborationSelectionSync.php - name: Verify protected public routes if: matrix.suite == 'api' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c36f4c..224e7dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,19 @@ ## Unreleased -## 0.9.2 — 2026-09-07 - -- make the mobile gallery and lightbox easier to use with more reliable overlays, - touch interaction, navigation, and action controls -- make success feedback and file-conflict resolution clearer and more accessible, - including improved screen-reader announcements and a simpler conflict dialog -- improve project settings and reusable preset controls with updated Nextcloud - interface components -- harden client-side input handling against case and whitespace variants of - malicious `onload` attributes +- separate general feedback and pin conversations beside the image without + dimming the artwork, with labelled review colors and localized comment headers +- attribute signed-in reviews to Nextcloud accounts while retaining guest + authorship, private ratings, link restrictions, and private selection updates +- identify pin replies by their root comment rather than matching coordinates; + retain conversations when their initial comment is deleted +- invalidate live collaboration cursors after account or guest data erasure so + open viewers refresh without exposing deleted identity data +- keep annotations attached during zoom and support bounded touch panning +- add account attribution and private event recipient migrations; complete the + database upgrade before serving the updated application +- accept empty settings during user migration and fail migration smoke checks + with a nonzero exit status ## 0.9.1 — 2026-09-06 diff --git a/README.md b/README.md index f03b48c..4d92e6d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Automation, Privacy, and History workspaces. and server-rendered preview watermarks - likes, color states, comments, image annotations, named selections, and CSV/plain-text exports +- separate general comments and expandable pin conversations, with panels beside + the selected detail, color-labelled review states, and account-aware authorship - individual downloads, selected ZIP files, and printable contact sheets - resumable guest uploads to a hidden moderation inbox - user/group gallery managers, activity filters, and opt-in event digests for diff --git a/appinfo/info.xml b/appinfo/info.xml index 8ff54c3..e9629a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -73,7 +73,7 @@ Vorlagen und Nextcloud-Integrationen, sofern aktiviert. Administrations-, Galerie-, Link- und Event-Wellen-Regeln werden gemeinsam ausgewertet, sodass ein öffentlicher Link nur erlauben kann, was alle geltenden Regeln zulassen. ]]> - 0.9.2 + 0.10.0 AGPL-3.0-or-later soerennb ProofingGallery diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e9ad654..ba5d084 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -22,6 +22,47 @@ PHP routes, templates, or controller constructors, run `docker compose restart nextcloud` to clear PHP OPcache before validating the change. +## Persistent remote test tenant + +A Docker-capable Linux VM can be used as a persistent manual-QA tenant when +Docker is not installed on the editing workstation. Keep source and build +dependencies in a staging directory outside the live Nextcloud Compose tree. +Build and test in disposable containers, then copy only a successful runtime +tree into the tenant's `custom_apps/proofing_gallery` directory. + +Before deployment, record `occ status`, the enabled-app list, migration state, +and container health. Keep the previous app directory only during the immediate +HTTP, `occ`, log, and browser checks. Restore it only if that deployment fails; +delete it as soon as the replacement passes so the development tenant retains +only the latest verified app build. Never reset the tenant's Compose volumes as +part of an app deployment. + +Keep rollback copies outside `custom_apps`; Nextcloud scans every directory in +that path and a backup containing the same app ID can cause duplicate-app +discovery. App Store packages intentionally omit development-only `scripts/`, +so run migration diagnostics from the source staging tree rather than assuming +they are installed with the runtime package. + +The persistent tenant is for manual integration and browser QA only. Do not +run this repository's Playwright global setup against it: the current E2E +fixtures assume the disposable `admin` / `admin` tenant, rewrite that user's +preferences, and remove prior `E2E` fixtures. Use `npm run test:e2e` only with +the repository's isolated loopback Compose stack, or refactor the harness for +a dedicated test identity before targeting another tenant. + +For frontend-only UI work, a persistent pinned Node container may run +`npm run watch` against the staging checkout. Run the affected Vitest files +during each small iteration and copy only generated `build`, `css`, and `js` +assets into the installed development app. Stable entry assets must be served +without long-lived caching on that development host so a refresh discovers +new content-hashed chunks. Run the complete lint, unit, build, package, PHP, +and compatibility gates at milestones and before publication. Never use the +frontend-only path for PHP, routes, migrations, or dependency changes. + +Machine-specific addresses, SSH key locations, credentials, and deployment +commands belong in an ignored local runbook under `.local/`, never in tracked +documentation. + ## Documentation sources and builds The English and German user and administrator guides under `docs/en/` and @@ -123,6 +164,13 @@ verify desktop and 390 px mobile layouts, scroll reachability, horizontal overflow, media hit testing, rows below the hero, and side and bottom filmstrip placement inside the viewport. +For feedback panels, test the overflow model rather than requiring every item +to fit at once. A history of 20 or more comments must scroll inside the panel; +headers, comment text, identity labels, edit controls, and the composer must not +be clipped, overlapped, or made unreachable at short viewport heights. Selecting +an image annotation may keep its marker visible for context, but the comment +workflow must remain usable without relying on the marker staying on screen. + Playwright global setup creates and later supersedes its own E2E gallery. Snapshots are intentionally versioned. Update them only after reviewing the rendered images, preferably through the isolation-preserving wrapper: @@ -204,6 +252,13 @@ used for this gate without modifying its application code. ## Database changes +Point replies use `parentId` referencing a root comment in the same gallery, +file and visible feedback scope. Nested replies and foreign/private roots are +rejected. The server copies the root annotation; client-supplied reply coordinates +are not authoritative. Responses expose `threadId` independently of coordinates, +so coincident pins remain distinct. Deleted roots retain a text-free tombstone +while replies survive, including when their original author is deleted. + Add a new monotonically increasing migration; do not modify released migrations. Use Nextcloud's schema abstraction exclusively and rerun all three database engines. Keep controllers thin and put authorization and domain rules diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 78716d2..bd2e592 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -57,10 +57,15 @@ and workflow labels even if those values exist in Files or an XMP sidecar. ## Visibility and retention -Private proofing exposes a guest's feedback only to that guest and gallery +Private proofing exposes an author's feedback only to that guest or account and gallery managers. Collaborative proofing deliberately shares feedback with other reviewers. The chosen policy should be communicated before inviting guests. +Signed-in feedback records the existing Nextcloud UID. Historical guest +authorship is not transferred on sign-in. Owner-originated selection events +retain the owner's UID and a separate private account recipient where needed; +neither identity field is added to the public event response. + Guest identities expire after 30 days unless renewed by product behavior. Scheduled cleanup removes expired identities, abandoned chunks, stale derived previews, and old internal activity in bounded batches. Owners can revoke the @@ -72,6 +77,9 @@ identity and contributions and can erase those records from the gallery. The mutation requires both the guest cookie and its independent nonce. Gallery exports omit session hashes, nonces, public-link and verification tokens, unsubscribe tokens, encrypted email ciphertext, and Live Push password hashes. +Open collaboration viewers receive a privacy-neutral reset signal and +re-hydrate their permitted state after such an erasure; the signal contains no +deleted identity or contribution data. After archiving, an owner may schedule deletion of that gallery's app records with a 30-day cancellation period. The dry-run reports affected row categories diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 357cc41..34b31ea 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -122,6 +122,27 @@ pick, reject, label, comment, annotate, and save named selections. Guests do not need Nextcloud accounts. Their identity and mutation token are stored in a private browser session; clearing site data ends access to private feedback. +If you are signed in to Nextcloud, new feedback uses your account identity. +Earlier guest comments remain linked to their original guest identity; signing +in does not claim them. Account identity does not bypass gallery or link rules. +Uploads still require a guest session; signed-in reviewers can open the link +in a private browser window when they need to upload files. + +**Feedback** opens **General comments**, newest first beneath the input. +The **Pins** tab groups point conversations into expandable rows. Expand a row +to read its replies, or use its separate open button to show the conversation +beside the pin. These panels do not dim the image. **Review state** shows both +the configured state name and its color indicator. +Each pin has its own conversation, even when two pins share the same position. +Replies remain available when the original comment is deleted; its text is +replaced with a deletion notice. + +Zoom with the viewer controls, mouse wheel, or a touch pinch. On desktop, hold +the right mouse button and drag the zoomed image to pan. A right-click without +dragging keeps the browser context menu. Pins retain their image-relative +positions while zooming and panning. On touchscreens, drag with one finger +after zooming to reach the image edges. Dragging does not create a new pin. + Click or tap an image to place a numbered point and open its comment editor. For keyboard placement, choose **Add point comment**, move the point with the arrow keys, press Enter to write, or Escape to cancel. Unpinned comments remain diff --git a/docs/de/administrationshandbuch.md b/docs/de/administrationshandbuch.md index 40216cc..6ef3fd9 100644 --- a/docs/de/administrationshandbuch.md +++ b/docs/de/administrationshandbuch.md @@ -51,6 +51,20 @@ Aufbewahrung. Die Regeln werden serverseitig erzwungen und sperren kritische Funktionen bei Unsicherheit. Native Nextcloud-Regeln für Freigaben, Passwörter, Ablauf und Uploads bleiben maßgeblich und werden niemals gelockert. +Öffentliche Prüfer können Gastidentitäten oder angemeldete Nextcloud-Konten +verwenden. Konto-UIDs bestimmen die Urheberschaft, nicht zusätzliche Rechte. +Frühere Gastbeiträge werden nicht anhand von Namen oder E-Mail-Adressen +übertragen. Privates Feedback bleibt auf den Autor und berechtigte +Galerieverwalter beschränkt. Eigentümeränderungen an einer Kontoauswahl werden +nur an den zulässigen Empfängerkreis übermittelt. Gast-Uploads bleiben ein +separater, Gästen vorbehaltener Ablauf. + +Die Migration der Kontozuordnung behält bei doppelten Konto-Feedbackzeilen die +höchste ID, bevor sie Eindeutigkeitsregeln anlegt; Gastkommentare und Punkte +werden nicht neu zugeordnet. Eine folgende Migration ergänzt eine optionale +Empfänger-UID für Ereignisse, getrennt vom auslösenden Eigentümer. Schließe das +normale Datenbank-Upgrade ab, bevor der neue Anwendungscode bereitgestellt wird. + Prüfe Freigabe-, Mail- und Gruppenrichtlinien vor der Einführung. Aktiviere Gast-Downloads und -Uploads nur bei Bedarf. Richte Grenzen nach PHP, Proxy, Speicher und Worker-Kapazität aus, nicht nach Browservalidierung. diff --git a/docs/de/benutzerhandbuch.md b/docs/de/benutzerhandbuch.md index 5b1b95f..40a88db 100644 --- a/docs/de/benutzerhandbuch.md +++ b/docs/de/benutzerhandbuch.md @@ -131,6 +131,28 @@ Auswahlen speichern. Ein Nextcloud-Konto ist nicht nötig. Identität und Änderungstoken liegen in einer privaten Browsersitzung; gelöschte Website-Daten beenden den Zugriff auf privates Feedback. +Wenn du bei Nextcloud angemeldet bist, gehört neues Feedback zu deinem Konto. +Frühere Gastkommentare bleiben ihrer ursprünglichen Gastidentität zugeordnet; +die Anmeldung übernimmt sie nicht. Galerie- und Linkregeln gelten weiterhin. +Uploads benötigen weiterhin eine Gastsitzung. Öffne dafür als angemeldeter +Benutzer den Link in einem privaten Browserfenster. + +**Feedback** öffnet **Allgemeine Kommentare**, mit den neuesten Beiträgen direkt +unter dem Eingabefeld. **Markierungen** gruppiert Punktgespräche in aufklappbaren +Zeilen. Klappe eine Zeile zum Lesen auf oder öffne das Gespräch über dessen +separate Schaltfläche neben dem Punkt. Diese Fenster verdunkeln das Bild nicht. +**Prüfstatus** zeigt den konfigurierten Statusnamen zusammen mit seiner Farbe. +Jeder Punkt hat ein eigenes Gespräch, auch bei identischen Bildkoordinaten. +Antworten bleiben beim Löschen des ursprünglichen Kommentars erhalten; dessen +Text wird durch einen Löschhinweis ersetzt. + +Zoome über die Bedienelemente, das Mausrad oder eine Zwei-Finger-Geste. Am Desktop +kannst du das vergrößerte Bild mit gedrückter rechter Maustaste verschieben. +Ein Rechtsklick ohne Ziehen öffnet weiterhin das Browser-Kontextmenü. Punkte +bleiben beim Zoomen und Verschieben an derselben bildrelativen Position. +Auf Touchscreens kannst du nach dem Zoomen mit einem Finger bis zu den +Bildrändern verschieben. Dabei wird kein neuer Punkt gesetzt. + Klicke oder tippe direkt auf ein Bild, um einen nummerierten Punkt zu setzen und den zugehörigen Kommentar zu schreiben. Wähle für die Tastaturplatzierung **Punktkommentar hinzufügen**, verschiebe den Punkt mit den Pfeiltasten, drücke diff --git a/docs/en/admin-guide.md b/docs/en/admin-guide.md index 2da63d0..11186e6 100644 --- a/docs/en/admin-guide.md +++ b/docs/en/admin-guide.md @@ -52,6 +52,19 @@ the server, including existing galleries where a capability must fail closed. Native Nextcloud sharing, password, expiration, and upload restrictions remain authoritative; this app never weakens them. +Public reviewers may use guest identities or existing signed-in Nextcloud +accounts. Account UIDs establish authorship, not additional permissions. Earlier +guest contributions are not reassigned by matching names or email addresses. +Private feedback stays private to its author and authorized gallery managers; +owner changes to an account's selection are delivered only to the permitted +review audience. Guest uploads remain a separate guest-only workflow. + +The account-attribution migration retains the highest-ID duplicate account +feedback row before adding uniqueness constraints; guest comments and pins are +not reassigned. A subsequent migration adds a nullable event recipient UID so +owner attribution remains distinct from private account delivery. Complete the +normal database upgrade before serving the new application code. + Review public-link, mail, and group policy before onboarding users. Keep guest downloads and uploads disabled unless required. Set limits according to PHP, proxy, storage, and worker capacity rather than relying on browser validation. diff --git a/docs/en/user-guide.md b/docs/en/user-guide.md index 357cc41..34b31ea 100644 --- a/docs/en/user-guide.md +++ b/docs/en/user-guide.md @@ -122,6 +122,27 @@ pick, reject, label, comment, annotate, and save named selections. Guests do not need Nextcloud accounts. Their identity and mutation token are stored in a private browser session; clearing site data ends access to private feedback. +If you are signed in to Nextcloud, new feedback uses your account identity. +Earlier guest comments remain linked to their original guest identity; signing +in does not claim them. Account identity does not bypass gallery or link rules. +Uploads still require a guest session; signed-in reviewers can open the link +in a private browser window when they need to upload files. + +**Feedback** opens **General comments**, newest first beneath the input. +The **Pins** tab groups point conversations into expandable rows. Expand a row +to read its replies, or use its separate open button to show the conversation +beside the pin. These panels do not dim the image. **Review state** shows both +the configured state name and its color indicator. +Each pin has its own conversation, even when two pins share the same position. +Replies remain available when the original comment is deleted; its text is +replaced with a deletion notice. + +Zoom with the viewer controls, mouse wheel, or a touch pinch. On desktop, hold +the right mouse button and drag the zoomed image to pan. A right-click without +dragging keeps the browser context menu. Pins retain their image-relative +positions while zooming and panning. On touchscreens, drag with one finger +after zooming to reach the image edges. Dragging does not create a new pin. + Click or tap an image to place a numbered point and open its comment editor. For keyboard placement, choose **Add point comment**, move the point with the arrow keys, press Enter to write, or Escape to cancel. Unpinned comments remain diff --git a/eslint.config.js b/eslint.config.js index dde928d..62c7681 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -29,6 +29,11 @@ export default [ 'vue/custom-event-name-casing': ['error', 'kebab-case'], 'vue/define-macros-order': 'off', 'vue/first-attribute-linebreak': 'off', + 'vue/no-bare-strings-in-template': ['error', { + // Numbers, icons, punctuation, units and protocol tokens are not prose. + // Everything containing translatable words must use the l10n helpers. + allowlist: ['/^[^\\p{L}]+$/u', '/^[A-Z]$/u', 'Ø', 'TXT', 'px'], + }], 'vue/v-on-event-hyphenation': ['error', 'always'], }, }, diff --git a/l10n/de.js b/l10n/de.js index 8e4ac27..87c7928 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -3,6 +3,10 @@ OC.L10N.register("proofing_gallery", { "%n Album", "%n Alben" ], + "_%n comment_::_%n comments_": [ + "%n Kommentar", + "%n Kommentare" + ], "_%n folder_::_%n folders_": [ "%n Ordner", "%n Ordner" @@ -134,6 +138,7 @@ OC.L10N.register("proofing_gallery", { "Access audit could not be loaded.": "Das Zugriffsprotokoll konnte nicht geladen werden.", "access changed": "geänderte Zugriffsrechte", "Access map": "Zugriffsplan", + "Access summary": "Zugriffsübersicht", "Access, features and defaults": "Zugriff, Funktionen und Vorgaben", "Actions for {name}": "Aktionen für {name}", "Actions for {title}": "Aktionen für {title}", @@ -178,6 +183,7 @@ OC.L10N.register("proofing_gallery", { "An administrator must configure the retention system tag first.": "Ein Administrator muss zuerst den System-Tag für die Aufbewahrung konfigurieren.", "An upload was accepted in {gallery}": "Ein Upload wurde in {gallery} angenommen", "An upload was rejected in {gallery}": "Ein Upload wurde in {gallery} abgelehnt", + "App": "App", "Appearance": "Erscheinungsbild", "Appearance: {theme}": "Erscheinungsbild: {theme}", "Apply": "Anwenden", @@ -296,6 +302,7 @@ OC.L10N.register("proofing_gallery", { "Cinematic cover": "Großes Titelbild", "Clear": "Leeren", "Click the image anywhere to add a point comment.": "Klicke auf eine beliebige Stelle im Bild, um einen Punktkommentar hinzuzufügen.", + "Client / Finals": "Kunde / Finale", "Client access": "Kundenzugriff", "Client average": "Kundendurchschnitt", "Client comments": "Kundenkommentare", @@ -330,7 +337,6 @@ OC.L10N.register("proofing_gallery", { "Color": "Farbe", "Color {number}": "Farbe {number}", "Color labels": "Farbmarkierungen", - "Color state": "Farbstatus", "Color state changed": "Farbstatus geändert", "Color states": "Farbstatus", "Color workflow": "Farbworkflow", @@ -398,7 +404,9 @@ OC.L10N.register("proofing_gallery", { "Credential rotated. Save the new password now.": "Zugang erneuert. Speichern Sie das neue Passwort jetzt.", "CSV": "CSV", "CSV preview copied.": "CSV-Vorschau kopiert.", + "Ctrl/⌘ Z": "Strg/⌘ Z", "Cull": "Sichten", + "Culling tools": "Sichtungswerkzeuge", "Curated collection": "Kuratierte Sammlung", "Current folder": "Aktueller Ordner", "Custom domain": "Benutzerdefinierte Domain", @@ -506,9 +514,11 @@ OC.L10N.register("proofing_gallery", { "Enable only the tools your teams actually use.": "Nur die Werkzeuge aktivieren, die Ihre Teams tatsächlich verwenden.", "Enable review rounds on a client link in Share.": "Aktivieren Sie Prüfrunden bei einem Kundenlink unter Teilen.", "Encoding preset": "Kodierungsvoreinstellung", + "English": "Englisch", "Enter a gallery title": "Geben Sie einen Galerietitel ein", "Enter each PIN yourself before release.": "Jede PIN vor der Veröffentlichung selbst eingeben.", "Enter the custom domain for this link": "Benutzerdefinierte Domain für diesen Link eingeben", + "Esc": "Esc", "ETag conflicts": "ETag-Konflikte", "Event delivery": "Event-Auslieferung", "Event delivery steps": "Schritte der Event-Auslieferung", @@ -532,6 +542,7 @@ OC.L10N.register("proofing_gallery", { "Exit fullscreen": "Vollbild beenden", "Expires on": "Läuft ab am", "Expires on (optional)": "Gültig bis (optional)", + "Export": "Export", "Export app data (NDJSON)": "App-Daten exportieren (NDJSON)", "Export composer": "Export-Konfigurator", "Export my data": "Meine Daten exportieren", @@ -544,6 +555,7 @@ OC.L10N.register("proofing_gallery", { "Failed": "Fehlgeschlagen", "Fast": "Schnell", "Fastest for links you send privately.": "Am schnellsten für Links, die Sie privat versenden.", + "Favorite": "Favorit", "Feedback": "Feedback", "Feedback is shared with reviewers": "Feedback ist für alle Beteiligten sichtbar", "FFmpeg executable": "FFmpeg-Programmdatei", @@ -583,6 +595,7 @@ OC.L10N.register("proofing_gallery", { "For a field-wise merge, choose the source of truth for each value.": "Wähle für einen feldweisen Abgleich die maßgebliche Quelle jedes Werts.", "For everyone": "Für alle", "For your group": "Für deine Gruppe", + "Former user": "Ehemaliger Benutzer", "Full frame": "Vollformat", "Full screen": "Vollbild", "Fullscreen": "Vollbild", @@ -629,8 +642,10 @@ OC.L10N.register("proofing_gallery", { "Gallery updates for “%s”": "Aktualisierungen für „%s“", "Gallery view": "Galerieansicht", "General": "Allgemein", + "General comments": "Allgemeine Kommentare", "Generated locally in your browser. The gallery link is not sent to a third party.": "Wird lokal in Ihrem Browser erstellt. Der Galerielink wird nicht an Dritte gesendet.", "Generated PIN": "Generierte PIN", + "German": "Deutsch", "Give a Nextcloud user or group view-only or editing access.": "Geben Sie einer Nextcloud-Person oder -Gruppe Lese- oder Bearbeitungszugriff.", "Give clients a polished gallery with the downloads they need.": "Geben Sie Kunden eine hochwertige Galerie mit den benötigten Downloads.", "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Gib jeder Zielgruppe eine eigene URL, eigene Berechtigungen und einen eigenen Ordnerbereich. Passwörter und Token werden nie im Auditprotokoll gespeichert.", @@ -761,6 +776,7 @@ OC.L10N.register("proofing_gallery", { "Loading event workflow…": "Event-Ablauf wird geladen …", "Loading files…": "Dateien werden geladen …", "Loading galleries…": "Galerien werden geladen …", + "Loading gallery": "Galerie wird geladen", "Loading link history…": "Linkverlauf wird geladen…", "Loading more photographs…": "Weitere Fotos werden geladen …", "Loading private ratings…": "Private Bewertungen werden geladen…", @@ -831,6 +847,7 @@ OC.L10N.register("proofing_gallery", { "Name this saved view": "Gespeicherte Ansicht benennen", "Navigate": "Navigieren", "Needs attention": "Aktion erforderlich", + "Needs changes": "Änderungen erforderlich", "New activity in {gallery}": "Neue Aktivität in {gallery}", "New client link": "Neuer Kundenlink", "New client uploads": "Neue Kunden-Uploads", @@ -867,6 +884,7 @@ OC.L10N.register("proofing_gallery", { "No audited link activity yet.": "Noch keine protokollierte Linkaktivität.", "No client ratings have been submitted yet.": "Es wurden noch keine Kundenbewertungen abgegeben.", "No color": "Keine Farbe", + "No comments yet.": "Noch keine Kommentare.", "No custom domains requested.": "Keine benutzerdefinierten Domains angefragt.", "No files or folders match your search.": "Keine Dateien oder Ordner entsprechen der Suche.", "No folder scopes overlap": "Ordnerfreigaben überschneiden sich nicht", @@ -880,6 +898,7 @@ OC.L10N.register("proofing_gallery", { "No matching galleries": "Keine passenden Galerien", "No matching gallery images.": "Keine passenden Galeriebilder.", "No PIN": "Keine PIN", + "No point comments yet.": "Noch keine Punktkommentare.", "No presets yet. Enter a name to save the current settings.": "Noch keine Voreinstellungen. Geben Sie einen Namen ein, um die aktuellen Einstellungen zu speichern.", "No review round is active": "Keine Prüfrunde ist aktiv", "No saved selections yet": "Noch keine gespeicherten Auswahlen", @@ -972,6 +991,7 @@ OC.L10N.register("proofing_gallery", { "Photos in this section": "Fotos in diesem Abschnitt", "Pick": "Auswählen", "Picks": "Ausgewählt", + "Pins": "Markierungen", "PNG, JPEG, WebP or SVG, up to 5 MiB.": "PNG, JPEG, WebP oder SVG bis 5 MiB.", "Point comment": "Punktkommentar", "Point comment {number}": "Punktkommentar {number}", @@ -1019,6 +1039,7 @@ OC.L10N.register("proofing_gallery", { "Private guest feedback": "Privates Kundenfeedback", "Private guest pick or reject": "Private Auswahl oder Ablehnung durch Kunden", "Private guest star ratings": "Private Sternebewertungen durch Kunden", + "Private rating": "Private Bewertung", "private ratings on this page": "private Bewertungen auf dieser Seite", "Processing, video and search": "Verarbeitung, Video und Suche", "Project automation": "Projektautomatisierung", @@ -1110,6 +1131,7 @@ OC.L10N.register("proofing_gallery", { "Refresh to try again.": "Aktualisieren Sie die Ansicht, um es erneut zu versuchen.", "Refreshing…": "Wird aktualisiert …", "Reject": "Ablehnen", + "Rejected": "Abgelehnt", "Rejected uploads": "Abgelehnte Uploads", "Rejects": "Abgelehnt", "Relative path": "Relativer Pfad", @@ -1174,6 +1196,7 @@ OC.L10N.register("proofing_gallery", { "Review round": "Prüfrunde", "Review round {round}: {status}": "Prüfrunde {round}: {status}", "Review rounds could not be loaded.": "Die Prüfrunden konnten nicht geladen werden.", + "Review state": "Prüfstatus", "Review the newer server version before continuing.": "Prüfen Sie die neuere Serverversion, bevor Sie fortfahren.", "Review together": "Gemeinsam abstimmen", "Review updates are temporarily unavailable.": "Aktualisierungen der Abstimmung sind vorübergehend nicht verfügbar.", @@ -1267,6 +1290,7 @@ OC.L10N.register("proofing_gallery", { "Select all visible files": "Alle sichtbaren Dateien auswählen", "Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.", "Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.", + "Selected": "Ausgewählt", "Selected file actions": "Aktionen für ausgewählte Dateien", "Selected files": "Ausgewählte Dateien", "Selected groups": "Ausgewählte Gruppen", @@ -1274,6 +1298,7 @@ OC.L10N.register("proofing_gallery", { "Selected project type": "Gewählte Projektart", "Selected recipient links updated.": "Ausgewählte Empfängerlinks wurden aktualisiert.", "Selected story photos": "Ausgewählte Story-Fotos", + "Selection": "Auswahl", "Selection deleted.": "Auswahl gelöscht.", "Selection name": "Name der Auswahl", "Selection saved": "Auswahl gespeichert", @@ -1343,6 +1368,7 @@ OC.L10N.register("proofing_gallery", { "Source gallery": "Quellgalerie", "Source gallery could not be opened.": "Die Quellgalerie konnte nicht geöffnet werden.", "Source unavailable": "Quelle nicht verfügbar", + "Space": "Leertaste", "Spacing": "Abstand", "Square": "Quadratisch", "Standard": "Standard", @@ -1531,6 +1557,7 @@ OC.L10N.register("proofing_gallery", { "Uploading event folder…": "Event-Ordner wird hochgeladen …", "Uploading…": "Wird hochgeladen …", "Uploads": "Uploads", + "Uploads require a guest session. Open this link in a private browser window to upload files.": "Uploads benötigen eine Gastsitzung. Öffne diesen Link in einem privaten Browserfenster, um Dateien hochzuladen.", "Use a compact opening for fast review or a cinematic cover for final delivery.": "Nutzen Sie einen kompakten Einstieg für schnelle Abstimmungen oder ein großes Titelbild für die finale Übergabe.", "Use App values": "App-Werte verwenden", "Use dark appearance": "Dunkles Erscheinungsbild verwenden", @@ -1585,6 +1612,7 @@ OC.L10N.register("proofing_gallery", { "Workflow features": "Workflow-Funktionen", "Write a comment…": "Kommentar schreiben …", "Write XMP sidecars": "XMP-Sidecars schreiben", + "XMP": "XMP", "XMP sidecar saved.": "XMP-Sidecar gespeichert.", "XMP sidecars could not be written.": "XMP-Sidecars konnten nicht geschrieben werden.", "XMP sidecars were written for {written} files; {failed} files need attention.": "XMP-Sidecars wurden für {written} Dateien geschrieben; {failed} Dateien benötigen Aufmerksamkeit.", diff --git a/l10n/de.json b/l10n/de.json index 91ba73a..e16b824 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -4,6 +4,10 @@ "%n Album", "%n Alben" ], + "_%n comment_::_%n comments_": [ + "%n Kommentar", + "%n Kommentare" + ], "_%n folder_::_%n folders_": [ "%n Ordner", "%n Ordner" @@ -135,6 +139,7 @@ "Access audit could not be loaded.": "Das Zugriffsprotokoll konnte nicht geladen werden.", "access changed": "geänderte Zugriffsrechte", "Access map": "Zugriffsplan", + "Access summary": "Zugriffsübersicht", "Access, features and defaults": "Zugriff, Funktionen und Vorgaben", "Actions for {name}": "Aktionen für {name}", "Actions for {title}": "Aktionen für {title}", @@ -179,6 +184,7 @@ "An administrator must configure the retention system tag first.": "Ein Administrator muss zuerst den System-Tag für die Aufbewahrung konfigurieren.", "An upload was accepted in {gallery}": "Ein Upload wurde in {gallery} angenommen", "An upload was rejected in {gallery}": "Ein Upload wurde in {gallery} abgelehnt", + "App": "App", "Appearance": "Erscheinungsbild", "Appearance: {theme}": "Erscheinungsbild: {theme}", "Apply": "Anwenden", @@ -297,6 +303,7 @@ "Cinematic cover": "Großes Titelbild", "Clear": "Leeren", "Click the image anywhere to add a point comment.": "Klicke auf eine beliebige Stelle im Bild, um einen Punktkommentar hinzuzufügen.", + "Client / Finals": "Kunde / Finale", "Client access": "Kundenzugriff", "Client average": "Kundendurchschnitt", "Client comments": "Kundenkommentare", @@ -331,7 +338,6 @@ "Color": "Farbe", "Color {number}": "Farbe {number}", "Color labels": "Farbmarkierungen", - "Color state": "Farbstatus", "Color state changed": "Farbstatus geändert", "Color states": "Farbstatus", "Color workflow": "Farbworkflow", @@ -399,7 +405,9 @@ "Credential rotated. Save the new password now.": "Zugang erneuert. Speichern Sie das neue Passwort jetzt.", "CSV": "CSV", "CSV preview copied.": "CSV-Vorschau kopiert.", + "Ctrl/⌘ Z": "Strg/⌘ Z", "Cull": "Sichten", + "Culling tools": "Sichtungswerkzeuge", "Curated collection": "Kuratierte Sammlung", "Current folder": "Aktueller Ordner", "Custom domain": "Benutzerdefinierte Domain", @@ -507,9 +515,11 @@ "Enable only the tools your teams actually use.": "Nur die Werkzeuge aktivieren, die Ihre Teams tatsächlich verwenden.", "Enable review rounds on a client link in Share.": "Aktivieren Sie Prüfrunden bei einem Kundenlink unter Teilen.", "Encoding preset": "Kodierungsvoreinstellung", + "English": "Englisch", "Enter a gallery title": "Geben Sie einen Galerietitel ein", "Enter each PIN yourself before release.": "Jede PIN vor der Veröffentlichung selbst eingeben.", "Enter the custom domain for this link": "Benutzerdefinierte Domain für diesen Link eingeben", + "Esc": "Esc", "ETag conflicts": "ETag-Konflikte", "Event delivery": "Event-Auslieferung", "Event delivery steps": "Schritte der Event-Auslieferung", @@ -533,6 +543,7 @@ "Exit fullscreen": "Vollbild beenden", "Expires on": "Läuft ab am", "Expires on (optional)": "Gültig bis (optional)", + "Export": "Export", "Export app data (NDJSON)": "App-Daten exportieren (NDJSON)", "Export composer": "Export-Konfigurator", "Export my data": "Meine Daten exportieren", @@ -545,6 +556,7 @@ "Failed": "Fehlgeschlagen", "Fast": "Schnell", "Fastest for links you send privately.": "Am schnellsten für Links, die Sie privat versenden.", + "Favorite": "Favorit", "Feedback": "Feedback", "Feedback is shared with reviewers": "Feedback ist für alle Beteiligten sichtbar", "FFmpeg executable": "FFmpeg-Programmdatei", @@ -584,6 +596,7 @@ "For a field-wise merge, choose the source of truth for each value.": "Wähle für einen feldweisen Abgleich die maßgebliche Quelle jedes Werts.", "For everyone": "Für alle", "For your group": "Für deine Gruppe", + "Former user": "Ehemaliger Benutzer", "Full frame": "Vollformat", "Full screen": "Vollbild", "Fullscreen": "Vollbild", @@ -630,8 +643,10 @@ "Gallery updates for “%s”": "Aktualisierungen für „%s“", "Gallery view": "Galerieansicht", "General": "Allgemein", + "General comments": "Allgemeine Kommentare", "Generated locally in your browser. The gallery link is not sent to a third party.": "Wird lokal in Ihrem Browser erstellt. Der Galerielink wird nicht an Dritte gesendet.", "Generated PIN": "Generierte PIN", + "German": "Deutsch", "Give a Nextcloud user or group view-only or editing access.": "Geben Sie einer Nextcloud-Person oder -Gruppe Lese- oder Bearbeitungszugriff.", "Give clients a polished gallery with the downloads they need.": "Geben Sie Kunden eine hochwertige Galerie mit den benötigten Downloads.", "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Gib jeder Zielgruppe eine eigene URL, eigene Berechtigungen und einen eigenen Ordnerbereich. Passwörter und Token werden nie im Auditprotokoll gespeichert.", @@ -762,6 +777,7 @@ "Loading event workflow…": "Event-Ablauf wird geladen …", "Loading files…": "Dateien werden geladen …", "Loading galleries…": "Galerien werden geladen …", + "Loading gallery": "Galerie wird geladen", "Loading link history…": "Linkverlauf wird geladen…", "Loading more photographs…": "Weitere Fotos werden geladen …", "Loading private ratings…": "Private Bewertungen werden geladen…", @@ -832,6 +848,7 @@ "Name this saved view": "Gespeicherte Ansicht benennen", "Navigate": "Navigieren", "Needs attention": "Aktion erforderlich", + "Needs changes": "Änderungen erforderlich", "New activity in {gallery}": "Neue Aktivität in {gallery}", "New client link": "Neuer Kundenlink", "New client uploads": "Neue Kunden-Uploads", @@ -868,6 +885,7 @@ "No audited link activity yet.": "Noch keine protokollierte Linkaktivität.", "No client ratings have been submitted yet.": "Es wurden noch keine Kundenbewertungen abgegeben.", "No color": "Keine Farbe", + "No comments yet.": "Noch keine Kommentare.", "No custom domains requested.": "Keine benutzerdefinierten Domains angefragt.", "No files or folders match your search.": "Keine Dateien oder Ordner entsprechen der Suche.", "No folder scopes overlap": "Ordnerfreigaben überschneiden sich nicht", @@ -881,6 +899,7 @@ "No matching galleries": "Keine passenden Galerien", "No matching gallery images.": "Keine passenden Galeriebilder.", "No PIN": "Keine PIN", + "No point comments yet.": "Noch keine Punktkommentare.", "No presets yet. Enter a name to save the current settings.": "Noch keine Voreinstellungen. Geben Sie einen Namen ein, um die aktuellen Einstellungen zu speichern.", "No review round is active": "Keine Prüfrunde ist aktiv", "No saved selections yet": "Noch keine gespeicherten Auswahlen", @@ -973,6 +992,7 @@ "Photos in this section": "Fotos in diesem Abschnitt", "Pick": "Auswählen", "Picks": "Ausgewählt", + "Pins": "Markierungen", "PNG, JPEG, WebP or SVG, up to 5 MiB.": "PNG, JPEG, WebP oder SVG bis 5 MiB.", "Point comment": "Punktkommentar", "Point comment {number}": "Punktkommentar {number}", @@ -1020,6 +1040,7 @@ "Private guest feedback": "Privates Kundenfeedback", "Private guest pick or reject": "Private Auswahl oder Ablehnung durch Kunden", "Private guest star ratings": "Private Sternebewertungen durch Kunden", + "Private rating": "Private Bewertung", "private ratings on this page": "private Bewertungen auf dieser Seite", "Processing, video and search": "Verarbeitung, Video und Suche", "Project automation": "Projektautomatisierung", @@ -1111,6 +1132,7 @@ "Refresh to try again.": "Aktualisieren Sie die Ansicht, um es erneut zu versuchen.", "Refreshing…": "Wird aktualisiert …", "Reject": "Ablehnen", + "Rejected": "Abgelehnt", "Rejected uploads": "Abgelehnte Uploads", "Rejects": "Abgelehnt", "Relative path": "Relativer Pfad", @@ -1175,6 +1197,7 @@ "Review round": "Prüfrunde", "Review round {round}: {status}": "Prüfrunde {round}: {status}", "Review rounds could not be loaded.": "Die Prüfrunden konnten nicht geladen werden.", + "Review state": "Prüfstatus", "Review the newer server version before continuing.": "Prüfen Sie die neuere Serverversion, bevor Sie fortfahren.", "Review together": "Gemeinsam abstimmen", "Review updates are temporarily unavailable.": "Aktualisierungen der Abstimmung sind vorübergehend nicht verfügbar.", @@ -1268,6 +1291,7 @@ "Select all visible files": "Alle sichtbaren Dateien auswählen", "Select photos across pages, then download them together.": "Fotos seitenübergreifend auswählen und anschließend gemeinsam herunterladen.", "Select public image information to make this panel available to guests.": "Wählen Sie öffentliche Bildinformationen aus, damit dieses Panel Gästen zur Verfügung steht.", + "Selected": "Ausgewählt", "Selected file actions": "Aktionen für ausgewählte Dateien", "Selected files": "Ausgewählte Dateien", "Selected groups": "Ausgewählte Gruppen", @@ -1275,6 +1299,7 @@ "Selected project type": "Gewählte Projektart", "Selected recipient links updated.": "Ausgewählte Empfängerlinks wurden aktualisiert.", "Selected story photos": "Ausgewählte Story-Fotos", + "Selection": "Auswahl", "Selection deleted.": "Auswahl gelöscht.", "Selection name": "Name der Auswahl", "Selection saved": "Auswahl gespeichert", @@ -1344,6 +1369,7 @@ "Source gallery": "Quellgalerie", "Source gallery could not be opened.": "Die Quellgalerie konnte nicht geöffnet werden.", "Source unavailable": "Quelle nicht verfügbar", + "Space": "Leertaste", "Spacing": "Abstand", "Square": "Quadratisch", "Standard": "Standard", @@ -1532,6 +1558,7 @@ "Uploading event folder…": "Event-Ordner wird hochgeladen …", "Uploading…": "Wird hochgeladen …", "Uploads": "Uploads", + "Uploads require a guest session. Open this link in a private browser window to upload files.": "Uploads benötigen eine Gastsitzung. Öffne diesen Link in einem privaten Browserfenster, um Dateien hochzuladen.", "Use a compact opening for fast review or a cinematic cover for final delivery.": "Nutzen Sie einen kompakten Einstieg für schnelle Abstimmungen oder ein großes Titelbild für die finale Übergabe.", "Use App values": "App-Werte verwenden", "Use dark appearance": "Dunkles Erscheinungsbild verwenden", @@ -1586,6 +1613,7 @@ "Workflow features": "Workflow-Funktionen", "Write a comment…": "Kommentar schreiben …", "Write XMP sidecars": "XMP-Sidecars schreiben", + "XMP": "XMP", "XMP sidecar saved.": "XMP-Sidecar gespeichert.", "XMP sidecars could not be written.": "XMP-Sidecars konnten nicht geschrieben werden.", "XMP sidecars were written for {written} files; {failed} files need attention.": "XMP-Sidecars wurden für {written} Dateien geschrieben; {failed} Dateien benötigen Aufmerksamkeit.", diff --git a/l10n/en.js b/l10n/en.js index 1f59e01..9003128 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -3,6 +3,10 @@ OC.L10N.register("proofing_gallery", { "%n album", "%n albums" ], + "_%n comment_::_%n comments_": [ + "%n comment", + "%n comments" + ], "_%n folder_::_%n folders_": [ "%n folder", "%n folders" @@ -134,6 +138,7 @@ OC.L10N.register("proofing_gallery", { "Access audit could not be loaded.": "Access audit could not be loaded.", "access changed": "access changed", "Access map": "Access map", + "Access summary": "Access summary", "Access, features and defaults": "Access, features and defaults", "Actions for {name}": "Actions for {name}", "Actions for {title}": "Actions for {title}", @@ -178,6 +183,7 @@ OC.L10N.register("proofing_gallery", { "An administrator must configure the retention system tag first.": "An administrator must configure the retention system tag first.", "An upload was accepted in {gallery}": "An upload was accepted in {gallery}", "An upload was rejected in {gallery}": "An upload was rejected in {gallery}", + "App": "App", "Appearance": "Appearance", "Appearance: {theme}": "Appearance: {theme}", "Apply": "Apply", @@ -296,6 +302,7 @@ OC.L10N.register("proofing_gallery", { "Cinematic cover": "Cinematic cover", "Clear": "Clear", "Click the image anywhere to add a point comment.": "Click the image anywhere to add a point comment.", + "Client / Finals": "Client / Finals", "Client access": "Client access", "Client average": "Client average", "Client comments": "Client comments", @@ -330,7 +337,6 @@ OC.L10N.register("proofing_gallery", { "Color": "Color", "Color {number}": "Color {number}", "Color labels": "Color labels", - "Color state": "Color state", "Color state changed": "Color state changed", "Color states": "Color states", "Color workflow": "Color workflow", @@ -398,7 +404,9 @@ OC.L10N.register("proofing_gallery", { "Credential rotated. Save the new password now.": "Credential rotated. Save the new password now.", "CSV": "CSV", "CSV preview copied.": "CSV preview copied.", + "Ctrl/⌘ Z": "Ctrl/⌘ Z", "Cull": "Cull", + "Culling tools": "Culling tools", "Curated collection": "Curated collection", "Current folder": "Current folder", "Custom domain": "Custom domain", @@ -506,9 +514,11 @@ OC.L10N.register("proofing_gallery", { "Enable only the tools your teams actually use.": "Enable only the tools your teams actually use.", "Enable review rounds on a client link in Share.": "Enable review rounds on a client link in Share.", "Encoding preset": "Encoding preset", + "English": "English", "Enter a gallery title": "Enter a gallery title", "Enter each PIN yourself before release.": "Enter each PIN yourself before release.", "Enter the custom domain for this link": "Enter the custom domain for this link", + "Esc": "Esc", "ETag conflicts": "ETag conflicts", "Event delivery": "Event delivery", "Event delivery steps": "Event delivery steps", @@ -532,6 +542,7 @@ OC.L10N.register("proofing_gallery", { "Exit fullscreen": "Exit fullscreen", "Expires on": "Expires on", "Expires on (optional)": "Expires on (optional)", + "Export": "Export", "Export app data (NDJSON)": "Export app data (NDJSON)", "Export composer": "Export composer", "Export my data": "Export my data", @@ -544,6 +555,7 @@ OC.L10N.register("proofing_gallery", { "Failed": "Failed", "Fast": "Fast", "Fastest for links you send privately.": "Fastest for links you send privately.", + "Favorite": "Favorite", "Feedback": "Feedback", "Feedback is shared with reviewers": "Feedback is shared with reviewers", "FFmpeg executable": "FFmpeg executable", @@ -583,6 +595,7 @@ OC.L10N.register("proofing_gallery", { "For a field-wise merge, choose the source of truth for each value.": "For a field-wise merge, choose the source of truth for each value.", "For everyone": "For everyone", "For your group": "For your group", + "Former user": "Former user", "Full frame": "Full frame", "Full screen": "Full screen", "Fullscreen": "Fullscreen", @@ -629,8 +642,10 @@ OC.L10N.register("proofing_gallery", { "Gallery updates for “%s”": "Gallery updates for “%s”", "Gallery view": "Gallery view", "General": "General", + "General comments": "General comments", "Generated locally in your browser. The gallery link is not sent to a third party.": "Generated locally in your browser. The gallery link is not sent to a third party.", "Generated PIN": "Generated PIN", + "German": "German", "Give a Nextcloud user or group view-only or editing access.": "Give a Nextcloud user or group view-only or editing access.", "Give clients a polished gallery with the downloads they need.": "Give clients a polished gallery with the downloads they need.", "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.", @@ -761,6 +776,7 @@ OC.L10N.register("proofing_gallery", { "Loading event workflow…": "Loading event workflow…", "Loading files…": "Loading files…", "Loading galleries…": "Loading galleries…", + "Loading gallery": "Loading gallery", "Loading link history…": "Loading link history…", "Loading more photographs…": "Loading more photographs…", "Loading private ratings…": "Loading private ratings…", @@ -831,6 +847,7 @@ OC.L10N.register("proofing_gallery", { "Name this saved view": "Name this saved view", "Navigate": "Navigate", "Needs attention": "Needs attention", + "Needs changes": "Needs changes", "New activity in {gallery}": "New activity in {gallery}", "New client link": "New client link", "New client uploads": "New client uploads", @@ -867,6 +884,7 @@ OC.L10N.register("proofing_gallery", { "No audited link activity yet.": "No audited link activity yet.", "No client ratings have been submitted yet.": "No client ratings have been submitted yet.", "No color": "No color", + "No comments yet.": "No comments yet.", "No custom domains requested.": "No custom domains requested.", "No files or folders match your search.": "No files or folders match your search.", "No folder scopes overlap": "No folder scopes overlap", @@ -880,6 +898,7 @@ OC.L10N.register("proofing_gallery", { "No matching galleries": "No matching galleries", "No matching gallery images.": "No matching gallery images.", "No PIN": "No PIN", + "No point comments yet.": "No point comments yet.", "No presets yet. Enter a name to save the current settings.": "No presets yet. Enter a name to save the current settings.", "No review round is active": "No review round is active", "No saved selections yet": "No saved selections yet", @@ -972,6 +991,7 @@ OC.L10N.register("proofing_gallery", { "Photos in this section": "Photos in this section", "Pick": "Pick", "Picks": "Picks", + "Pins": "Pins", "PNG, JPEG, WebP or SVG, up to 5 MiB.": "PNG, JPEG, WebP or SVG, up to 5 MiB.", "Point comment": "Point comment", "Point comment {number}": "Point comment {number}", @@ -1019,6 +1039,7 @@ OC.L10N.register("proofing_gallery", { "Private guest feedback": "Private guest feedback", "Private guest pick or reject": "Private guest pick or reject", "Private guest star ratings": "Private guest star ratings", + "Private rating": "Private rating", "private ratings on this page": "private ratings on this page", "Processing, video and search": "Processing, video and search", "Project automation": "Project automation", @@ -1110,6 +1131,7 @@ OC.L10N.register("proofing_gallery", { "Refresh to try again.": "Refresh to try again.", "Refreshing…": "Refreshing…", "Reject": "Reject", + "Rejected": "Rejected", "Rejected uploads": "Rejected uploads", "Rejects": "Rejects", "Relative path": "Relative path", @@ -1174,6 +1196,7 @@ OC.L10N.register("proofing_gallery", { "Review round": "Review round", "Review round {round}: {status}": "Review round {round}: {status}", "Review rounds could not be loaded.": "Review rounds could not be loaded.", + "Review state": "Review state", "Review the newer server version before continuing.": "Review the newer server version before continuing.", "Review together": "Review together", "Review updates are temporarily unavailable.": "Review updates are temporarily unavailable.", @@ -1267,6 +1290,7 @@ OC.L10N.register("proofing_gallery", { "Select all visible files": "Select all visible files", "Select photos across pages, then download them together.": "Select photos across pages, then download them together.", "Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.", + "Selected": "Selected", "Selected file actions": "Selected file actions", "Selected files": "Selected files", "Selected groups": "Selected groups", @@ -1274,6 +1298,7 @@ OC.L10N.register("proofing_gallery", { "Selected project type": "Selected project type", "Selected recipient links updated.": "Selected recipient links updated.", "Selected story photos": "Selected story photos", + "Selection": "Selection", "Selection deleted.": "Selection deleted.", "Selection name": "Selection name", "Selection saved": "Selection saved", @@ -1343,6 +1368,7 @@ OC.L10N.register("proofing_gallery", { "Source gallery": "Source gallery", "Source gallery could not be opened.": "Source gallery could not be opened.", "Source unavailable": "Source unavailable", + "Space": "Space", "Spacing": "Spacing", "Square": "Square", "Standard": "Standard", @@ -1531,6 +1557,7 @@ OC.L10N.register("proofing_gallery", { "Uploading event folder…": "Uploading event folder…", "Uploading…": "Uploading…", "Uploads": "Uploads", + "Uploads require a guest session. Open this link in a private browser window to upload files.": "Uploads require a guest session. Open this link in a private browser window to upload files.", "Use a compact opening for fast review or a cinematic cover for final delivery.": "Use a compact opening for fast review or a cinematic cover for final delivery.", "Use App values": "Use App values", "Use dark appearance": "Use dark appearance", @@ -1585,6 +1612,7 @@ OC.L10N.register("proofing_gallery", { "Workflow features": "Workflow features", "Write a comment…": "Write a comment…", "Write XMP sidecars": "Write XMP sidecars", + "XMP": "XMP", "XMP sidecar saved.": "XMP sidecar saved.", "XMP sidecars could not be written.": "XMP sidecars could not be written.", "XMP sidecars were written for {written} files; {failed} files need attention.": "XMP sidecars were written for {written} files; {failed} files need attention.", diff --git a/l10n/en.json b/l10n/en.json index 9ad2733..27c1dd8 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -4,6 +4,10 @@ "%n album", "%n albums" ], + "_%n comment_::_%n comments_": [ + "%n comment", + "%n comments" + ], "_%n folder_::_%n folders_": [ "%n folder", "%n folders" @@ -135,6 +139,7 @@ "Access audit could not be loaded.": "Access audit could not be loaded.", "access changed": "access changed", "Access map": "Access map", + "Access summary": "Access summary", "Access, features and defaults": "Access, features and defaults", "Actions for {name}": "Actions for {name}", "Actions for {title}": "Actions for {title}", @@ -179,6 +184,7 @@ "An administrator must configure the retention system tag first.": "An administrator must configure the retention system tag first.", "An upload was accepted in {gallery}": "An upload was accepted in {gallery}", "An upload was rejected in {gallery}": "An upload was rejected in {gallery}", + "App": "App", "Appearance": "Appearance", "Appearance: {theme}": "Appearance: {theme}", "Apply": "Apply", @@ -297,6 +303,7 @@ "Cinematic cover": "Cinematic cover", "Clear": "Clear", "Click the image anywhere to add a point comment.": "Click the image anywhere to add a point comment.", + "Client / Finals": "Client / Finals", "Client access": "Client access", "Client average": "Client average", "Client comments": "Client comments", @@ -331,7 +338,6 @@ "Color": "Color", "Color {number}": "Color {number}", "Color labels": "Color labels", - "Color state": "Color state", "Color state changed": "Color state changed", "Color states": "Color states", "Color workflow": "Color workflow", @@ -399,7 +405,9 @@ "Credential rotated. Save the new password now.": "Credential rotated. Save the new password now.", "CSV": "CSV", "CSV preview copied.": "CSV preview copied.", + "Ctrl/⌘ Z": "Ctrl/⌘ Z", "Cull": "Cull", + "Culling tools": "Culling tools", "Curated collection": "Curated collection", "Current folder": "Current folder", "Custom domain": "Custom domain", @@ -507,9 +515,11 @@ "Enable only the tools your teams actually use.": "Enable only the tools your teams actually use.", "Enable review rounds on a client link in Share.": "Enable review rounds on a client link in Share.", "Encoding preset": "Encoding preset", + "English": "English", "Enter a gallery title": "Enter a gallery title", "Enter each PIN yourself before release.": "Enter each PIN yourself before release.", "Enter the custom domain for this link": "Enter the custom domain for this link", + "Esc": "Esc", "ETag conflicts": "ETag conflicts", "Event delivery": "Event delivery", "Event delivery steps": "Event delivery steps", @@ -533,6 +543,7 @@ "Exit fullscreen": "Exit fullscreen", "Expires on": "Expires on", "Expires on (optional)": "Expires on (optional)", + "Export": "Export", "Export app data (NDJSON)": "Export app data (NDJSON)", "Export composer": "Export composer", "Export my data": "Export my data", @@ -545,6 +556,7 @@ "Failed": "Failed", "Fast": "Fast", "Fastest for links you send privately.": "Fastest for links you send privately.", + "Favorite": "Favorite", "Feedback": "Feedback", "Feedback is shared with reviewers": "Feedback is shared with reviewers", "FFmpeg executable": "FFmpeg executable", @@ -584,6 +596,7 @@ "For a field-wise merge, choose the source of truth for each value.": "For a field-wise merge, choose the source of truth for each value.", "For everyone": "For everyone", "For your group": "For your group", + "Former user": "Former user", "Full frame": "Full frame", "Full screen": "Full screen", "Fullscreen": "Fullscreen", @@ -630,8 +643,10 @@ "Gallery updates for “%s”": "Gallery updates for “%s”", "Gallery view": "Gallery view", "General": "General", + "General comments": "General comments", "Generated locally in your browser. The gallery link is not sent to a third party.": "Generated locally in your browser. The gallery link is not sent to a third party.", "Generated PIN": "Generated PIN", + "German": "German", "Give a Nextcloud user or group view-only or editing access.": "Give a Nextcloud user or group view-only or editing access.", "Give clients a polished gallery with the downloads they need.": "Give clients a polished gallery with the downloads they need.", "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.": "Give every audience its own URL, permissions and folder scope. No password or token is written to the audit log.", @@ -762,6 +777,7 @@ "Loading event workflow…": "Loading event workflow…", "Loading files…": "Loading files…", "Loading galleries…": "Loading galleries…", + "Loading gallery": "Loading gallery", "Loading link history…": "Loading link history…", "Loading more photographs…": "Loading more photographs…", "Loading private ratings…": "Loading private ratings…", @@ -832,6 +848,7 @@ "Name this saved view": "Name this saved view", "Navigate": "Navigate", "Needs attention": "Needs attention", + "Needs changes": "Needs changes", "New activity in {gallery}": "New activity in {gallery}", "New client link": "New client link", "New client uploads": "New client uploads", @@ -868,6 +885,7 @@ "No audited link activity yet.": "No audited link activity yet.", "No client ratings have been submitted yet.": "No client ratings have been submitted yet.", "No color": "No color", + "No comments yet.": "No comments yet.", "No custom domains requested.": "No custom domains requested.", "No files or folders match your search.": "No files or folders match your search.", "No folder scopes overlap": "No folder scopes overlap", @@ -881,6 +899,7 @@ "No matching galleries": "No matching galleries", "No matching gallery images.": "No matching gallery images.", "No PIN": "No PIN", + "No point comments yet.": "No point comments yet.", "No presets yet. Enter a name to save the current settings.": "No presets yet. Enter a name to save the current settings.", "No review round is active": "No review round is active", "No saved selections yet": "No saved selections yet", @@ -973,6 +992,7 @@ "Photos in this section": "Photos in this section", "Pick": "Pick", "Picks": "Picks", + "Pins": "Pins", "PNG, JPEG, WebP or SVG, up to 5 MiB.": "PNG, JPEG, WebP or SVG, up to 5 MiB.", "Point comment": "Point comment", "Point comment {number}": "Point comment {number}", @@ -1020,6 +1040,7 @@ "Private guest feedback": "Private guest feedback", "Private guest pick or reject": "Private guest pick or reject", "Private guest star ratings": "Private guest star ratings", + "Private rating": "Private rating", "private ratings on this page": "private ratings on this page", "Processing, video and search": "Processing, video and search", "Project automation": "Project automation", @@ -1111,6 +1132,7 @@ "Refresh to try again.": "Refresh to try again.", "Refreshing…": "Refreshing…", "Reject": "Reject", + "Rejected": "Rejected", "Rejected uploads": "Rejected uploads", "Rejects": "Rejects", "Relative path": "Relative path", @@ -1175,6 +1197,7 @@ "Review round": "Review round", "Review round {round}: {status}": "Review round {round}: {status}", "Review rounds could not be loaded.": "Review rounds could not be loaded.", + "Review state": "Review state", "Review the newer server version before continuing.": "Review the newer server version before continuing.", "Review together": "Review together", "Review updates are temporarily unavailable.": "Review updates are temporarily unavailable.", @@ -1268,6 +1291,7 @@ "Select all visible files": "Select all visible files", "Select photos across pages, then download them together.": "Select photos across pages, then download them together.", "Select public image information to make this panel available to guests.": "Select public image information to make this panel available to guests.", + "Selected": "Selected", "Selected file actions": "Selected file actions", "Selected files": "Selected files", "Selected groups": "Selected groups", @@ -1275,6 +1299,7 @@ "Selected project type": "Selected project type", "Selected recipient links updated.": "Selected recipient links updated.", "Selected story photos": "Selected story photos", + "Selection": "Selection", "Selection deleted.": "Selection deleted.", "Selection name": "Selection name", "Selection saved": "Selection saved", @@ -1344,6 +1369,7 @@ "Source gallery": "Source gallery", "Source gallery could not be opened.": "Source gallery could not be opened.", "Source unavailable": "Source unavailable", + "Space": "Space", "Spacing": "Spacing", "Square": "Square", "Standard": "Standard", @@ -1532,6 +1558,7 @@ "Uploading event folder…": "Uploading event folder…", "Uploading…": "Uploading…", "Uploads": "Uploads", + "Uploads require a guest session. Open this link in a private browser window to upload files.": "Uploads require a guest session. Open this link in a private browser window to upload files.", "Use a compact opening for fast review or a cinematic cover for final delivery.": "Use a compact opening for fast review or a cinematic cover for final delivery.", "Use App values": "Use App values", "Use dark appearance": "Use dark appearance", @@ -1586,6 +1613,7 @@ "Workflow features": "Workflow features", "Write a comment…": "Write a comment…", "Write XMP sidecars": "Write XMP sidecars", + "XMP": "XMP", "XMP sidecar saved.": "XMP sidecar saved.", "XMP sidecars could not be written.": "XMP sidecars could not be written.", "XMP sidecars were written for {written} files; {failed} files need attention.": "XMP sidecars were written for {written} files; {failed} files need attention.", diff --git a/lib/Controller/CollaborationController.php b/lib/Controller/CollaborationController.php index 08a30ea..f203ac3 100644 --- a/lib/Controller/CollaborationController.php +++ b/lib/Controller/CollaborationController.php @@ -8,6 +8,8 @@ use OCA\ProofingGallery\Db\Gallery; use OCA\ProofingGallery\Db\PublicLink; use OCA\ProofingGallery\Db\Guest; +use OCA\ProofingGallery\Domain\CollaborationActor; +use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession; use OCA\ProofingGallery\Service\CollaborationService; use OCA\ProofingGallery\Service\GuestService; use OCA\ProofingGallery\Service\PublicShareContextResolver; @@ -38,6 +40,7 @@ public function __construct( private \OCA\ProofingGallery\Service\GuestRatingService $guestRatings, private \OCA\ProofingGallery\Service\CapabilityPolicyService $capabilities, private \OCA\ProofingGallery\Service\ShareAuditService $shareAudit, + private AuthenticatedCollaborationSession $authenticated, ) { parent::__construct($request, $session, $contextResolver); } @@ -48,7 +51,7 @@ public function __construct( public function state(int $cursor = 0, string $fileIds = ''): JSONResponse { try { $visibleFileIds = $this->parseVisibleFileIds($cursor, $fileIds); - $state = $this->collaboration->publicState($this->resolvedGallery(), $this->optionalGuest(), $cursor, $visibleFileIds); + $state = $this->collaboration->publicState($this->resolvedGallery(), $this->optionalActor(), $cursor, $visibleFileIds); } catch (InvalidArgumentException $exception) { return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } @@ -93,12 +96,17 @@ public function state(int $cursor = 0, string $fileIds = ''): JSONResponse { } return true; })); - $guest = $this->optionalGuest(); - $state['ratings'] = $guest === null || !$ratingEnabled + $actor = $this->optionalActor(); + $state['ratings'] = $actor === null || !$ratingEnabled ? [] : array_values(array_map( static fn ($rating): array => $rating->jsonSerialize(), - array_filter($visibleFileIds === [] ? $this->guestRatings->forGuest($guest) : $this->guestRatings->forGuestFiles($guest, $visibleFileIds), fn ($rating): bool => $this->allowsFile($rating->getFileId())), + array_filter( + $visibleFileIds === [] + ? $this->guestRatings->forActor($this->resolvedGallery()->getId(), $actor) + : $this->guestRatings->forActorFiles($this->resolvedGallery()->getId(), $actor, $visibleFileIds), + fn ($rating): bool => $this->allowsFile($rating->getFileId()), + ), )); return new JSONResponse($state); } @@ -111,17 +119,23 @@ public function setRating(int $fileId, int $rating = 0, string $pick = 'none'): if (!$this->ratingEnabled()) return new JSONResponse(['code' => 'policy_denied', 'message' => 'Guest ratings are disabled for this link'], Http::STATUS_FORBIDDEN); if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND); try { - $guest = $this->guests->authenticate($this->resolvedGallery(), $this->guestSecret($this->resolvedGallery()), $this->request->getHeader('X-Proofing-Nonce')); + $actor = $this->authenticateActor(); $permissions = $this->ratingPermissions(); $current = null; - foreach ($this->guestRatings->forGuest($guest) as $saved) if ($saved->getFileId() === $fileId) { $current = $saved; break; } + foreach ($this->guestRatings->forActor($this->resolvedGallery()->getId(), $actor) as $saved) if ($saved->getFileId() === $fileId) { $current = $saved; break; } $rating = $permissions['ratings'] ? $rating : ($current?->getRating() ?? 0); $pick = $permissions['pick'] ? $pick : ($current?->getPickState() ?? 'none'); - $value = $this->collaboration->saveRating($this->resolvedPublicLink(), $this->resolvedGallery(), $guest, $fileId, $rating, $pick); - $this->shareAudit->record($this->resolvedPublicLink(), 'feedback', $guest->getId(), fileId: $fileId); + $value = $this->collaboration->saveRating($this->resolvedPublicLink(), $this->resolvedGallery(), $actor, $fileId, $rating, $pick); + $this->shareAudit->record( + $this->resolvedPublicLink(), + 'feedback', + guestId: $actor->guestId(), + actorUid: $actor->userUid(), + fileId: $fileId, + ); return new JSONResponse($value); } catch (DoesNotExistException) { - return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED); + return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED); } catch (InvalidArgumentException $exception) { if ($exception->getMessage() === 'Invalid request nonce') { return new JSONResponse(['code' => 'invalid_nonce', 'message' => $exception->getMessage()], Http::STATUS_FORBIDDEN); @@ -136,8 +150,8 @@ public function setRating(int $fileId, int $rating = 0, string $pick = 'none'): #[FrontpageRoute(verb: 'POST', url: '/public/{token}/collaboration/media/{fileId}/like')] public function toggleLike(int $fileId): JSONResponse { if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND); - return $this->mutation('likes', fn (Guest $guest): array => [ - 'liked' => $this->collaboration->toggleLike($this->resolvedGallery(), $guest, $fileId), + return $this->mutation('likes', fn (CollaborationActor $actor): array => [ + 'liked' => $this->collaboration->toggleLike($this->resolvedGallery(), $actor, $fileId), ]); } @@ -147,8 +161,8 @@ public function toggleLike(int $fileId): JSONResponse { #[FrontpageRoute(verb: 'PUT', url: '/public/{token}/collaboration/media/{fileId}/color')] public function setColor(int $fileId, ?string $value = null): JSONResponse { if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND); - return $this->mutation('colors', function (Guest $guest) use ($fileId, $value): array { - $this->collaboration->setColor($this->resolvedGallery(), $guest, $fileId, $value); + return $this->mutation('colors', function (CollaborationActor $actor) use ($fileId, $value): array { + $this->collaboration->setColor($this->resolvedGallery(), $actor, $fileId, $value); return []; }); } @@ -158,13 +172,13 @@ public function setColor(int $fileId, ?string $value = null): JSONResponse { #[NoCSRFRequired] #[AnonRateLimit(limit: 240, period: 3600)] #[FrontpageRoute(verb: 'POST', url: '/public/{token}/collaboration/media/{fileId}/comments')] - public function addComment(int $fileId, string $body, ?array $annotation = null): JSONResponse { + public function addComment(int $fileId, string $body, ?array $annotation = null, ?int $parentId = null): JSONResponse { if (!$this->allowsFile($fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND); - if ($annotation !== null && !$this->policy()['annotations']) { + if (($annotation !== null || $parentId !== null) && !$this->policy()['annotations']) { return new JSONResponse(['code' => 'policy_denied', 'message' => 'Image annotations are disabled for this link'], Http::STATUS_FORBIDDEN); } - return $this->mutation('comments', fn (Guest $guest): array => [ - 'id' => $this->collaboration->addComment($this->resolvedGallery(), $guest, $fileId, $body, $annotation), + return $this->mutation('comments', fn (CollaborationActor $actor): array => [ + 'id' => $this->collaboration->addComment($this->resolvedGallery(), $actor, $fileId, $body, $annotation, $parentId), ], Http::STATUS_CREATED); } @@ -173,11 +187,11 @@ public function addComment(int $fileId, string $body, ?array $annotation = null) #[AnonRateLimit(limit: 240, period: 3600)] #[FrontpageRoute(verb: 'DELETE', url: '/public/{token}/collaboration/comments/{commentId}')] public function deleteComment(int $commentId): JSONResponse { - return $this->mutation('comments', function (Guest $guest) use ($commentId): array { - if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $guest, $commentId))) { + return $this->mutation('comments', function (CollaborationActor $actor) use ($commentId): array { + if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $actor, $commentId))) { throw new InvalidArgumentException('Comment not found'); } - $this->collaboration->deleteComment($this->resolvedGallery(), $guest, $commentId); + $this->collaboration->deleteComment($this->resolvedGallery(), $actor, $commentId); return []; }); } @@ -187,11 +201,11 @@ public function deleteComment(int $commentId): JSONResponse { #[AnonRateLimit(limit: 240, period: 3600)] #[FrontpageRoute(verb: 'PUT', url: '/public/{token}/collaboration/comments/{commentId}')] public function updateComment(int $commentId, string $body): JSONResponse { - return $this->mutation('comments', function (Guest $guest) use ($commentId, $body): array { - if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $guest, $commentId))) { + return $this->mutation('comments', function (CollaborationActor $actor) use ($commentId, $body): array { + if (!$this->allowsFile($this->collaboration->ownedCommentFileId($this->resolvedGallery(), $actor, $commentId))) { throw new InvalidArgumentException('Comment not found'); } - $this->collaboration->updateComment($this->resolvedGallery(), $guest, $commentId, $body); + $this->collaboration->updateComment($this->resolvedGallery(), $actor, $commentId, $body); return []; }); } @@ -203,11 +217,11 @@ public function updateComment(int $commentId, string $body): JSONResponse { #[FrontpageRoute(verb: 'POST', url: '/public/{token}/collaboration/selections')] public function saveSelection(string $name, string $message = '', array $fileIds = []): JSONResponse { foreach ($fileIds as $fileId) if (!$this->allowsFile((int)$fileId)) return new JSONResponse(['message' => 'Media not found'], Http::STATUS_NOT_FOUND); - return $this->mutation('selections', fn (Guest $guest): array => [ + return $this->mutation('selections', fn (CollaborationActor $actor): array => [ 'id' => $this->collaboration->saveSelection( $this->resolvedGallery(), $this->resolvedPublicLink(), - $guest, + $actor, $name, $message, $fileIds, @@ -220,21 +234,16 @@ public function saveSelection(string $name, string $message = '', array $fileIds #[FrontpageRoute(verb: 'GET', url: '/public/{token}/collaboration/selections/{selectionId}/export')] public function exportSelection(string $selectionId, string $format = 'csv', string $fields = ''): Response { if (!$this->policy()['export']) return new JSONResponse(['code' => 'policy_denied', 'message' => 'Export is disabled for this link'], Http::STATUS_FORBIDDEN); + $actor = $this->optionalActor(); + if ($actor === null) return new JSONResponse(['message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED); try { - $guest = $this->guests->authenticate( - $this->resolvedGallery(), - $this->guestSecret($this->resolvedGallery()), - ); - foreach ($this->collaboration->guestSelectionFileIds($this->resolvedGallery(), $guest, $selectionId) as $fileId) { + foreach ($this->collaboration->actorSelectionFileIds($this->resolvedGallery(), $actor, $selectionId) as $fileId) { if (!$this->allowsFile($fileId)) throw new InvalidArgumentException('Selection not found'); } - $export = $this->collaboration->exportSelection( - $this->resolvedGallery(), - $guest, - $selectionId, - $format, - array_filter(explode(',', $fields)), - ); + $guest = $actor->isGuest() ? $this->optionalGuest() : null; + $export = $guest !== null + ? $this->collaboration->exportSelection($this->resolvedGallery(), $guest, $selectionId, $format, array_filter(explode(',', $fields))) + : $this->collaboration->exportActorSelection($this->resolvedGallery(), $actor, $selectionId, $format, array_filter(explode(',', $fields))); return new DataDownloadResponse( $export['content'], $export['filename'], @@ -265,22 +274,38 @@ private function optionalGuest(): ?Guest { } } + private function optionalActor(): ?CollaborationActor { + $authenticated = $this->authenticated->actor(); + if ($authenticated !== null) return $authenticated; + $guest = $this->optionalGuest(); + return $guest === null ? null : CollaborationActor::guest($guest); + } + + private function authenticateActor(): CollaborationActor { + $authenticated = $this->authenticated->authenticate( + $this->resolvedGallery(), + $this->request->getHeader('X-Proofing-Nonce'), + ); + if ($authenticated !== null) return $authenticated; + return CollaborationActor::guest($this->guests->authenticate( + $this->resolvedGallery(), + $this->guestSecret($this->resolvedGallery()), + $this->request->getHeader('X-Proofing-Nonce'), + )); + } + /** @param Http::STATUS_OK|Http::STATUS_CREATED $status */ private function mutation(string $feature, callable $callback, int $status = Http::STATUS_OK): JSONResponse { if (!$this->policy()[$feature]) return new JSONResponse(['code' => 'policy_denied', 'message' => 'This action is disabled for this link'], Http::STATUS_FORBIDDEN); try { - $guest = $this->guests->authenticate( - $this->resolvedGallery(), - $this->guestSecret($this->resolvedGallery()), - $this->request->getHeader('X-Proofing-Nonce'), - ); + $actor = $this->authenticateActor(); } catch (DoesNotExistException) { return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED); } catch (InvalidArgumentException) { return new JSONResponse(['code' => 'invalid_nonce', 'message' => 'Invalid request nonce'], Http::STATUS_FORBIDDEN); } try { - return new JSONResponse($callback($guest), $status); + return new JSONResponse($callback($actor), $status); } catch (InvalidArgumentException $exception) { return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } diff --git a/lib/Controller/GuestController.php b/lib/Controller/GuestController.php index 58a7788..261582a 100644 --- a/lib/Controller/GuestController.php +++ b/lib/Controller/GuestController.php @@ -7,6 +7,7 @@ use InvalidArgumentException; use OCA\ProofingGallery\Db\Gallery; use OCA\ProofingGallery\Service\GuestService; +use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession; use OCA\ProofingGallery\Service\PublicShareContextResolver; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -24,6 +25,7 @@ public function __construct( ISession $session, PublicShareContextResolver $contextResolver, private GuestService $guests, + private AuthenticatedCollaborationSession $authenticated, ) { parent::__construct($request, $session, $contextResolver); } @@ -34,6 +36,16 @@ public function __construct( #[FrontpageRoute(verb: 'POST', url: '/public/{token}/session')] public function create(string $displayName = '', ?string $email = null): JSONResponse { try { + $authenticated = $this->authenticated->current($this->gallery()); + if ($authenticated !== null) { + $response = new JSONResponse([ + 'guest' => $authenticated['actor'], + 'nonce' => $authenticated['nonce'], + 'expiresIn' => null, + ]); + $response->addHeader('Cache-Control', 'private, no-store'); + return $response; + } $session = $this->guests->create($this->gallery(), $displayName, $email); $response = new JSONResponse([ 'guest' => $session['guest'], @@ -52,7 +64,19 @@ public function create(string $displayName = '', ?string $email = null): JSONRes #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/public/{token}/session')] public function current(): JSONResponse { + $scopedCookieName = GuestService::cookieName($this->gallery()); + $scopedCookiePresent = $this->request->getCookie($scopedCookieName) !== null; try { + $authenticated = $this->authenticated->current($this->gallery()); + if ($authenticated !== null) { + $response = new JSONResponse([ + 'guest' => $authenticated['actor'], + 'nonce' => $authenticated['nonce'], + 'expiresIn' => null, + ]); + $response->addHeader('Cache-Control', 'private, no-store'); + return $response; + } $secret = $this->guestSecret($this->gallery()); $session = $this->guests->resume($this->gallery(), $secret); $response = new JSONResponse(['guest' => $session['guest'], 'nonce' => $session['nonce'], 'expiresIn' => 2592000]); @@ -63,7 +87,10 @@ public function current(): JSONResponse { // An anonymous visitor is the normal initial state of a public gallery. $response = new JSONResponse(['guest' => null]); $response->addHeader('Cache-Control', 'private, no-store'); - $response->invalidateCookie(GuestService::cookieName($this->gallery())); + // Do not emit a deletion for a cookie that was absent from this request. + // A just-created session can otherwise be overwritten by a late + // anonymous bootstrap request from the public app. + if ($scopedCookiePresent) $response->invalidateCookie($scopedCookieName); return $response; } } diff --git a/lib/Controller/PublicReviewController.php b/lib/Controller/PublicReviewController.php index d0f45eb..7d1c879 100644 --- a/lib/Controller/PublicReviewController.php +++ b/lib/Controller/PublicReviewController.php @@ -4,7 +4,9 @@ namespace OCA\ProofingGallery\Controller; +use OCA\ProofingGallery\Domain\CollaborationActor; use OCA\ProofingGallery\Exception\ReviewConflictException; +use OCA\ProofingGallery\Service\AuthenticatedCollaborationSession; use OCA\ProofingGallery\Service\GuestService; use OCA\ProofingGallery\Service\PublicShareContextResolver; use OCA\ProofingGallery\Service\ReviewWorkflowService; @@ -25,6 +27,7 @@ public function __construct( PublicShareContextResolver $contextResolver, private ReviewWorkflowService $reviews, private GuestService $guests, + private AuthenticatedCollaborationSession $authenticated, ) { parent::__construct($request, $session, $contextResolver); } @@ -34,7 +37,13 @@ public function __construct( #[FrontpageRoute(verb: 'GET', url: '/public/{token}/review')] public function state(): JSONResponse { $context = $this->publicContext(); - return new JSONResponse($this->reviews->publicState($context->gallery, $context->link)); + $actor = $this->authenticated->actor(); + if ($actor === null) { + try { + $actor = CollaborationActor::guest($this->guests->authenticate($context->gallery, $this->guestSecret($context->gallery))); + } catch (DoesNotExistException) {} + } + return new JSONResponse($this->reviews->publicState($context->gallery, $context->link, $actor)); } #[PublicPage] @@ -44,14 +53,10 @@ public function state(): JSONResponse { public function submit(): JSONResponse { try { $context = $this->publicContext(); - $guest = $this->guests->authenticate( - $context->gallery, - $this->guestSecret($context->gallery), - $this->request->getHeader('X-Proofing-Nonce'), - ); - return new JSONResponse($this->reviews->submit($context->gallery, $context->link, $guest)); + $actor = $this->authenticateActor($context->gallery); + return new JSONResponse($this->reviews->submit($context->gallery, $context->link, $actor)); } catch (DoesNotExistException) { - return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Guest session required'], Http::STATUS_UNAUTHORIZED); + return new JSONResponse(['code' => 'guest_session_required', 'message' => 'Collaboration identity required'], Http::STATUS_UNAUTHORIZED); } catch (ReviewConflictException $exception) { return new JSONResponse(['code' => 'review_conflict', 'message' => $exception->getMessage()], Http::STATUS_CONFLICT); } catch (\InvalidArgumentException $exception) { @@ -61,4 +66,14 @@ public function submit(): JSONResponse { return new JSONResponse(['message' => $exception->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } } + + private function authenticateActor(\OCA\ProofingGallery\Db\Gallery $gallery): CollaborationActor { + $actor = $this->authenticated->authenticate($gallery, $this->request->getHeader('X-Proofing-Nonce')); + if ($actor !== null) return $actor; + return CollaborationActor::guest($this->guests->authenticate( + $gallery, + $this->guestSecret($gallery), + $this->request->getHeader('X-Proofing-Nonce'), + )); + } } diff --git a/lib/Db/ActivityRepository.php b/lib/Db/ActivityRepository.php index eecd242..65631be 100644 --- a/lib/Db/ActivityRepository.php +++ b/lib/Db/ActivityRepository.php @@ -12,12 +12,12 @@ public function __construct(private IDBConnection $db) { } /** @param array $payload */ - public function insert(int $galleryId, ?int $guestId, string $type, array $payload, int $now): int { + public function insert(int $galleryId, ?int $guestId, string $type, array $payload, int $now, ?string $actorUid = null): int { $qb = $this->db->getQueryBuilder(); $qb->insert('proofing_events')->values([ 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), 'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT), - 'actor_uid' => $qb->createNamedParameter(null), + 'actor_uid' => $qb->createNamedParameter($actorUid), 'event_type' => $qb->createNamedParameter($type), 'payload' => $qb->createNamedParameter(json_encode($payload, JSON_THROW_ON_ERROR)), 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT), diff --git a/lib/Db/CollaborationRepository.php b/lib/Db/CollaborationRepository.php index a7909ad..f3e8a41 100644 --- a/lib/Db/CollaborationRepository.php +++ b/lib/Db/CollaborationRepository.php @@ -8,18 +8,21 @@ use OCA\ProofingGallery\Domain\CollaborationReadScope; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\IUserManager; final class CollaborationRepository { + private const ANNOTATION_COORDINATE_SCALE = 10000; private const COLLABORATION_EVENT_TYPES = [ 'like.changed', 'color.changed', 'comment.created', 'comment.updated', 'comment.deleted', 'rating.changed', 'selection.created', 'selection.updated', 'selection.deleted', + 'collaboration.reset', ]; private const ROW_LIMITS = [ 'proofing_feedback' => 200000, 'proofing_comments' => 10000, 'proofing_selections' => 2000, ]; - public function __construct(private IDBConnection $db) { + public function __construct(private IDBConnection $db, private IUserManager $users) { } /** @@ -39,11 +42,13 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor ]; } $guestId = $scope->guestId(); - $events = $this->events($galleryId, $guestId, $cursor); + $actorUid = $scope->actorUid(); + $events = $this->events($galleryId, $guestId, $actorUid, $cursor); if ($cursor > 0 && $events === []) { return ['feedback' => [], 'comments' => [], 'selections' => [], 'events' => [], 'unchanged' => true]; } $delta = $cursor > 0; + $reset = $delta && array_filter($events, static fn (array $event): bool => $event['event_type'] === 'collaboration.reset') !== []; $eventFileIds = []; $commentIds = []; $selectionIds = []; @@ -58,23 +63,21 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor } $fileIds = array_values(array_unique($delta ? $eventFileIds : array_map('intval', $visibleFileIds))); $feedback = $allFiles && !$delta - ? $this->rows('proofing_feedback', $galleryId, $guestId, 'updated_at') - : $this->rowsForFiles('proofing_feedback', $galleryId, $guestId, $fileIds, 'updated_at'); + ? $this->rows('proofing_feedback', $galleryId, $guestId, $actorUid, 'updated_at') + : $this->rowsForFiles('proofing_feedback', $galleryId, $guestId, $actorUid, $fileIds, 'updated_at'); $comments = $allFiles && !$delta - ? $this->rows('proofing_comments', $galleryId, $guestId, 'created_at') - : $this->commentsForDelta($galleryId, $guestId, $fileIds, $commentIds); + ? $this->rows('proofing_comments', $galleryId, $guestId, $actorUid, 'created_at') + : $this->commentsForDelta($galleryId, $guestId, $actorUid, $fileIds, $commentIds); $selections = $delta - ? $this->selectionsByPublicIds($galleryId, $guestId, $selectionIds) - : $this->selectionPage($galleryId, $guestId, null, 50); + ? $this->selectionsByPublicIds($galleryId, $guestId, $actorUid, $selectionIds) + : $this->selectionPage($galleryId, $guestId, $actorUid, null, 50); $this->decorateSelections($selections); $annotations = $this->annotations(array_map(static fn (array $row): int => (int)$row['id'], $comments)); - $names = $this->guestNames(array_values(array_unique(array_map( - static fn (array $row): int => (int)$row['guest_id'], - $comments, - )))); + $guestNames = $this->guestNames($this->guestIds($comments)); + $userNames = $this->userNames($this->actorUids($comments)); foreach ($comments as &$comment) { $commentId = (int)$comment['id']; - $comment['author'] = $names[(int)$comment['guest_id']] ?? 'Deleted guest'; + $comment['author'] = $this->authorName($comment, $guestNames, $userNames); $comment['annotations'] = $annotations[$commentId] ?? []; } unset($comment); @@ -85,23 +88,24 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor 'events' => $events, 'unchanged' => false, 'delta' => $delta, + 'reset' => $reset, ]; } /** @return list> */ public function selections(int $galleryId, ?int $guestId): array { - $selections = $this->rows('proofing_selections', $galleryId, $guestId, 'updated_at'); + $selections = $this->rows('proofing_selections', $galleryId, $guestId, null, 'updated_at'); $this->decorateSelections($selections); return $selections; } /** @return list> */ - public function selectionPage(int $galleryId, ?int $guestId, ?int $beforeId, int $limit): array { + public function selectionPage(int $galleryId, ?int $guestId, ?string $actorUid, ?int $beforeId, int $limit): array { $qb = $this->db->getQueryBuilder(); $qb->select('*')->from('proofing_selections') ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->orderBy('id', 'DESC')->setMaxResults(max(1, min(101, $limit))); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); if ($beforeId !== null) $qb->andWhere($qb->expr()->lt('id', $qb->createNamedParameter($beforeId, IQueryBuilder::PARAM_INT))); return QueryResult::rows($qb->executeQuery()); } @@ -116,12 +120,10 @@ public function selectionCount(int $galleryId): int { /** @param list> $selections */ public function decorateSelections(array &$selections): void { $items = $this->selectionItems(array_map(static fn (array $row): int => (int)$row['id'], $selections)); - $names = $this->guestNames(array_values(array_unique(array_map( - static fn (array $row): int => (int)$row['guest_id'], - $selections, - )))); + $guestNames = $this->guestNames($this->guestIds($selections)); + $userNames = $this->userNames($this->actorUids($selections)); foreach ($selections as &$selection) { - $selection['author'] = $names[(int)$selection['guest_id']] ?? 'Deleted guest'; + $selection['author'] = $this->authorName($selection, $guestNames, $userNames); $selection['fileIds'] = $items[(int)$selection['id']] ?? []; } unset($selection); @@ -130,7 +132,7 @@ public function decorateSelections(array &$selections): void { /** @param list $fileIds * @param list $commentIds * @return list> */ - private function commentsForDelta(int $galleryId, ?int $guestId, array $fileIds, array $commentIds): array { + private function commentsForDelta(int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds, array $commentIds): array { if ($fileIds === [] && $commentIds === []) return []; $qb = $this->db->getQueryBuilder(); $qb->select('*')->from('proofing_comments') @@ -139,31 +141,31 @@ private function commentsForDelta(int $galleryId, ?int $guestId, array $fileIds, $fileIds === [] ? $qb->expr()->eq('id', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) : $qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)), $commentIds === [] ? $qb->expr()->eq('id', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)) : $qb->expr()->in('id', $qb->createNamedParameter($commentIds, IQueryBuilder::PARAM_INT_ARRAY)), ))->orderBy('id', 'ASC')->setMaxResults(1000); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); return QueryResult::rows($qb->executeQuery()); } /** @param list $publicIds * @return list> */ - private function selectionsByPublicIds(int $galleryId, ?int $guestId, array $publicIds): array { + private function selectionsByPublicIds(int $galleryId, ?int $guestId, ?string $actorUid, array $publicIds): array { if ($publicIds === []) return []; $qb = $this->db->getQueryBuilder(); $qb->select('*')->from('proofing_selections') ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->in('public_id', $qb->createNamedParameter($publicIds, IQueryBuilder::PARAM_STR_ARRAY))) ->orderBy('id', 'ASC')->setMaxResults(200); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); return QueryResult::rows($qb->executeQuery()); } - public function feedbackId(int $galleryId, int $guestId, int $fileId, string $kind): ?int { + public function feedbackId(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $kind): ?int { $qb = $this->db->getQueryBuilder(); - $value = $qb->select('id')->from('proofing_feedback') + $qb->select('id')->from('proofing_feedback') ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind))) - ->executeQuery()->fetchOne(); + ->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind))); + $this->actorCondition($qb, $guestId, $actorUid); + $value = $qb->executeQuery()->fetchOne(); return $value === false ? null : (int)$value; } @@ -174,12 +176,12 @@ public function deleteFeedback(int $id): void { ->executeStatement(); } - public function insertFeedback(int $galleryId, int $guestId, int $fileId, string $kind, string $value, int $now): void { + public function insertFeedback(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $kind, string $value, int $now): void { $qb = $this->db->getQueryBuilder(); $qb->insert('proofing_feedback')->values([ 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), 'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT), - 'actor_uid' => $qb->createNamedParameter(null), + 'actor_uid' => $qb->createNamedParameter($actorUid), 'file_id' => $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), 'kind' => $qb->createNamedParameter($kind), 'value' => $qb->createNamedParameter($value), @@ -188,13 +190,13 @@ public function insertFeedback(int $galleryId, int $guestId, int $fileId, string ])->executeStatement(); } - public function hasAtLeastRows(string $table, int $galleryId, int $threshold, ?int $guestId = null): bool { + public function hasAtLeastRows(string $table, int $galleryId, int $threshold, ?int $guestId = null, ?string $actorUid = null): bool { if (!array_key_exists($table, self::ROW_LIMITS)) throw new InvalidArgumentException('Unsupported collaboration table'); if ($threshold < 1) return true; $qb = $this->db->getQueryBuilder(); $qb->select('id')->from($table) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); $qb->orderBy('id', 'ASC')->setFirstResult($threshold - 1)->setMaxResults(1); return $qb->executeQuery()->fetchOne() !== false; } @@ -209,7 +211,7 @@ public function updateFeedback(int $id, string $value, int $now): void { } /** @param array|null $annotation */ - public function insertComment(int $galleryId, int $guestId, int $fileId, string $body, ?array $annotation, int $now): int { + public function insertComment(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId, string $body, ?array $annotation, int $now, ?int $parentId = null): int { $ownsTransaction = !$this->db->inTransaction(); if ($ownsTransaction) $this->db->beginTransaction(); try { @@ -217,9 +219,9 @@ public function insertComment(int $galleryId, int $guestId, int $fileId, string $qb->insert('proofing_comments')->values([ 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), 'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT), - 'actor_uid' => $qb->createNamedParameter(null), + 'actor_uid' => $qb->createNamedParameter($actorUid), 'file_id' => $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT), - 'parent_id' => $qb->createNamedParameter(null), + 'parent_id' => $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT), 'body' => $qb->createNamedParameter($body), 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT), 'edited_at' => $qb->createNamedParameter(null), @@ -235,40 +237,57 @@ public function insertComment(int $galleryId, int $guestId, int $fileId, string } } - public function deleteComment(int $galleryId, int $guestId, int $commentId, int $now): bool { + /** @return array */ + public function threadAnnotation(int $galleryId, int $fileId, int $rootId, CollaborationReadScope $scope): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id')->from('proofing_comments') + ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('id', $qb->createNamedParameter($rootId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->isNull('parent_id')); + $this->actorCondition($qb, $scope->guestId(), $scope->actorUid()); + $root = QueryResult::row($qb->executeQuery()); + $annotation = $root === false || $scope->isEmpty() ? null : ($this->annotations([$rootId])[$rootId][0] ?? null); + if ($annotation === null) throw new InvalidArgumentException('Point conversation not found'); + return $annotation; + } + + public function deleteComment(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId, int $now): bool { $qb = $this->db->getQueryBuilder(); - return $qb->update('proofing_comments') + $qb->update('proofing_comments') ->set('deleted_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) ->set('body', $qb->createNamedParameter('')) ->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) - ->executeStatement() === 1; + ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); + return $qb->executeStatement() === 1; } - public function ownedCommentFileId(int $galleryId, int $guestId, int $commentId): ?int { + public function ownedCommentFileId(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId): ?int { $qb = $this->db->getQueryBuilder(); $value = $qb->select('file_id')->from('proofing_comments') ->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->isNull('deleted_at'))->executeQuery()->fetchOne(); + ->andWhere($qb->expr()->isNull('deleted_at')); + $this->actorCondition($qb, $guestId, $actorUid); + $value = $qb->executeQuery()->fetchOne(); return $value === false ? null : (int)$value; } - public function updateComment(int $galleryId, int $guestId, int $commentId, string $body, int $now): bool { + public function updateComment(int $galleryId, ?int $guestId, ?string $actorUid, int $commentId, string $body, int $now): bool { $qb = $this->db->getQueryBuilder(); - return $qb->update('proofing_comments') + $qb->update('proofing_comments') ->set('body', $qb->createNamedParameter($body)) ->set('edited_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) ->where($qb->expr()->eq('id', $qb->createNamedParameter($commentId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->isNull('deleted_at'))->executeStatement() === 1; + ->andWhere($qb->expr()->isNull('deleted_at')); + $this->actorCondition($qb, $guestId, $actorUid); + return $qb->executeStatement() === 1; } /** @param list $fileIds */ - public function insertSelection(int $galleryId, int $guestId, ?int $publicLinkId, string $publicId, string $name, string $message, array $fileIds, int $now): void { + public function insertSelection(int $galleryId, ?int $guestId, ?string $actorUid, ?int $publicLinkId, string $publicId, string $name, string $message, array $fileIds, int $now): void { $ownsTransaction = !$this->db->inTransaction(); if ($ownsTransaction) $this->db->beginTransaction(); try { @@ -276,8 +295,8 @@ public function insertSelection(int $galleryId, int $guestId, ?int $publicLinkId $qb->insert('proofing_selections')->values([ 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), 'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT), + 'actor_uid' => $qb->createNamedParameter($actorUid), 'public_link_id' => $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT), - 'actor_uid' => $qb->createNamedParameter(null), 'public_id' => $qb->createNamedParameter($publicId), 'name' => $qb->createNamedParameter($name), 'message' => $qb->createNamedParameter($message), @@ -302,9 +321,10 @@ public function insertSelection(int $galleryId, int $guestId, ?int $publicLinkId } /** @return array|null */ - public function latestSelectionForLink(int $galleryId, int $publicLinkId, int $guestId): ?array { + public function latestSelectionForLink(int $galleryId, int $publicLinkId, ?int $guestId, ?string $actorUid = null): ?array { + if ($guestId === null && $actorUid === null) throw new \InvalidArgumentException('Collaboration identity required'); $qb = $this->db->getQueryBuilder(); - $row = QueryResult::row($qb->select('s.*', $qb->func()->count('i.id', 'item_count')) + $qb->select('s.*', $qb->func()->count('i.id', 'item_count')) ->from('proofing_selections', 's') ->leftJoin('s', 'proofing_selection_items', 'i', $qb->expr()->eq('i.selection_id', 's.id')) ->where($qb->expr()->eq('s.gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) @@ -312,8 +332,9 @@ public function latestSelectionForLink(int $galleryId, int $publicLinkId, int $g $qb->expr()->eq('s.public_link_id', $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT)), $qb->expr()->isNull('s.public_link_id'), )) - ->andWhere($qb->expr()->eq('s.guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) - ->groupBy('s.id')->orderBy('s.updated_at', 'DESC')->addOrderBy('s.id', 'DESC')->setMaxResults(1)->executeQuery()); + ->groupBy('s.id')->orderBy('s.updated_at', 'DESC')->addOrderBy('s.id', 'DESC')->setMaxResults(1); + $this->actorCondition($qb, $guestId, $actorUid, 's.'); + $row = QueryResult::row($qb->executeQuery()); return $row === false ? null : $row; } @@ -433,22 +454,23 @@ public function deleteSelection(int $galleryId, string $publicId): bool { } /** @param array $payload */ - public function insertEvent(int $galleryId, int $guestId, string $type, array $payload, int $now): int { - return $this->insertActorEvent($galleryId, $guestId, null, $type, $payload, $now); + public function insertEvent(int $galleryId, ?int $guestId, ?string $actorUid, string $type, array $payload, int $now): int { + return $this->insertActorEvent($galleryId, $guestId, $actorUid, $type, $payload, $now); } /** @param array $payload */ - public function insertOwnerEvent(int $galleryId, int $guestId, string $actorUid, string $type, array $payload, int $now): int { - return $this->insertActorEvent($galleryId, $guestId, $actorUid, $type, $payload, $now); + public function insertOwnerEvent(int $galleryId, ?int $guestId, string $actorUid, string $type, array $payload, int $now, ?string $recipientUid = null): int { + return $this->insertActorEvent($galleryId, $guestId, $actorUid, $type, $payload, $now, $recipientUid); } /** @param array $payload */ - private function insertActorEvent(int $galleryId, int $guestId, ?string $actorUid, string $type, array $payload, int $now): int { + private function insertActorEvent(int $galleryId, ?int $guestId, ?string $actorUid, string $type, array $payload, int $now, ?string $recipientUid = null): int { $qb = $this->db->getQueryBuilder(); $qb->insert('proofing_events')->values([ 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), 'guest_id' => $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT), 'actor_uid' => $qb->createNamedParameter($actorUid), + 'recipient_uid' => $qb->createNamedParameter($recipientUid), 'event_type' => $qb->createNamedParameter($type), 'payload' => $qb->createNamedParameter(json_encode($payload, JSON_THROW_ON_ERROR)), 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT), @@ -457,26 +479,26 @@ private function insertActorEvent(int $galleryId, int $guestId, ?string $actorUi } /** @return list> */ - private function rows(string $table, int $galleryId, ?int $guestId, string $order): array { + private function rows(string $table, int $galleryId, ?int $guestId, ?string $actorUid, string $order): array { $limit = self::ROW_LIMITS[$table] ?? throw new InvalidArgumentException('Unsupported collaboration table'); $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($table) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->orderBy($order, 'DESC')->setMaxResults(min(5000, $limit)); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); return array_reverse(QueryResult::rows($qb->executeQuery())); } /** @param list $fileIds * @return list> */ - private function rowsForFiles(string $table, int $galleryId, ?int $guestId, array $fileIds, string $order): array { + private function rowsForFiles(string $table, int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds, string $order): array { if ($fileIds === []) return []; $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($table) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))) ->orderBy($order, 'ASC')->setMaxResults(5000); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + $this->actorCondition($qb, $guestId, $actorUid); return QueryResult::rows($qb->executeQuery()); } @@ -531,22 +553,84 @@ private function guestNames(array $guestIds): array { } /** @return list> */ - private function events(int $galleryId, ?int $guestId, int $cursor): array { + private function events(int $galleryId, ?int $guestId, ?string $actorUid, int $cursor): array { $qb = $this->db->getQueryBuilder(); $qb->select('*')->from('proofing_events') ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter(max(0, $cursor), IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->in('event_type', $qb->createNamedParameter(self::COLLABORATION_EVENT_TYPES, IQueryBuilder::PARAM_STR_ARRAY))) ->orderBy('id', 'ASC')->setMaxResults(200); - if ($guestId !== null) $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + if ($guestId === null && $actorUid === null) return QueryResult::rows($qb->executeQuery()); + $reset = $qb->expr()->eq('event_type', $qb->createNamedParameter('collaboration.reset')); + $actor = $guestId !== null + ? $qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)) + : $qb->expr()->orX( + $qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)), + $qb->expr()->eq('recipient_uid', $qb->createNamedParameter($actorUid)), + ); + $qb->andWhere($qb->expr()->orX($reset, $actor)); return QueryResult::rows($qb->executeQuery()); } + /** + * @param list> $rows + * @return list + */ + private function guestIds(array $rows): array { + return array_values(array_unique(array_map('intval', array_filter( + array_column($rows, 'guest_id'), + static fn (mixed $id): bool => $id !== null, + )))); + } + + /** + * @param list> $rows + * @return list + */ + private function actorUids(array $rows): array { + return array_values(array_unique(array_filter( + array_map('strval', array_column($rows, 'actor_uid')), + static fn (string $uid): bool => $uid !== '', + ))); + } + + /** + * @param list $uids + * @return array + */ + private function userNames(array $uids): array { + $result = []; + foreach ($uids as $uid) { + $result[$uid] = $this->users->get($uid)?->getDisplayName() ?? $uid; + } + return $result; + } + + /** + * @param array $row + * @param array $guestNames + * @param array $userNames + */ + private function authorName(array $row, array $guestNames, array $userNames): string { + if ($row['actor_uid'] !== null && (string)$row['actor_uid'] !== '') { + return $userNames[(string)$row['actor_uid']] ?? ''; + } + return $row['guest_id'] === null ? 'Deleted user' : ($guestNames[(int)$row['guest_id']] ?? 'Deleted guest'); + } + + private function actorCondition(IQueryBuilder $qb, ?int $guestId, ?string $actorUid, string $prefix = ''): void { + if ($guestId !== null) { + $qb->andWhere($qb->expr()->eq($prefix . 'guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + } elseif ($actorUid !== null) { + $qb->andWhere($qb->expr()->eq($prefix . 'actor_uid', $qb->createNamedParameter($actorUid))); + } + } + /** @param array $annotation */ private function insertAnnotation(int $galleryId, int $fileId, int $commentId, array $annotation): void { foreach (['x', 'y', 'width', 'height'] as $key) { - if (!isset($annotation[$key]) || !is_int($annotation[$key]) || $annotation[$key] < 0 || $annotation[$key] > 10000) { - throw new InvalidArgumentException('Annotation coordinates must be normalized integers'); + if (!isset($annotation[$key]) || !is_int($annotation[$key]) || $annotation[$key] < 0 || $annotation[$key] > self::ANNOTATION_COORDINATE_SCALE) { + throw new InvalidArgumentException('Annotation coordinates must be hundredths-of-a-percent integers between 0 and 10000'); } } $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/Guest.php b/lib/Db/Guest.php index 8dfe727..db63701 100644 --- a/lib/Db/Guest.php +++ b/lib/Db/Guest.php @@ -44,10 +44,11 @@ public function __construct() { } } - /** @return array{id: string, displayName: string, createdAt: int} */ + /** @return array{id: string, kind: 'guest', displayName: string, createdAt: int} */ public function jsonSerialize(): array { return [ 'id' => $this->getPublicId(), + 'kind' => 'guest', 'displayName' => $this->getDisplayName(), 'createdAt' => $this->getCreatedAt(), ]; diff --git a/lib/Db/GuestRating.php b/lib/Db/GuestRating.php index 4195047..ef681ff 100644 --- a/lib/Db/GuestRating.php +++ b/lib/Db/GuestRating.php @@ -12,8 +12,10 @@ * @method void setGalleryId(int $galleryId) * @method int getPublicLinkId() * @method void setPublicLinkId(int $publicLinkId) - * @method int getGuestId() - * @method void setGuestId(int $guestId) + * @method ?int getGuestId() + * @method void setGuestId(?int $guestId) + * @method ?string getActorUid() + * @method void setActorUid(?string $actorUid) * @method int getFileId() * @method void setFileId(int $fileId) * @method int getRating() @@ -26,7 +28,8 @@ final class GuestRating extends Entity implements \JsonSerializable { protected int $galleryId = 0; protected int $publicLinkId = 0; - protected int $guestId = 0; + protected ?int $guestId = null; + protected ?string $actorUid = null; protected int $fileId = 0; protected int $rating = 0; protected string $pickState = 'none'; @@ -37,6 +40,12 @@ public function __construct() { $this->addType('rating', Types::INTEGER); } + public function actorKey(): string { + return $this->getGuestId() !== null + ? 'guest:' . $this->getGuestId() + : 'user:' . ($this->getActorUid() ?? ''); + } + /** @return array{fileId: int, rating: int, pick: string, updatedAt: int} */ public function jsonSerialize(): array { return [ diff --git a/lib/Db/GuestRatingMapper.php b/lib/Db/GuestRatingMapper.php index ac9a0f5..eb786e2 100644 --- a/lib/Db/GuestRatingMapper.php +++ b/lib/Db/GuestRatingMapper.php @@ -18,21 +18,31 @@ public function __construct(IDBConnection $db) { /** @throws DoesNotExistException|MultipleObjectsReturnedException */ public function findGuestFile(int $galleryId, int $guestId, int $fileId): GuestRating { + return $this->findActorFile($galleryId, $guestId, null, $fileId); + } + + /** @throws DoesNotExistException|MultipleObjectsReturnedException */ + public function findActorFile(int $galleryId, ?int $guestId, ?string $actorUid, int $fileId): GuestRating { $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($this->tableName) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); + $this->actorWhere($qb, $guestId, $actorUid); return $this->findEntity($qb); } /** @return list */ public function findForGuest(int $galleryId, int $guestId): array { + return $this->findForActor($galleryId, $guestId, null); + } + + /** @return list */ + public function findForActor(int $galleryId, ?int $guestId, ?string $actorUid): array { $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($this->tableName) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) ->orderBy('file_id', 'ASC'); + $this->actorWhere($qb, $guestId, $actorUid); return $this->findEntities($qb); } @@ -40,14 +50,21 @@ public function findForGuest(int $galleryId, int $guestId): array { * @return list */ public function findForGuestFiles(int $galleryId, int $guestId, array $fileIds): array { + return $this->findForActorFiles($galleryId, $guestId, null, $fileIds); + } + + /** @param list $fileIds + * @return list + */ + public function findForActorFiles(int $galleryId, ?int $guestId, ?string $actorUid, array $fileIds): array { $fileIds = array_values(array_unique(array_filter(array_map('intval', $fileIds), static fn (int $id): bool => $id > 0))); if ($fileIds === []) return []; $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($this->tableName) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))) ->orderBy('file_id', 'ASC'); + $this->actorWhere($qb, $guestId, $actorUid); return $this->findEntities($qb); } @@ -56,7 +73,7 @@ public function findForGallery(int $galleryId): array { $qb = $this->db->getQueryBuilder(); $qb->select('*')->from($this->tableName) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) - ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC'); + ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC')->addOrderBy('actor_uid', 'ASC'); return $this->findEntities($qb); } @@ -90,9 +107,20 @@ public function findForGalleryFiles(int $galleryId, array $fileIds): array { $qb->select('*')->from($this->tableName) ->where($qb->expr()->eq('gallery_id', $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->in('file_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) - ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC'); + ->orderBy('file_id', 'ASC')->addOrderBy('guest_id', 'ASC')->addOrderBy('actor_uid', 'ASC'); array_push($result, ...$this->findEntities($qb)); } return $result; } + + private function actorWhere(IQueryBuilder $qb, ?int $guestId, ?string $actorUid): void { + if (($guestId === null) === ($actorUid === null)) { + throw new \InvalidArgumentException('Rating actor must be either a guest or a user'); + } + if ($guestId !== null) { + $qb->andWhere($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT))); + return; + } + $qb->andWhere($qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid))); + } } diff --git a/lib/Db/PurgeRepository.php b/lib/Db/PurgeRepository.php index dc823cb..5c57006 100644 --- a/lib/Db/PurgeRepository.php +++ b/lib/Db/PurgeRepository.php @@ -165,24 +165,47 @@ public function ownedGalleryIds(string $userId): array { } public function deletePrincipal(string $type, string $id): int { - $qb = $this->db->getQueryBuilder(); - $deleted = $qb->delete('proofing_managers') - ->where($qb->expr()->eq('principal_type', $qb->createNamedParameter($type))) - ->andWhere($qb->expr()->eq('user_uid', $qb->createNamedParameter($id)))->executeStatement(); - if ($type !== 'user') return $deleted; - foreach ([ - ['proofing_presets', 'owner_uid'], - ['proofing_inv_templates', 'owner_uid'], - ['proofing_media_cull', 'owner_uid'], - ['proofing_agent_requests', 'user_uid'], - ['proofing_notify_subs', 'user_uid'], - ['proofing_native_notify', 'user_uid'], - ['proofing_ext_resources', 'user_uid'], - ] as [$table, $column]) { + $this->db->beginTransaction(); + try { + $resetGalleryIds = $type === 'user' ? $this->actorGalleryIds($id) : []; $qb = $this->db->getQueryBuilder(); - $deleted += $qb->delete($table)->where($qb->expr()->eq($column, $qb->createNamedParameter($id)))->executeStatement(); + $deleted = $qb->delete('proofing_managers') + ->where($qb->expr()->eq('principal_type', $qb->createNamedParameter($type))) + ->andWhere($qb->expr()->eq('user_uid', $qb->createNamedParameter($id)))->executeStatement(); + if ($type === 'user') { + $comments = $this->preserveThreadRoots($this->actorParentIds('proofing_comments', $id)); + $selections = $this->actorParentIds('proofing_selections', $id); + $deleted += $this->deleteIds('proofing_annotations', 'comment_id', $comments) + + $this->deleteIds('proofing_selection_items', 'selection_id', $selections); + foreach (['proofing_feedback', 'proofing_comments', 'proofing_selections', 'proofing_guest_ratings', 'proofing_events', 'proofing_share_audit'] as $table) { + $qb = $this->db->getQueryBuilder(); + $deleted += $qb->delete($table)->where($qb->expr()->eq('actor_uid', $qb->createNamedParameter($id)))->executeStatement(); + } + $qb = $this->db->getQueryBuilder(); + $deleted += $qb->delete('proofing_events')->where($qb->expr()->eq('recipient_uid', $qb->createNamedParameter($id)))->executeStatement(); + $qb = $this->db->getQueryBuilder(); + $qb->update('proofing_review_rounds')->set('submitted_by_actor_uid', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('submitted_by_actor_uid', $qb->createNamedParameter($id)))->executeStatement(); + foreach ([ + ['proofing_presets', 'owner_uid'], + ['proofing_inv_templates', 'owner_uid'], + ['proofing_media_cull', 'owner_uid'], + ['proofing_agent_requests', 'user_uid'], + ['proofing_notify_subs', 'user_uid'], + ['proofing_native_notify', 'user_uid'], + ['proofing_ext_resources', 'user_uid'], + ] as [$table, $column]) { + $qb = $this->db->getQueryBuilder(); + $deleted += $qb->delete($table)->where($qb->expr()->eq($column, $qb->createNamedParameter($id)))->executeStatement(); + } + } + $this->insertPrivacyResetEvents($resetGalleryIds, time()); + $this->db->commit(); + return $deleted; + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; } - return $deleted; } /** @return array>> */ @@ -204,10 +227,10 @@ public function guestUploadIds(int $guestId): array { ->where($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))->executeQuery())); } - public function deleteGuestData(int $guestId): int { + public function deleteGuestData(int $guestId, int $galleryId): int { $this->db->beginTransaction(); try { - $comments = $this->guestParentIds('proofing_comments', $guestId); + $comments = $this->preserveThreadRoots($this->guestParentIds('proofing_comments', $guestId)); $selections = $this->guestParentIds('proofing_selections', $guestId); $deleted = $this->deleteIds('proofing_annotations', 'comment_id', $comments) + $this->deleteIds('proofing_selection_items', 'selection_id', $selections); @@ -217,6 +240,7 @@ public function deleteGuestData(int $guestId): int { } $qb = $this->db->getQueryBuilder(); $deleted += $qb->delete('proofing_guests')->where($qb->expr()->eq('id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))->executeStatement(); + $this->insertPrivacyResetEvents([$galleryId], time()); $this->db->commit(); return $deleted; } catch (\Throwable $exception) { @@ -225,6 +249,31 @@ public function deleteGuestData(int $guestId): int { } } + /** Keep anonymous roots for surviving replies; return comments safe to remove. + * @param list $commentIds + * @return list + */ + private function preserveThreadRoots(array $commentIds): array { + $keep = []; + foreach (array_chunk($commentIds, 500) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $replies = QueryResult::rows($qb->select('id', 'parent_id')->from('proofing_comments') + ->where($qb->expr()->in('parent_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))->executeQuery()); + foreach ($replies as $reply) { + if (!in_array((int)$reply['id'], $commentIds, true)) $keep[(int)$reply['parent_id']] = true; + } + } + foreach (array_chunk(array_keys($keep), 500) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $qb->update('proofing_comments')->set('actor_uid', $qb->createNamedParameter(null)) + ->set('guest_id', $qb->createNamedParameter(null, IQueryBuilder::PARAM_INT)) + ->set('body', $qb->createNamedParameter('')) + ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->where($qb->expr()->in('id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY)))->executeStatement(); + } + return array_values(array_diff($commentIds, array_keys($keep))); + } + /** @return list */ private function guestParentIds(string $table, int $guestId): array { $qb = $this->db->getQueryBuilder(); @@ -232,6 +281,49 @@ private function guestParentIds(string $table, int $guestId): array { ->where($qb->expr()->eq('guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)))->executeQuery())); } + /** @return list */ + private function actorParentIds(string $table, string $actorUid): array { + $qb = $this->db->getQueryBuilder(); + return array_map('intval', QueryResult::column($qb->select('id')->from($table) + ->where($qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)))->executeQuery())); + } + + /** @return list */ + private function actorGalleryIds(string $actorUid): array { + $galleryIds = []; + foreach ([ + ['proofing_feedback', 'actor_uid'], + ['proofing_comments', 'actor_uid'], + ['proofing_selections', 'actor_uid'], + ['proofing_guest_ratings', 'actor_uid'], + ['proofing_events', 'actor_uid'], + ['proofing_events', 'recipient_uid'], + ['proofing_share_audit', 'actor_uid'], + ['proofing_review_rounds', 'submitted_by_actor_uid'], + ] as [$table, $column]) { + $qb = $this->db->getQueryBuilder(); + $galleryIds = array_merge($galleryIds, QueryResult::column($qb->selectDistinct('gallery_id')->from($table) + ->where($qb->expr()->eq($column, $qb->createNamedParameter($actorUid)))->executeQuery())); + } + return array_values(array_unique(array_map('intval', $galleryIds))); + } + + /** @param list $galleryIds */ + private function insertPrivacyResetEvents(array $galleryIds, int $now): void { + foreach (array_values(array_unique(array_map('intval', $galleryIds))) as $galleryId) { + $qb = $this->db->getQueryBuilder(); + $qb->insert('proofing_events')->values([ + 'gallery_id' => $qb->createNamedParameter($galleryId, IQueryBuilder::PARAM_INT), + 'guest_id' => $qb->createNamedParameter(null, IQueryBuilder::PARAM_INT), + 'actor_uid' => $qb->createNamedParameter(null), + 'recipient_uid' => $qb->createNamedParameter(null), + 'event_type' => $qb->createNamedParameter('collaboration.reset'), + 'payload' => $qb->createNamedParameter(json_encode(['reason' => 'privacy_erasure'], JSON_THROW_ON_ERROR)), + 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT), + ])->executeStatement(); + } + } + /** @param list $ids */ private function deleteIds(string $table, string $column, array $ids): int { if ($ids === []) return 0; diff --git a/lib/Db/ReviewRoundRepository.php b/lib/Db/ReviewRoundRepository.php index b013d55..7fbdf97 100644 --- a/lib/Db/ReviewRoundRepository.php +++ b/lib/Db/ReviewRoundRepository.php @@ -6,30 +6,31 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\IUserManager; final class ReviewRoundRepository { - public function __construct(private IDBConnection $db) { + public function __construct(private IDBConnection $db, private IUserManager $users) { } /** @return array|null */ public function current(int $publicLinkId): ?array { $qb = $this->db->getQueryBuilder(); - $row = QueryResult::row($qb->select('r.*', 'g.display_name AS submitted_by') + $row = QueryResult::row($qb->select('r.*', 'g.display_name AS submitted_by_guest') ->from('proofing_review_rounds', 'r') ->leftJoin('r', 'proofing_guests', 'g', $qb->expr()->eq('g.id', 'r.submitted_by_guest_id')) ->where($qb->expr()->eq('r.public_link_id', $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT))) ->orderBy('r.round_number', 'DESC')->setMaxResults(1)->executeQuery()); - return $row === false ? null : $row; + return $row === false ? null : $this->withSubmitter($row); } /** @return list> */ public function history(int $publicLinkId): array { $qb = $this->db->getQueryBuilder(); - return QueryResult::rows($qb->select('r.*', 'g.display_name AS submitted_by') + return array_map($this->withSubmitter(...), QueryResult::rows($qb->select('r.*', 'g.display_name AS submitted_by_guest') ->from('proofing_review_rounds', 'r') ->leftJoin('r', 'proofing_guests', 'g', $qb->expr()->eq('g.id', 'r.submitted_by_guest_id')) ->where($qb->expr()->eq('r.public_link_id', $qb->createNamedParameter($publicLinkId, IQueryBuilder::PARAM_INT))) - ->orderBy('r.round_number', 'DESC')->executeQuery()); + ->orderBy('r.round_number', 'DESC')->executeQuery())); } public function create(int $galleryId, int $publicLinkId, int $number, ?string $dueDate, int $now): void { @@ -41,6 +42,7 @@ public function create(int $galleryId, int $publicLinkId, int $number, ?string $ 'status' => $qb->createNamedParameter('awaiting_feedback'), 'due_date' => $qb->createNamedParameter($dueDate), 'submitted_by_guest_id' => $qb->createNamedParameter(null), + 'submitted_by_actor_uid' => $qb->createNamedParameter(null), 'submitted_at' => $qb->createNamedParameter(null), 'decided_at' => $qb->createNamedParameter(null), 'created_at' => $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT), @@ -57,11 +59,13 @@ public function updateDueDate(int $id, ?string $dueDate, int $now): void { ->executeStatement(); } - public function submit(int $id, int $guestId, int $now): bool { + public function submit(int $id, ?int $guestId, ?string $actorUid, int $now): bool { + if (($guestId === null) === ($actorUid === null)) throw new \InvalidArgumentException('Review actor must be either a guest or a user'); $qb = $this->db->getQueryBuilder(); return $qb->update('proofing_review_rounds') ->set('status', $qb->createNamedParameter('submitted')) ->set('submitted_by_guest_id', $qb->createNamedParameter($guestId, IQueryBuilder::PARAM_INT)) + ->set('submitted_by_actor_uid', $qb->createNamedParameter($actorUid)) ->set('submitted_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) @@ -69,6 +73,19 @@ public function submit(int $id, int $guestId, int $now): bool { ->executeStatement() === 1; } + /** @param array $row + * @return array + */ + private function withSubmitter(array $row): array { + $guestName = $row['submitted_by_guest'] ?? null; + $uid = $row['submitted_by_actor_uid'] ?? null; + $row['submitted_by'] = is_string($guestName) && $guestName !== '' + ? $guestName + : (is_string($uid) && $uid !== '' ? $this->users->get($uid)?->getDisplayName() : null); + unset($row['submitted_by_guest']); + return $row; + } + public function decide(int $id, string $from, string $to, int $now): bool { $qb = $this->db->getQueryBuilder(); return $qb->update('proofing_review_rounds') @@ -85,6 +102,7 @@ public function reopen(int $id, string $from, int $now): bool { return $qb->update('proofing_review_rounds') ->set('status', $qb->createNamedParameter('awaiting_feedback')) ->set('submitted_by_guest_id', $qb->createNamedParameter(null)) + ->set('submitted_by_actor_uid', $qb->createNamedParameter(null)) ->set('submitted_at', $qb->createNamedParameter(null)) ->set('decided_at', $qb->createNamedParameter(null)) ->set('updated_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT)) diff --git a/lib/Domain/CollaborationActor.php b/lib/Domain/CollaborationActor.php new file mode 100644 index 0000000..f8aa209 --- /dev/null +++ b/lib/Domain/CollaborationActor.php @@ -0,0 +1,70 @@ +getId(), + null, + $guest->getPublicId(), + $guest->getDisplayName(), + $guest->getCreatedAt(), + ); + } + + public static function user(IUser $user): self { + return new self(null, $user->getUID(), $user->getUID(), $user->getDisplayName(), 0); + } + + public function guestId(): ?int { + return $this->guestId; + } + + public function userUid(): ?string { + return $this->userUid; + } + + public function isGuest(): bool { + return $this->guestId !== null; + } + + /** @param array $row */ + public function owns(array $row): bool { + return $this->guestId !== null + ? $row['guest_id'] !== null && (int)$row['guest_id'] === $this->guestId + : $row['actor_uid'] !== null && hash_equals($this->userUid ?? '', (string)$row['actor_uid']); + } + + /** @return array{id: string, kind: 'guest'|'user', displayName: string, createdAt: int} */ + public function jsonSerialize(): array { + return [ + 'id' => $this->publicId, + 'kind' => $this->isGuest() ? 'guest' : 'user', + 'displayName' => $this->displayName, + 'createdAt' => $this->createdAt, + ]; + } +} diff --git a/lib/Domain/CollaborationReadScope.php b/lib/Domain/CollaborationReadScope.php index 7c2efd4..c538719 100644 --- a/lib/Domain/CollaborationReadScope.php +++ b/lib/Domain/CollaborationReadScope.php @@ -10,10 +10,14 @@ final class CollaborationReadScope { private function __construct( private string $mode, private ?int $guestId = null, + private ?string $actorUid = null, ) { if ($mode === 'guest' && ($guestId ?? 0) < 1) { throw new InvalidArgumentException('A guest collaboration scope requires a guest ID'); } + if ($mode === 'user' && ($actorUid === null || $actorUid === '')) { + throw new InvalidArgumentException('A user collaboration scope requires a user UID'); + } } public static function all(): self { @@ -28,6 +32,10 @@ public static function guest(int $guestId): self { return new self('guest', $guestId); } + public static function user(string $actorUid): self { + return new self('user', null, $actorUid); + } + public function isEmpty(): bool { return $this->mode === 'none'; } @@ -35,4 +43,8 @@ public function isEmpty(): bool { public function guestId(): ?int { return $this->mode === 'guest' ? $this->guestId : null; } + + public function actorUid(): ?string { + return $this->mode === 'user' ? $this->actorUid : null; + } } diff --git a/lib/Dto/Settings/ReviewSettings.php b/lib/Dto/Settings/ReviewSettings.php index 476678d..77f7de6 100644 --- a/lib/Dto/Settings/ReviewSettings.php +++ b/lib/Dto/Settings/ReviewSettings.php @@ -36,7 +36,7 @@ public static function defaults(): array { return [ 'visibility' => 'collaborative', 'likes' => true, 'colors' => true, 'comments' => true, 'annotations' => true, 'selections' => true, 'ratings' => false, 'pick' => false, - 'colorLabels' => ['Favorit', 'Auswahl', 'Überarbeiten', 'Ablehnen'], + 'colorLabels' => ['Favorite', 'Selected', 'Needs changes', 'Rejected'], 'colorEnabled' => [true, true, true, true], 'selectionWarningThreshold' => 0, 'selectionMinimum' => 0, 'selectionMaximum' => 0, 'selectionDueDate' => null, ]; diff --git a/lib/Migration/Version000130Date20260903.php b/lib/Migration/Version000130Date20260903.php new file mode 100644 index 0000000..1175540 --- /dev/null +++ b/lib/Migration/Version000130Date20260903.php @@ -0,0 +1,93 @@ + $options */ + public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // Older builds did not enforce account feedback uniqueness. Keep the newest + // value if concurrent requests created duplicate account rows. + $qb = $this->db->getQueryBuilder(); + $duplicates = QueryResult::rows($qb->select('gallery_id', 'file_id', 'kind', 'actor_uid') + ->selectAlias($qb->func()->max('id'), 'keep_id') + ->from('proofing_feedback') + ->where($qb->expr()->isNotNull('actor_uid')) + ->groupBy('gallery_id', 'file_id', 'kind', 'actor_uid') + ->having($qb->expr()->gt($qb->func()->count('*'), $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))) + ->executeQuery()); + foreach ($duplicates as $row) { + $delete = $this->db->getQueryBuilder(); + $delete->delete('proofing_feedback') + ->where($delete->expr()->eq('gallery_id', $delete->createNamedParameter((int)$row['gallery_id'], IQueryBuilder::PARAM_INT))) + ->andWhere($delete->expr()->eq('file_id', $delete->createNamedParameter((int)$row['file_id'], IQueryBuilder::PARAM_INT))) + ->andWhere($delete->expr()->eq('kind', $delete->createNamedParameter((string)$row['kind']))) + ->andWhere($delete->expr()->eq('actor_uid', $delete->createNamedParameter((string)$row['actor_uid']))) + ->andWhere($delete->expr()->neq('id', $delete->createNamedParameter((int)$row['keep_id'], IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + } + + /** @param array $options */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + $changed = false; + + $feedback = $schema->getTable('proofing_feedback'); + if (!$feedback->hasIndex('proof_feedback_actor_kind')) { + $feedback->addUniqueIndex(['gallery_id', 'file_id', 'kind', 'actor_uid'], 'proof_feedback_actor_kind'); + $changed = true; + } + foreach ([ + 'proofing_comments' => ['proof_comment_actor', ['gallery_id', 'actor_uid', 'created_at']], + 'proofing_selections' => ['proof_selection_actor', ['gallery_id', 'actor_uid', 'updated_at']], + 'proofing_events' => ['proof_event_actor', ['gallery_id', 'actor_uid', 'created_at']], + ] as $tableName => [$indexName, $columns]) { + $table = $schema->getTable($tableName); + if (!$table->hasIndex($indexName)) { + $table->addIndex($columns, $indexName); + $changed = true; + } + } + + $rounds = $schema->getTable('proofing_review_rounds'); + if (!$rounds->hasColumn('submitted_by_actor_uid')) { + $rounds->addColumn('submitted_by_actor_uid', Types::STRING, ['length' => 64, 'notnull' => false]); + $changed = true; + } + if (!$rounds->hasIndex('proof_review_actor')) { + $rounds->addIndex(['submitted_by_actor_uid'], 'proof_review_actor'); + $changed = true; + } + + $ratings = $schema->getTable('proofing_guest_ratings'); + if (!$ratings->hasColumn('actor_uid')) { + $ratings->addColumn('actor_uid', Types::STRING, ['length' => 64, 'notnull' => false]); + $changed = true; + } + if ($ratings->getColumn('guest_id')->getNotnull()) { + $ratings->changeColumn('guest_id', ['notnull' => false]); + $changed = true; + } + if (!$ratings->hasIndex('proof_actor_rating_file')) { + $ratings->addUniqueIndex(['gallery_id', 'actor_uid', 'file_id'], 'proof_actor_rating_file'); + $changed = true; + } + + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version000140Date20260908.php b/lib/Migration/Version000140Date20260908.php new file mode 100644 index 0000000..99fa269 --- /dev/null +++ b/lib/Migration/Version000140Date20260908.php @@ -0,0 +1,27 @@ + $options */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper { + $schema = $schemaClosure(); + $events = $schema->getTable('proofing_events'); + if (!$events->hasColumn('recipient_uid')) { + $events->addColumn('recipient_uid', Types::STRING, ['length' => 64, 'notnull' => false]); + } + if (!$events->hasIndex('proof_event_recipient')) { + $events->addIndex(['gallery_id', 'recipient_uid', 'id'], 'proof_event_recipient'); + } + return $schema; + } +} diff --git a/lib/Service/ActivityService.php b/lib/Service/ActivityService.php index 752e636..1e90391 100644 --- a/lib/Service/ActivityService.php +++ b/lib/Service/ActivityService.php @@ -7,16 +7,26 @@ use OCA\ProofingGallery\Db\ActivityRepository; use OCA\ProofingGallery\Db\Gallery; use OCA\ProofingGallery\Db\Guest; +use OCA\ProofingGallery\Domain\CollaborationActor; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IUserManager; final class ActivityService { public function __construct( private ActivityRepository $repository, private ITimeFactory $clock, private NotificationService $notifications, + private IUserManager $users, ) { } + /** @param array $payload */ + public function recordActor(Gallery $gallery, CollaborationActor $actor, string $type, array $payload): void { + $now = $this->clock->getTime(); + $eventId = $this->repository->insert($gallery->getId(), $actor->guestId(), $type, $payload, $now, $actor->userUid()); + $this->notifications->queue($gallery, $eventId, $type, $now); + } + /** @param array $payload */ public function record(Gallery $gallery, ?Guest $guest, string $type, array $payload): void { $now = $this->clock->getTime(); @@ -33,10 +43,10 @@ public function recordOnce(Gallery $gallery, string $type, string $dedupeKey, ar /** @return list> */ public function list(Gallery $gallery, int $cursor = 0, string $type = ''): array { - return array_map(static fn (array $row): array => [ + return array_map(fn (array $row): array => [ 'id' => (int)$row['id'], 'type' => $row['event_type'], - 'actor' => $row['display_name'] ?? $row['actor_uid'] ?? 'Gallery manager', + 'actor' => $this->actorName($row), 'payload' => json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR), 'createdAt' => (int)$row['created_at'], ], $this->repository->list($gallery->getId(), $cursor, $type)); @@ -49,11 +59,20 @@ public function page(Gallery $gallery, int $limit, ?string $cursor, string $type $rows = $this->repository->page($gallery->getId(), $cursors->decode($cursor, $scope), $type, $limit + 1); $hasMore = count($rows) > $limit; if ($hasMore) array_pop($rows); - $items = array_map(static fn (array $row): array => [ - 'id' => (int)$row['id'], 'type' => $row['event_type'], 'actor' => $row['display_name'] ?? $row['actor_uid'] ?? 'Gallery manager', + $items = array_map(fn (array $row): array => [ + 'id' => (int)$row['id'], 'type' => $row['event_type'], 'actor' => $this->actorName($row), 'payload' => json_decode($row['payload'], true, flags: JSON_THROW_ON_ERROR), 'createdAt' => (int)$row['created_at'], ], $rows); $last = $rows === [] ? null : $rows[array_key_last($rows)]; return ['items' => $items, 'total' => $this->repository->countGallery($gallery->getId(), $type), 'nextCursor' => $hasMore && $last !== null ? $cursors->encode($scope, (int)$last['id']) : null]; } + + /** @param array $row */ + private function actorName(array $row): string { + if (isset($row['display_name']) && is_string($row['display_name']) && $row['display_name'] !== '') return $row['display_name']; + if (isset($row['actor_uid']) && is_string($row['actor_uid']) && $row['actor_uid'] !== '') { + return $this->users->get($row['actor_uid'])?->getDisplayName() ?? 'Former user'; + } + return 'Gallery manager'; + } } diff --git a/lib/Service/AuthenticatedCollaborationSession.php b/lib/Service/AuthenticatedCollaborationSession.php new file mode 100644 index 0000000..babaa09 --- /dev/null +++ b/lib/Service/AuthenticatedCollaborationSession.php @@ -0,0 +1,53 @@ +users->getUser(); + return $user === null ? null : CollaborationActor::user($user); + } + + /** @return array{actor: CollaborationActor, nonce: string}|null */ + public function current(Gallery $gallery): ?array { + $actor = $this->actor(); + if ($actor === null) return null; + $key = $this->key($gallery, $actor->userUid() ?? ''); + $nonce = $this->session->get($key); + if (!is_string($nonce) || strlen($nonce) < 32) { + $nonce = $this->random->generate(64, ISecureRandom::CHAR_ALPHANUMERIC); + $this->session->set($key, $nonce); + } + return ['actor' => $actor, 'nonce' => $nonce]; + } + + public function authenticate(Gallery $gallery, ?string $nonce): ?CollaborationActor { + $current = $this->current($gallery); + if ($current === null) return null; + if ($nonce === null || $nonce === '' || !hash_equals($current['nonce'], $nonce)) { + throw new InvalidArgumentException('Invalid request nonce'); + } + return $current['actor']; + } + + private function key(Gallery $gallery, string $uid): string { + return 'proofing_gallery_actor_nonce:' . $gallery->getId() . ':' . hash('sha256', $uid); + } +} diff --git a/lib/Service/CollaborationService.php b/lib/Service/CollaborationService.php index 370e600..c7a4e0b 100644 --- a/lib/Service/CollaborationService.php +++ b/lib/Service/CollaborationService.php @@ -11,6 +11,7 @@ use OCA\ProofingGallery\Db\GuestRating; use OCA\ProofingGallery\Db\PublicLink; use OCA\ProofingGallery\Domain\CollaborationReadScope; +use OCA\ProofingGallery\Domain\CollaborationActor; use OCA\ProofingGallery\Domain\FeedbackVisibility; use OCA\ProofingGallery\Domain\GalleryMode; use OCA\ProofingGallery\Dto\GallerySettings; @@ -41,12 +42,12 @@ public function __construct( /** @param list $visibleFileIds * @return array */ - public function publicState(Gallery $gallery, ?Guest $guest, int $cursor, array $visibleFileIds = []): array { + public function publicState(Gallery $gallery, ?CollaborationActor $actor, int $cursor, array $visibleFileIds = []): array { $settings = $this->settings($gallery); $scope = $settings->review->visibility === FeedbackVisibility::Collaborative ? CollaborationReadScope::all() - : ($guest === null ? CollaborationReadScope::none() : CollaborationReadScope::guest($guest->getId())); - return $this->state($gallery, $guest, $scope, $cursor, $visibleFileIds, false); + : ($actor === null ? CollaborationReadScope::none() : $this->actorScope($actor)); + return $this->state($gallery, $actor, $scope, $cursor, $visibleFileIds, false); } /** @param list $visibleFileIds @@ -57,7 +58,7 @@ public function ownerState(Gallery $gallery, array $visibleFileIds = []): array /** @param list $visibleFileIds * @return array */ - private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $scope, int $cursor, array $visibleFileIds, bool $allFiles): array { + private function state(Gallery $gallery, ?CollaborationActor $actor, CollaborationReadScope $scope, int $cursor, array $visibleFileIds, bool $allFiles): array { $settings = $this->settings($gallery); $visibleFileIds = array_values(array_unique(array_filter(array_map('intval', $visibleFileIds), static fn (int $id): bool => $id > 0))); if (count($visibleFileIds) > 200) throw new InvalidArgumentException('Too many visible media IDs'); @@ -97,11 +98,11 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $ if ($row['kind'] === 'like') { $likes[$fileId] ??= ['count' => 0, 'mine' => false]; $likes[$fileId]['count']++; - if ($guest !== null && (int)$row['guest_id'] === $guest->getId()) { + if ($actor !== null && $actor->owns($row)) { $likes[$fileId]['mine'] = true; } } - if ($row['kind'] === 'color' && $guest !== null && (int)$row['guest_id'] === $guest->getId()) { + if ($row['kind'] === 'color' && $actor !== null && $actor->owns($row)) { $colors[$fileId] = $row['value']; } if ($row['kind'] === 'color') { @@ -115,7 +116,7 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $ 'enabled' => $settings->mode === GalleryMode::Collaboration, 'visibility' => $settings->review->visibility->value, 'colorLabels' => $settings->review->colorLabels, - 'requiresSession' => $guest === null, + 'requiresSession' => $actor === null, 'features' => [ 'likes' => $settings->review->likes && $this->capabilities->feature('likes'), 'colors' => $settings->review->colors && $this->capabilities->feature('colors'), @@ -124,15 +125,16 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $ 'selections' => $settings->review->selections && $this->capabilities->feature('selections'), ], ], - 'guest' => $guest, + 'guest' => $actor, 'likes' => $likes, 'colors' => $colors, 'colorStates' => $colorStates, - 'comments' => $this->presentComments($comments, $guest), - 'selections' => $this->presentSelections($selections, $guest), + 'comments' => $this->presentComments($comments, $actor), + 'selections' => $this->presentSelections($selections, $actor), 'events' => $events, 'cursor' => $nextCursor, 'delta' => (bool)($state['delta'] ?? false), + 'reset' => (bool)($state['reset'] ?? false), ]; } @@ -140,7 +142,7 @@ private function state(Gallery $gallery, ?Guest $guest, CollaborationReadScope $ public function ownerSelectionPage(Gallery $gallery, int $limit, ?string $cursor, ScopedCursorCodec $cursors): array { $limit = max(1, min(100, $limit)); $scope = 'owner-selections:' . $gallery->getId(); - $rows = $this->repository->selectionPage($gallery->getId(), null, $cursors->decode($cursor, $scope), $limit + 1); + $rows = $this->repository->selectionPage($gallery->getId(), null, null, $cursors->decode($cursor, $scope), $limit + 1); $hasMore = count($rows) > $limit; if ($hasMore) array_pop($rows); $this->repository->decorateSelections($rows); @@ -149,37 +151,38 @@ public function ownerSelectionPage(Gallery $gallery, int $limit, ?string $cursor return ['items' => $items, 'total' => $this->repository->selectionCount($gallery->getId()), 'nextCursor' => $hasMore && $last !== null ? $cursors->encode($scope, (int)$last['id']) : null]; } - public function toggleLike(Gallery $gallery, Guest $guest, int $fileId): bool { - return $this->atomic(function () use ($gallery, $guest, $fileId): bool { + public function toggleLike(Gallery $gallery, CollaborationActor $actor, int $fileId): bool { + return $this->atomic(function () use ($gallery, $actor, $fileId): bool { $this->capabilities->assertFeature('likes'); $settings = $this->assertCollaboration($gallery, $fileId); if (!$settings->review->likes) { throw new InvalidArgumentException('Likes are disabled'); } - $existing = $this->repository->feedbackId($gallery->getId(), $guest->getId(), $fileId, 'like'); + $existing = $this->repository->feedbackId($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'like'); if ($existing !== null) { $this->repository->deleteFeedback($existing); $liked = false; } else { - $this->assertQuota('proofing_feedback', $gallery->getId(), $guest->getId(), self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY); - $this->repository->insertFeedback($gallery->getId(), $guest->getId(), $fileId, 'like', '1', $this->clock->getTime()); + $this->assertQuota('proofing_feedback', $gallery->getId(), $actor, self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY); + $this->repository->insertFeedback($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'like', '1', $this->clock->getTime()); $liked = true; } - $this->event($gallery, $guest, 'like.changed', ['fileId' => $fileId, 'liked' => $liked]); + $this->event($gallery, $actor, 'like.changed', ['fileId' => $fileId, 'liked' => $liked]); return $liked; }); } - public function saveRating(PublicLink $link, Gallery $gallery, Guest $guest, int $fileId, int $rating, string $pick): GuestRating { - return $this->atomic(function () use ($link, $gallery, $guest, $fileId, $rating, $pick): GuestRating { - $value = $this->guestRatings->save($link, $guest, $fileId, $rating, $pick); - $this->event($gallery, $guest, 'rating.changed', ['fileId' => $fileId]); + public function saveRating(PublicLink $link, Gallery $gallery, CollaborationActor $actor, int $fileId, int $rating, string $pick): GuestRating { + if ($link->getGalleryId() !== $gallery->getId()) throw new InvalidArgumentException('Public link does not belong to this gallery'); + return $this->atomic(function () use ($link, $gallery, $actor, $fileId, $rating, $pick): GuestRating { + $value = $this->guestRatings->saveForActor($link, $actor, $fileId, $rating, $pick); + $this->event($gallery, $actor, 'rating.changed', ['fileId' => $fileId]); return $value; }); } - public function setColor(Gallery $gallery, Guest $guest, int $fileId, ?string $value): void { - $this->atomic(function () use ($gallery, $guest, $fileId, $value): void { + public function setColor(Gallery $gallery, CollaborationActor $actor, int $fileId, ?string $value): void { + $this->atomic(function () use ($gallery, $actor, $fileId, $value): void { $this->capabilities->assertFeature('colors'); $settings = $this->assertCollaboration($gallery, $fileId); if (!$settings->review->colors) { @@ -193,28 +196,29 @@ public function setColor(Gallery $gallery, Guest $guest, int $fileId, ?string $v if ($value !== null && !in_array($value, $enabledLabels, true)) { throw new InvalidArgumentException('Unknown color workflow state'); } - $id = $this->repository->feedbackId($gallery->getId(), $guest->getId(), $fileId, 'color'); + $id = $this->repository->feedbackId($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'color'); if ($value === null && $id !== null) { $this->repository->deleteFeedback($id); } elseif ($value !== null && $id === null) { - $this->assertQuota('proofing_feedback', $gallery->getId(), $guest->getId(), self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY); - $this->repository->insertFeedback($gallery->getId(), $guest->getId(), $fileId, 'color', $value, $this->clock->getTime()); + $this->assertQuota('proofing_feedback', $gallery->getId(), $actor, self::MAX_FEEDBACK_PER_GALLERY, self::MAX_FEEDBACK_PER_GALLERY); + $this->repository->insertFeedback($gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, 'color', $value, $this->clock->getTime()); } elseif ($value !== null) { $this->repository->updateFeedback($id, $value, $this->clock->getTime()); } - $this->event($gallery, $guest, 'color.changed', ['fileId' => $fileId, 'value' => $value]); + $this->event($gallery, $actor, 'color.changed', ['fileId' => $fileId, 'value' => $value]); }); } /** @param array|null $annotation */ - public function addComment(Gallery $gallery, Guest $guest, int $fileId, string $body, ?array $annotation): int { - return $this->atomic(function () use ($gallery, $guest, $fileId, $body, $annotation): int { - $this->capabilities->assertFeature('comments'); - if ($annotation !== null) $this->capabilities->assertFeature('annotations'); - $settings = $this->assertCollaboration($gallery, $fileId); - if (!$settings->review->comments) { - throw new InvalidArgumentException('Comments are disabled'); + public function addComment(Gallery $gallery, CollaborationActor $actor, int $fileId, string $body, ?array $annotation, ?int $parentId = null): int { + return $this->atomic(function () use ($gallery, $actor, $fileId, $body, $annotation, $parentId): int { + $settings = $this->assertCommentingEnabled($gallery); + if ($parentId !== null) { + $scope = $settings->review->visibility === FeedbackVisibility::Private ? $this->actorScope($actor) : CollaborationReadScope::all(); + $annotation = $this->repository->threadAnnotation($gallery->getId(), $fileId, $parentId, $scope); } + if ($annotation !== null) $this->capabilities->assertFeature('annotations'); + $this->resolveMedia($gallery, $fileId); if ($annotation !== null && !$settings->review->annotations) { throw new InvalidArgumentException('Image annotations are disabled'); } @@ -225,50 +229,50 @@ public function addComment(Gallery $gallery, Guest $guest, int $fileId, string $ if ($body === '' || mb_strlen($body) > 5000) { throw new InvalidArgumentException('Comment must contain between 1 and 5000 characters'); } - $this->assertQuota('proofing_comments', $gallery->getId(), $guest->getId(), self::MAX_COMMENTS_PER_GALLERY, self::MAX_COMMENTS_PER_GUEST); + $this->assertQuota('proofing_comments', $gallery->getId(), $actor, self::MAX_COMMENTS_PER_GALLERY, self::MAX_COMMENTS_PER_GUEST); $commentId = $this->repository->insertComment( - $gallery->getId(), $guest->getId(), $fileId, $body, $annotation, $this->clock->getTime(), + $gallery->getId(), $actor->guestId(), $actor->userUid(), $fileId, $body, $annotation, $this->clock->getTime(), $parentId, ); - $this->event($gallery, $guest, 'comment.created', ['fileId' => $fileId, 'commentId' => $commentId]); + $this->event($gallery, $actor, 'comment.created', ['fileId' => $fileId, 'commentId' => $commentId]); return $commentId; }); } - public function deleteComment(Gallery $gallery, Guest $guest, int $commentId): void { - $this->atomic(function () use ($gallery, $guest, $commentId): void { - $this->capabilities->assertFeature('comments'); - $fileId = $this->ownedCommentFileId($gallery, $guest, $commentId); - if (!$this->repository->deleteComment($gallery->getId(), $guest->getId(), $commentId, $this->clock->getTime())) { + public function deleteComment(Gallery $gallery, CollaborationActor $actor, int $commentId): void { + $this->atomic(function () use ($gallery, $actor, $commentId): void { + $this->assertCommentingEnabled($gallery); + $fileId = $this->ownedCommentFileId($gallery, $actor, $commentId); + if (!$this->repository->deleteComment($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId, $this->clock->getTime())) { throw new InvalidArgumentException('Comment cannot be deleted'); } - $this->event($gallery, $guest, 'comment.deleted', ['fileId' => $fileId, 'commentId' => $commentId]); + $this->event($gallery, $actor, 'comment.deleted', ['fileId' => $fileId, 'commentId' => $commentId]); }); } - public function ownedCommentFileId(Gallery $gallery, Guest $guest, int $commentId): int { - $fileId = $this->repository->ownedCommentFileId($gallery->getId(), $guest->getId(), $commentId); + public function ownedCommentFileId(Gallery $gallery, CollaborationActor $actor, int $commentId): int { + $fileId = $this->repository->ownedCommentFileId($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId); if ($fileId === null) throw new InvalidArgumentException('Comment not found'); return $fileId; } - public function updateComment(Gallery $gallery, Guest $guest, int $commentId, string $body): void { - $this->atomic(function () use ($gallery, $guest, $commentId, $body): void { - $this->capabilities->assertFeature('comments'); - $fileId = $this->ownedCommentFileId($gallery, $guest, $commentId); + public function updateComment(Gallery $gallery, CollaborationActor $actor, int $commentId, string $body): void { + $this->atomic(function () use ($gallery, $actor, $commentId, $body): void { + $this->assertCommentingEnabled($gallery); + $fileId = $this->ownedCommentFileId($gallery, $actor, $commentId); $body = trim($body); if ($body === '' || mb_strlen($body) > 5000) { throw new InvalidArgumentException('Comment must contain between 1 and 5000 characters'); } - if (!$this->repository->updateComment($gallery->getId(), $guest->getId(), $commentId, $body, $this->clock->getTime())) { + if (!$this->repository->updateComment($gallery->getId(), $actor->guestId(), $actor->userUid(), $commentId, $body, $this->clock->getTime())) { throw new InvalidArgumentException('Comment cannot be edited'); } - $this->event($gallery, $guest, 'comment.updated', ['fileId' => $fileId, 'commentId' => $commentId]); + $this->event($gallery, $actor, 'comment.updated', ['fileId' => $fileId, 'commentId' => $commentId]); }); } /** @param list $fileIds */ - public function saveSelection(Gallery $gallery, PublicLink $link, Guest $guest, string $name, string $message, array $fileIds): string { - return $this->atomic(function () use ($gallery, $link, $guest, $name, $message, $fileIds): string { + public function saveSelection(Gallery $gallery, PublicLink $link, CollaborationActor $actor, string $name, string $message, array $fileIds): string { + return $this->atomic(function () use ($gallery, $link, $actor, $name, $message, $fileIds): string { $this->capabilities->assertFeature('selections'); $this->assertCollaborationMode($gallery); if (!$this->settings($gallery)->review->selections) { @@ -284,7 +288,7 @@ public function saveSelection(Gallery $gallery, PublicLink $link, Guest $guest, if ($link->getReviewEnabled()) { $settings = $this->settings($gallery)->review; $maximum = $link->getReviewSelectionMax() ?? $settings->selectionMaximum; - $current = $this->repository->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), (int)$guest->getId()); + $current = $this->repository->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), $actor->guestId(), $actor->userUid()); if ($current !== null && $current['status'] !== 'open') throw new InvalidArgumentException('The submitted selection is locked'); if ($maximum > 0 && count($fileIds) > $maximum) throw new InvalidArgumentException('Selection exceeds the maximum of ' . $maximum . ' photos'); } @@ -294,13 +298,13 @@ public function saveSelection(Gallery $gallery, PublicLink $link, Guest $guest, foreach ($fileIds as $fileId) { $this->resolveMedia($gallery, $fileId); } - $this->assertQuota('proofing_selections', $gallery->getId(), $guest->getId(), self::MAX_SELECTIONS_PER_GALLERY, self::MAX_SELECTIONS_PER_GUEST); + $this->assertQuota('proofing_selections', $gallery->getId(), $actor, self::MAX_SELECTIONS_PER_GALLERY, self::MAX_SELECTIONS_PER_GUEST); $publicId = $this->uuid(); $now = $this->clock->getTime(); $this->repository->insertSelection( - $gallery->getId(), $guest->getId(), (int)$link->getId(), $publicId, $name, $message, $fileIds, $now, + $gallery->getId(), $actor->guestId(), $actor->userUid(), (int)$link->getId(), $publicId, $name, $message, $fileIds, $now, ); - $this->event($gallery, $guest, 'selection.created', ['selectionId' => $publicId, 'count' => count($fileIds)]); + $this->event($gallery, $actor, 'selection.created', ['selectionId' => $publicId, 'count' => count($fileIds)]); $this->markResponseReceived($gallery, $now); return $publicId; }); @@ -373,18 +377,69 @@ public function guestSelectionFileIds(Gallery $gallery, Guest $guest, string $pu return $this->repository->selectionFileIds((int)$row['id']); } + /** @return list */ + public function actorSelectionFileIds(Gallery $gallery, CollaborationActor $actor, string $publicId): array { + $row = $this->repository->selection($gallery->getId(), $publicId); + if ($row === null || ($this->settings($gallery)->review->visibility === FeedbackVisibility::Private && !$actor->owns($row))) { + throw new InvalidArgumentException('Selection not found'); + } + return $this->repository->selectionFileIds((int)$row['id']); + } + + /** + * Authenticated reviewers export their selection without being treated as + * either a guest-rating principal or a gallery owner. + * @param list $requestedFields + * @return array{content: string, filename: string, mimeType: string} + */ + public function exportActorSelection(Gallery $gallery, CollaborationActor $actor, string $publicId, string $format, array $requestedFields = []): array { + $this->capabilities->assertFeature('selections'); + $row = $this->repository->selection($gallery->getId(), $publicId); + if ($row === null || ($this->settings($gallery)->review->visibility === FeedbackVisibility::Private && !$actor->owns($row))) { + throw new InvalidArgumentException('Selection not found'); + } + $fileIds = []; + $names = []; + foreach ($this->repository->selectionFileIds((int)$row['id']) as $fileId) { + try { + $fileIds[] = $fileId; + $names[] = $this->resolveMedia($gallery, $fileId)->getName(); + } catch (\Throwable) {} + } + $base = preg_replace('/[^a-z0-9._-]+/i', '-', (string)$row['name']) ?: 'selection'; + if ($format === 'csv' || $format === 'preview') { + $fields = array_values(array_unique(array_intersect(['filename', 'rating', 'pick'], array_map('strval', $requestedFields)))); + if ($fields === []) $fields = ['filename']; + $rows = $this->composeExportRows($gallery, null, $fileIds, $fields, (string)$row['name'], $actor); + $content = "\xEF\xBB\xBF" . $this->csv->encode([$fields, ...array_map( + static fn (array $values): array => array_map(static fn (string $field): string => (string)($values[$field] ?? ''), $fields), + $rows, + )]); + return ['content' => $content, 'filename' => $base . ($format === 'preview' ? '-preview.csv' : '.csv'), 'mimeType' => 'text/csv; charset=utf-8']; + } + return match ($format) { + 'search' => ['content' => implode(' OR ', array_map(static fn (string $name): string => 'name:"' . str_replace('"', '\\"', $name) . '"', $names)), 'filename' => $base . '-search.txt', 'mimeType' => 'text/plain'], + 'plain' => ['content' => implode("\n", $names) . "\n", 'filename' => $base . '.txt', 'mimeType' => 'text/plain'], + default => throw new InvalidArgumentException('Unknown export format'), + }; + } + /** * @param list $fileIds * @param list $fields * @return list> */ - private function composeExportRows(Gallery $gallery, ?Guest $guest, array $fileIds, array $fields, string $selectionName): array { + private function composeExportRows(Gallery $gallery, ?Guest $guest, array $fileIds, array $fields, string $selectionName, ?CollaborationActor $actor = null): array { if ($fileIds === []) return []; - $culls = $guest === null ? $this->culling->forFiles($gallery->getOwnerUid(), $fileIds) : []; - $aggregates = $guest === null ? array_column($this->guestRatings->aggregate($gallery, $fileIds)['items'], null, 'fileId') : []; - $guestValues = $guest === null ? [] : array_column(array_map(static fn (\OCA\ProofingGallery\Db\GuestRating $value): array => $value->jsonSerialize(), $this->guestRatings->forGuest($guest)), null, 'fileId'); + $isOwner = $guest === null && $actor === null; + $culls = $isOwner ? $this->culling->forFiles($gallery->getOwnerUid(), $fileIds) : []; + $aggregates = $isOwner ? array_column($this->guestRatings->aggregate($gallery, $fileIds)['items'], null, 'fileId') : []; + $ratingValues = $guest !== null + ? $this->guestRatings->forGuestFiles($guest, $fileIds) + : ($actor === null ? [] : $this->guestRatings->forActorFiles($gallery->getId(), $actor, $fileIds)); + $guestValues = array_column(array_map(static fn (\OCA\ProofingGallery\Db\GuestRating $value): array => $value->jsonSerialize(), $ratingValues), null, 'fileId'); $comments = []; - if ($guest === null && in_array('comments', $fields, true)) { + if ($isOwner && in_array('comments', $fields, true)) { $comments = $this->repository->commentsByFileIds($gallery->getId(), $fileIds); } $root = $gallery->getSourceType() === 'folder' ? $this->folders->resolveFolder($gallery->getOwnerUid(), $gallery->getFolderId()) : null; @@ -440,8 +495,8 @@ public function updateOwnerSelection(Gallery $gallery, string $publicId, string throw new InvalidArgumentException('Selection not found'); } $this->repository->insertOwnerEvent( - $gallery->getId(), (int)$selection['guest_id'], $gallery->getOwnerUid(), - 'selection.updated', ['selectionId' => $publicId], $now, + $gallery->getId(), $selection['guest_id'] === null ? null : (int)$selection['guest_id'], $gallery->getOwnerUid(), + 'selection.updated', ['selectionId' => $publicId], $now, $selection['actor_uid'], ); }); } @@ -452,8 +507,8 @@ public function deleteOwnerSelection(Gallery $gallery, string $publicId): void { if ($selection === null) throw new InvalidArgumentException('Selection not found'); if (!$this->repository->deleteSelection($gallery->getId(), $publicId)) throw new InvalidArgumentException('Selection not found'); $this->repository->insertOwnerEvent( - $gallery->getId(), (int)$selection['guest_id'], $gallery->getOwnerUid(), - 'selection.deleted', ['selectionId' => $publicId, 'deleted' => true], $this->clock->getTime(), + $gallery->getId(), $selection['guest_id'] === null ? null : (int)$selection['guest_id'], $gallery->getOwnerUid(), + 'selection.deleted', ['selectionId' => $publicId, 'deleted' => true], $this->clock->getTime(), $selection['actor_uid'], ); }); } @@ -462,9 +517,9 @@ private function settings(Gallery $gallery): GallerySettings { return GallerySettings::fromArray(json_decode($gallery->getSettings(), true, flags: JSON_THROW_ON_ERROR)); } - private function assertQuota(string $table, int $galleryId, int $guestId, int $galleryLimit, int $guestLimit): void { + private function assertQuota(string $table, int $galleryId, CollaborationActor $actor, int $galleryLimit, int $guestLimit): void { if ($this->repository->hasAtLeastRows($table, $galleryId, $galleryLimit) - || $this->repository->hasAtLeastRows($table, $galleryId, $guestLimit, $guestId)) { + || $this->repository->hasAtLeastRows($table, $galleryId, $guestLimit, $actor->guestId(), $actor->userUid())) { throw new InvalidArgumentException('Collaboration data limit reached'); } } @@ -475,6 +530,15 @@ private function assertCollaboration(Gallery $gallery, int $fileId): GallerySett return $settings; } + private function assertCommentingEnabled(Gallery $gallery): GallerySettings { + $this->capabilities->assertFeature('comments'); + $settings = $this->assertCollaborationMode($gallery); + if (!$settings->review->comments) { + throw new InvalidArgumentException('Comments are disabled'); + } + return $settings; + } + private function resolveMedia(Gallery $gallery, int $fileId): \OCP\Files\File { try { return $gallery->getSourceType() === 'collection' @@ -500,16 +564,17 @@ private function assertCollaborationMode(Gallery $gallery): GallerySettings { /** @param list> $rows * @return list> */ - private function presentComments(array $rows, ?Guest $viewer): array { + private function presentComments(array $rows, ?CollaborationActor $viewer): array { return array_map( static fn (array $row): array => [ 'id' => (int)$row['id'], 'fileId' => (int)$row['file_id'], 'body' => $row['body'], + 'threadId' => $row['parent_id'] === null ? (int)$row['id'] : (int)$row['parent_id'], 'createdAt' => (int)$row['created_at'], 'editedAt' => $row['edited_at'] === null ? null : (int)$row['edited_at'], 'deletedAt' => $row['deleted_at'] === null ? null : (int)$row['deleted_at'], - 'mine' => $viewer !== null && (int)$row['guest_id'] === $viewer->getId(), + 'mine' => $viewer !== null && $viewer->owns($row), 'author' => (string)$row['author'], 'annotations' => $row['annotations'], ], @@ -520,7 +585,7 @@ private function presentComments(array $rows, ?Guest $viewer): array { /** @param list> $rows * @return list> */ - private function presentSelections(array $rows, ?Guest $viewer): array { + private function presentSelections(array $rows, ?CollaborationActor $viewer): array { return array_map(static fn (array $row): array => [ 'id' => $row['public_id'], 'name' => $row['name'], @@ -528,15 +593,15 @@ private function presentSelections(array $rows, ?Guest $viewer): array { 'status' => $row['status'], 'fileIds' => $row['fileIds'], 'updatedAt' => (int)$row['updated_at'], - 'mine' => $viewer !== null && (int)$row['guest_id'] === $viewer->getId(), + 'mine' => $viewer !== null && $viewer->owns($row), 'author' => (string)$row['author'], ], $rows); } /** @param array $payload */ - private function event(Gallery $gallery, Guest $guest, string $type, array $payload): void { + private function event(Gallery $gallery, CollaborationActor $actor, string $type, array $payload): void { $now = $this->clock->getTime(); - $eventId = $this->repository->insertEvent($gallery->getId(), $guest->getId(), $type, $payload, $now); + $eventId = $this->repository->insertEvent($gallery->getId(), $actor->guestId(), $actor->userUid(), $type, $payload, $now); $staged = $this->notifications->stage($gallery, $eventId, $type, $now); $this->stagedActivities[] = [ 'gallery' => $gallery, @@ -547,6 +612,12 @@ private function event(Gallery $gallery, Guest $guest, string $type, array $payl ]; } + private function actorScope(CollaborationActor $actor): CollaborationReadScope { + return $actor->guestId() !== null + ? CollaborationReadScope::guest($actor->guestId()) + : CollaborationReadScope::user($actor->userUid() ?? ''); + } + private function atomic(callable $callback): mixed { $ownsTransaction = !$this->db->inTransaction(); $activityOffset = count($this->stagedActivities); diff --git a/lib/Service/GuestRatingAggregator.php b/lib/Service/GuestRatingAggregator.php index 758e347..8e29948 100644 --- a/lib/Service/GuestRatingAggregator.php +++ b/lib/Service/GuestRatingAggregator.php @@ -9,10 +9,10 @@ final class GuestRatingAggregator { /** * @param list $values - * @param array $guestNames + * @param array $actorNames * @return array */ - public function summarize(int $fileId, array $values, array $guestNames): array { + public function summarize(int $fileId, array $values, array $actorNames): array { if ($values === []) throw new \InvalidArgumentException('Cannot summarize an empty guest rating set'); $distribution = array_fill(0, 6, 0); $picks = ['none' => 0, 'pick' => 0, 'reject' => 0]; @@ -26,7 +26,9 @@ public function summarize(int $fileId, array $values, array $guestNames): array $updatedAt = max($updatedAt, $value->getUpdatedAt()); $individuals[] = [ 'guestId' => $value->getGuestId(), - 'name' => ($guestNames[$value->getGuestId()] ?? '') ?: 'Guest', + 'actorUid' => $value->getActorUid(), + 'actorKind' => $value->getGuestId() === null ? 'user' : 'guest', + 'name' => ($actorNames[$value->actorKey()] ?? '') ?: ($value->getGuestId() === null ? 'Account' : 'Guest'), ...$value->jsonSerialize(), ]; } diff --git a/lib/Service/GuestRatingService.php b/lib/Service/GuestRatingService.php index ce7cf34..168b3a3 100644 --- a/lib/Service/GuestRatingService.php +++ b/lib/Service/GuestRatingService.php @@ -10,8 +10,10 @@ use OCA\ProofingGallery\Db\GuestRating; use OCA\ProofingGallery\Db\GuestRatingMapper; use OCA\ProofingGallery\Db\PublicLink; +use OCA\ProofingGallery\Domain\CollaborationActor; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IUserManager; final class GuestRatingService { public function __construct( @@ -21,19 +23,27 @@ public function __construct( private \OCP\IDBConnection $db, private GuestRatingAggregator $aggregator, private CapabilityPolicyService $capabilities, + private IUserManager $users, ) { } public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, string $pick = 'none'): GuestRating { + if ($guest->getGalleryId() !== $link->getGalleryId()) { + throw new \InvalidArgumentException('Guest and public link do not belong to the same active gallery'); + } + return $this->saveForActor($link, CollaborationActor::guest($guest), $fileId, $rating, $pick); + } + + public function saveForActor(PublicLink $link, CollaborationActor $actor, int $fileId, int $rating, string $pick = 'none'): GuestRating { $this->capabilities->assertFeature('guestRatings'); if ($rating < 0 || $rating > 5 || !in_array($pick, ['none', 'pick', 'reject'], true)) { - throw new \InvalidArgumentException('Invalid guest rating'); + throw new \InvalidArgumentException('Invalid reviewer rating'); } - if ($guest->getGalleryId() !== $link->getGalleryId() || $link->getStatus() !== 'active') { - throw new \InvalidArgumentException('Guest and public link do not belong to the same active gallery'); + if ($link->getStatus() !== 'active') { + throw new \InvalidArgumentException('Public link is not active'); } try { - $value = $this->ratings->findGuestFile($link->getGalleryId(), $guest->getId(), $fileId); + $value = $this->ratings->findActorFile($link->getGalleryId(), $actor->guestId(), $actor->userUid(), $fileId); $value->setPublicLinkId($link->getId()); $value->setRating($rating); $value->setPickState($pick); @@ -43,7 +53,8 @@ public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, s $value = new GuestRating(); $value->setGalleryId($link->getGalleryId()); $value->setPublicLinkId($link->getId()); - $value->setGuestId($guest->getId()); + $value->setGuestId($actor->guestId()); + $value->setActorUid($actor->userUid()); $value->setFileId($fileId); $value->setRating($rating); $value->setPickState($pick); @@ -54,16 +65,28 @@ public function save(PublicLink $link, Guest $guest, int $fileId, int $rating, s /** @return list */ public function forGuest(Guest $guest): array { + return $this->forActor($guest->getGalleryId(), CollaborationActor::guest($guest)); + } + + /** @return list */ + public function forActor(int $galleryId, CollaborationActor $actor): array { $this->capabilities->assertFeature('guestRatings'); - return $this->ratings->findForGuest($guest->getGalleryId(), $guest->getId()); + return $this->ratings->findForActor($galleryId, $actor->guestId(), $actor->userUid()); } /** @param list $fileIds * @return list */ public function forGuestFiles(Guest $guest, array $fileIds): array { + return $this->forActorFiles($guest->getGalleryId(), CollaborationActor::guest($guest), $fileIds); + } + + /** @param list $fileIds + * @return list + */ + public function forActorFiles(int $galleryId, CollaborationActor $actor, array $fileIds): array { $this->capabilities->assertFeature('guestRatings'); - return $this->ratings->findForGuestFiles($guest->getGalleryId(), $guest->getId(), $fileIds); + return $this->ratings->findForActorFiles($galleryId, $actor->guestId(), $actor->userUid(), $fileIds); } /** @@ -74,22 +97,35 @@ public function aggregate(\OCA\ProofingGallery\Db\Gallery $gallery, array $fileI $this->capabilities->assertFeature('guestRatings'); $grouped = []; $guests = []; + $actorNames = []; + $actorUids = []; $values = $fileIds === [] ? $this->ratings->findForGallery($gallery->getId()) : $this->ratings->findForGalleryFiles($gallery->getId(), $fileIds); foreach ($values as $value) { $grouped[$value->getFileId()][] = $value; - $guests[$value->getGuestId()] = ''; + if ($value->getGuestId() !== null) { + $guests[$value->getGuestId()] = ''; + $actorNames[$value->actorKey()] = ''; + } elseif ($value->getActorUid() !== null) { + $actorUids[$value->getActorUid()] = ''; + } + } + foreach (array_keys($actorUids) as $actorUid) { + $actorNames['user:' . $actorUid] = $this->users->get($actorUid)?->getDisplayName() ?? ''; } if ($guests !== []) { foreach (array_chunk(array_keys($guests), 500) as $guestIds) { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'display_name')->from('proofing_guests') ->where($qb->expr()->in('id', $qb->createNamedParameter($guestIds, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT_ARRAY))); - foreach (QueryResult::rows($qb->executeQuery()) as $row) $guests[(int)$row['id']] = (string)$row['display_name']; + foreach (QueryResult::rows($qb->executeQuery()) as $row) { + $guests[(int)$row['id']] = (string)$row['display_name']; + $actorNames['guest:' . (int)$row['id']] = (string)$row['display_name']; + } } } $items = []; foreach ($grouped as $fileId => $values) { - $items[] = $this->aggregator->summarize((int)$fileId, $values, $guests); + $items[] = $this->aggregator->summarize((int)$fileId, $values, $actorNames); } return ['items' => $items, 'guests' => $guests]; } diff --git a/lib/Service/PrivacyService.php b/lib/Service/PrivacyService.php index 9533126..3e3b7b5 100644 --- a/lib/Service/PrivacyService.php +++ b/lib/Service/PrivacyService.php @@ -55,7 +55,7 @@ public function deleteGuest(Guest $guest): int { foreach ($this->repository->guestUploadIds((int)$guest->getId()) as $uploadId) { try { $this->appData->getFolder('guest-uploads')->getFolder($uploadId)->delete(); } catch (\Throwable) {} } - return $this->repository->deleteGuestData((int)$guest->getId()); + return $this->repository->deleteGuestData((int)$guest->getId(), (int)$guest->getGalleryId()); } /** @return array */ diff --git a/lib/Service/ReviewWorkflowService.php b/lib/Service/ReviewWorkflowService.php index 7dfd3f4..28f1f13 100644 --- a/lib/Service/ReviewWorkflowService.php +++ b/lib/Service/ReviewWorkflowService.php @@ -6,12 +6,12 @@ use OCA\ProofingGallery\Db\CollaborationRepository; use OCA\ProofingGallery\Db\Gallery; -use OCA\ProofingGallery\Db\Guest; use OCA\ProofingGallery\Db\PublicLink; use OCA\ProofingGallery\Db\PublicLinkMapper; use OCA\ProofingGallery\Db\ReviewRoundRepository; use OCA\ProofingGallery\Dto\GallerySettings; use OCA\ProofingGallery\Exception\ReviewConflictException; +use OCA\ProofingGallery\Domain\CollaborationActor; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\TTransactional; use OCP\AppFramework\Utility\ITimeFactory; @@ -33,9 +33,9 @@ public function __construct( } /** @return array */ - public function publicState(Gallery $gallery, PublicLink $link, ?Guest $guest = null): array { + public function publicState(Gallery $gallery, PublicLink $link, ?CollaborationActor $actor = null): array { $rules = $this->rules($gallery, $link); - $selection = $guest === null ? null : $this->collaboration->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), (int)$guest->getId()); + $selection = $actor === null ? null : $this->collaboration->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), $actor->guestId(), $actor->userUid()); return [ 'enabled' => $link->getReviewEnabled(), 'dueDate' => $rules['dueDate'], @@ -73,28 +73,28 @@ public function synchronize(Gallery $gallery, PublicLink $link): void { } /** @return array */ - public function submit(Gallery $gallery, PublicLink $link, Guest $guest): array { + public function submit(Gallery $gallery, PublicLink $link, CollaborationActor $actor): array { $this->assertLink($gallery, $link); if (!$link->getReviewEnabled()) throw new \InvalidArgumentException('Review submission is disabled for this link'); $current = $this->ensure($link); $rules = $this->rules($gallery, $link); $now = $this->clock->getTime(); if ($rules['dueDate'] !== null && gmdate('Y-m-d', $now) > $rules['dueDate']) throw new \InvalidArgumentException('The selection deadline has passed'); - $selection = $this->collaboration->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), (int)$guest->getId()); + $selection = $this->collaboration->latestSelectionForLink((int)$gallery->getId(), (int)$link->getId(), $actor->guestId(), $actor->userUid()); if ($selection === null || $selection['status'] !== 'open') throw new \InvalidArgumentException('Save a selection draft before submitting'); $count = (int)$selection['item_count']; if ($count < $rules['minimum']) throw new \InvalidArgumentException('Select at least ' . $rules['minimum'] . ' photos before submitting'); if ($rules['maximum'] > 0 && $count > $rules['maximum']) throw new \InvalidArgumentException('Select no more than ' . $rules['maximum'] . ' photos before submitting'); - $this->atomic(function () use ($selection, $link, $current, $guest, $now): void { + $this->atomic(function () use ($selection, $link, $current, $actor, $now): void { if (!$this->collaboration->submitSelection((int)$selection['id'], (int)$link->getId(), $now) - || !$this->rounds->submit((int)$current['id'], (int)$guest->getId(), $now)) { + || !$this->rounds->submit((int)$current['id'], $actor->guestId(), $actor->userUid(), $now)) { throw new ReviewConflictException('This review round is no longer open'); } }, $this->db); $this->collaboration->markResponseReceived((int)$gallery->getId(), $now); - $this->activity->record($gallery, $guest, 'review.submitted', ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]); + $this->activity->recordActor($gallery, $actor, 'review.submitted', ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]); $this->integrations->emit('review.submitted', (int)$gallery->getId(), ['publicLinkId' => (int)$link->getId(), 'round' => (int)$current['round_number']]); - return $this->publicState($gallery, $link, $guest); + return $this->publicState($gallery, $link, $actor); } /** @return array */ diff --git a/lib/Share/PublicShareTemplateProvider.php b/lib/Share/PublicShareTemplateProvider.php index 6c75cb9..b05c72e 100644 --- a/lib/Share/PublicShareTemplateProvider.php +++ b/lib/Share/PublicShareTemplateProvider.php @@ -11,6 +11,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IURLGenerator; +use OCP\IUserSession; use OCP\Share\IPublicShareTemplateProvider; use OCP\Share\IShare; use OCP\Util; @@ -22,6 +23,7 @@ public function __construct( private IURLGenerator $urlGenerator, private \OCA\ProofingGallery\Service\PublicShareContextResolver $contexts, private \OCA\ProofingGallery\Service\ReviewWorkflowService $reviews, + private IUserSession $userSession, ) { } @@ -42,6 +44,7 @@ public function renderPage(IShare $share, string $token, string $path): Template return $response; } $gallery = $context->gallery; + $user = $this->userSession->getUser(); $initialPage = $this->galleryData->page($context, new PublicGalleryQuery(path: $path)); $firstImage = null; foreach ($initialPage['items'] as $item) { @@ -72,11 +75,16 @@ public function renderPage(IShare $share, string $token, string $path): Template 'path' => $path, 'initialPage' => $initialPage, 'review' => $this->reviews->publicState($context->gallery, $context->link), + 'viewer' => $user === null ? null : [ + 'displayName' => $user->getDisplayName(), + 'email' => $user->getEMailAddress(), + ], ]); Util::addScript(Application::APP_ID, 'proofing_gallery-public'); Util::addStyle(Application::APP_ID, 'proofing_gallery-public'); $response = new PublicTemplateResponse(Application::APP_ID, 'public'); + $response->addHeader('Cache-Control', 'private, no-store'); if ($firstPaintImage !== null) { $response->addHeader('Link', sprintf('<%s>; rel=preload; as=image', $firstPaintImage)); } @@ -87,4 +95,5 @@ public function renderPage(IShare $share, string $token, string $path): Template ]); return $response; } + } diff --git a/lib/UserMigration/ProofingGalleryMigrator.php b/lib/UserMigration/ProofingGalleryMigrator.php index 9c9daf8..ee9e29b 100644 --- a/lib/UserMigration/ProofingGalleryMigrator.php +++ b/lib/UserMigration/ProofingGalleryMigrator.php @@ -248,7 +248,8 @@ private function boundedList(mixed $value, int $limit): array { /** @return array */ private function object(mixed $value): array { - if (!is_array($value) || array_is_list($value)) throw new UserMigrationException('Invalid Proofing Gallery manifest object'); + // Associative JSON decoding represents an empty object as an empty array. + if (!is_array($value) || ($value !== [] && array_is_list($value))) throw new UserMigrationException('Invalid Proofing Gallery manifest object'); return $value; } diff --git a/package-lock.json b/package-lock.json index 47d0358..42ec958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proofing_gallery", - "version": "0.9.2", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proofing_gallery", - "version": "0.9.2", + "version": "0.10.0", "license": "AGPL-3.0-or-later", "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/package.json b/package.json index 68f895a..6570cdf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proofing_gallery", - "version": "0.9.2", + "version": "0.10.0", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/scripts/prepare-public-history.sh b/scripts/prepare-public-history.sh index ae14d66..2862251 100755 --- a/scripts/prepare-public-history.sh +++ b/scripts/prepare-public-history.sh @@ -11,7 +11,10 @@ public_author_email="${PUBLIC_AUTHOR_EMAIL:-soerennb@users.noreply.github.com}" # Content alternatives are matched as standalone tokens. This keeps a private # token such as "internal.example.invalid" from rewriting the public "soerennb" repository -# identity while still removing standalone private names and domains. +# identity while still removing standalone private names and domains. Existing +# author and committer addresses are intentionally not part of this content +# scan: the public history already contains those identities and they are +# allowed to remain historical metadata. source "${repo_dir}/scripts/lib/public-history-sanitizer.sh" if [[ -z "${destination}" ]]; then @@ -73,12 +76,8 @@ git filter-repo --force \ public_history_clear_replace_refs -private_pattern="${private_content_pattern}" -for email in "${private_email_list[@]}"; do - private_pattern+="|${email//./\\.}" -done -private_scan_regex="$(public_history_scan_regex "${private_pattern}")" -if git log --all --format='%an <%ae>%n%cn <%ce>%n%B' | grep -Ei "${private_scan_regex}" >/dev/null; then +private_scan_regex="$(public_history_scan_regex "${private_content_pattern}")" +if git log --all --format='%B' | grep -Ei "${private_scan_regex}" >/dev/null; then echo "Private metadata remains in commit metadata or messages." >&2 exit 1 fi diff --git a/scripts/test-public-history-sanitizer.sh b/scripts/test-public-history-sanitizer.sh index 39f8372..d2043db 100755 --- a/scripts/test-public-history-sanitizer.sh +++ b/scripts/test-public-history-sanitizer.sh @@ -12,6 +12,17 @@ token_regex="$(public_history_token_regex "${private_content_pattern}")" public_identity='https://github.com/soerennb/nextcloud-proofing_gallery' private_content='private owner internal.example.invalid · internal.example.invalid.de · internal.example.invalid' +fixture_dir="$(mktemp -d -t proofing-gallery-sanitizer-test.XXXXXXXX)" +trap 'rm -rf -- "${fixture_dir}"' EXIT +git -C "${fixture_dir}" init -q +git -C "${fixture_dir}" config user.name 'internal.example.invalid' +git -C "${fixture_dir}" config user.email 'soerennb@users.noreply.github.com' +touch "${fixture_dir}/README.md" +git -C "${fixture_dir}" add README.md +git -C "${fixture_dir}" commit -q -m 'public sync' +historical_metadata="$(git -C "${fixture_dir}" log -1 --format='%an <%ae>%n%cn <%ce>%n%B')" +commit_message="$(git -C "${fixture_dir}" log -1 --format='%B')" + if printf '%s\n' "${public_identity}" | grep -E "${scan_regex}" >/dev/null; then echo "public identity was incorrectly classified as private content" >&2 exit 1 @@ -20,6 +31,14 @@ if ! printf '%s\n' "${private_content}" | grep -E "${scan_regex}" >/dev/null; th echo "standalone private content was not detected" >&2 exit 1 fi +if ! printf '%s\n' "${historical_metadata}" | grep -E "${scan_regex}" >/dev/null; then + echo "fixture did not contain historical private author metadata" >&2 + exit 1 +fi +if printf '%s\n' "${commit_message}" | grep -E "${scan_regex}" >/dev/null; then + echo "clean commit message was incorrectly classified as private content" >&2 + exit 1 +fi python3 - "${token_regex}" "${public_identity}" "${private_content}" <<'PY' import re diff --git a/scripts/test-upgrade.sh b/scripts/test-upgrade.sh index a1ab089..69d73db 100755 --- a/scripts/test-upgrade.sh +++ b/scripts/test-upgrade.sh @@ -244,8 +244,17 @@ compose exec -T -e PG_BASELINE_HAS_LEGACY_REPAIR="${baseline_has_legacy_repair}" } ' +compose exec -T --user www-data "${service}" php /dev/stdin seed < "${repo_dir}/tests/smoke/UpgradeCollaboration.php" + run_upgrade() { run_sqlite_retry exec -T --user www-data "${service}" php occ upgrade + # A successful retry inherits maintenance mode from the failed attempt. + # Leave it only after Nextcloud confirms that no DB upgrade remains. + compose exec -T --user www-data "${service}" php -r ' + require "/var/www/html/lib/base.php"; + if (\OCP\Util::needUpgrade()) throw new RuntimeException("Database upgrade remains pending"); + ' + compose exec -T --user www-data "${service}" php occ maintenance:mode --off } run_app_update() { run_sqlite_retry exec -T --user www-data "${service}" php -r 'require "/var/www/html/lib/base.php"; \OC_App::updateApp("proofing_gallery");' @@ -303,6 +312,7 @@ else fi run_upgrade status_json="$(compose exec -T --user www-data "${service}" php occ status --output=json)" +compose exec -T --user www-data "${service}" php /dev/stdin verify < "${repo_dir}/tests/smoke/UpgradeCollaboration.php" php -r ' $status = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); if (($status["maintenance"] ?? true) || ($status["needsDbUpgrade"] ?? true)) { diff --git a/src/PublicApp.vue b/src/PublicApp.vue index 3e5b445..bae6387 100644 --- a/src/PublicApp.vue +++ b/src/PublicApp.vue @@ -6,7 +6,7 @@ import { IonAlert, IonApp, IonContent, IonLoading, IonPage } from '@ionic/vue' import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { calculateMediaLayout } from './domain/mediaGridLayout.ts' -import { publicGalleryCssVariables } from './domain/galleryTheme.ts' +import { publicGalleryThemeStyle } from './domain/publicGalleryThemeStyle.ts' import { PUBLIC_GALLERY_PAGE_SIZE, readPublicGalleryLocation, writePublicGalleryLocation } from './domain/publicGalleryNavigation.ts' import { continuationStorageKey, layoutSessionStorageKey, loadPublicGalleryCompareIds, loadPublicGalleryContinuation, loadPublicGallerySavedView, loadPublicGallerySessionLayout, viewStorageKey } from './domain/publicGalleryPreferences.ts' import { serialTask } from './domain/serialTask.ts' @@ -56,7 +56,7 @@ const title = ref(props.gallery.initialPage?.gallery.title ?? props.gallery.titl const deliveryMode = ref(props.gallery.initialPage?.gallery.deliveryMode ?? props.gallery.deliveryMode ?? 'standard'), accentColor = computed(() => settings.value.presentation.accentColor || '#E85D4A') const { visitorPreference, effectiveTheme, setVisitorPreference } = usePublicAppearance(computed(() => settings.value.presentation.theme), accentColor) const appearancePreference = computed({ get: () => visitorPreference.value, set: setVisitorPreference }) -const pageStyle = computed(() => publicGalleryCssVariables(accentColor.value, settings.value.presentation.heroFocusX, settings.value.presentation.heroFocusY)) +const pageStyle = computed(() => publicGalleryThemeStyle(settings.value)) const mediaItems = computed(() => items.value.filter(item => !item.folder)) const eventAlbumRoot = computed(() => deliveryMode.value === 'event' && currentPath.value === '' && scope.value?.viewMode === 'folder'), openerTotalLabel = computed(() => eventAlbumRoot.value ? n('proofing_gallery', '%n album', '%n albums', total.value) : undefined) @@ -71,7 +71,6 @@ const compareOpen = ref(false) const compareItems = computed(() => compareIds.value.map(id => mediaItems.value.find(item => item.id === id)).filter((item): item is MediaItem => !!item)) let collaborationTimer: number | undefined const { guest, collaboration, hydratedIds: collaborationHydratedIds, nonce, restoreIdentity, clearIdentity } = usePublicCollaborationIdentity(props.gallery.token) -const [guestName, guestEmail] = [ref(''), ref('')] const joining = ref(false) const collaborationError = ref('') const galleryDownloadBusy = ref(false) @@ -108,7 +107,9 @@ const activePanel = ref<'menu' | 'search' | 'view' | 'pages' | 'download' | 'sel const searchOpen = ref(false) const collaborationSheetOpen = ref(false) const mediaDimensions = ref>({}) -const mobileViewportQuery = window.matchMedia('(max-width: 640px)') +// Panel mode changes only on phone-sized viewports. Narrow desktop windows keep +// centered dialogs and floating annotation controls instead of mobile sheets. +const mobileViewportQuery = window.matchMedia('(max-width: 520px)') const mobileViewport = ref(mobileViewportQuery.matches) const viewportWidth = ref(window.innerWidth) let searchTimer: number | undefined @@ -425,7 +426,7 @@ function onVisibilityChange() { startCollaborationPolling() } -async function joinCollaboration() { +async function joinCollaboration(identity: { displayName: string; email: string }) { joining.value = true collaborationError.value = '' try { @@ -433,11 +434,11 @@ async function joinCollaboration() { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ displayName: guestName.value, email: guestEmail.value || null }), + body: JSON.stringify({ displayName: identity.displayName, email: identity.email || null }), }) const payload = await response.json() as { guest?: GuestIdentity, nonce?: string, message?: string } if (!response.ok || !payload.guest || !payload.nonce) { - throw new Error(payload.message || t('proofing_gallery', 'Could not start review session')) + throw new Error(t('proofing_gallery', 'Could not start review session')) } restoreIdentity(payload.guest, payload.nonce) await loadCollaboration() @@ -450,21 +451,18 @@ async function joinCollaboration() { } } -async function performCollaborationLoad() { +async function performCollaborationLoad(forceHydration = false): Promise { try { - const visibleIds = mediaItems.value.slice(0, 200).map(item => item.id) - const unhydratedIds = visibleIds.filter(id => !collaborationHydratedIds.has(id)) - const hydration = unhydratedIds.length > 0 - const query = new URLSearchParams({ - cursor: String(hydration ? 0 : collaboration.value?.cursor ?? 0), - fileIds: (hydration ? unhydratedIds : visibleIds).join(','), - }) - const response = await fetch(publicEndpoint(`collaboration?${query}`), { - headers: { Accept: 'application/json' }, - }) - if (!response.ok) throw response - const payload = await response.json() as CollaborationState | { unchanged: true; cursor: number } + const visibleIds = mediaItems.value.slice(0, 200).map(item => item.id); const unhydratedIds = visibleIds.filter(id => !collaborationHydratedIds.has(id)) + const hydration = forceHydration || unhydratedIds.length > 0 + const query = new URLSearchParams({ cursor: String(hydration ? 0 : collaboration.value?.cursor ?? 0), fileIds: (hydration ? unhydratedIds : visibleIds).join(',') }) + const response = await fetch(publicEndpoint(`collaboration?${query}`), { headers: { Accept: 'application/json' } }) + if (!response.ok) throw response; const payload = await response.json() as CollaborationState | { unchanged: true; cursor: number } if (!('unchanged' in payload)) { + if (payload.reset === true) { + collaboration.value = null; collaborationHydratedIds.clear() + return performCollaborationLoad(true) + } const { mergeCollaborationState } = await import('./domain/collaboration.ts') collaboration.value = collaboration.value === null ? payload : mergeCollaborationState(collaboration.value, payload, hydration ? unhydratedIds : []) for (const id of unhydratedIds) collaborationHydratedIds.add(id) @@ -502,7 +500,7 @@ async function performMutation(path: string, method: 'POST' | 'PUT' | 'DELETE', if (response.status === 401 || payload.code === 'invalid_nonce') { if (!deferredMutation.isCompleting()) return deferredMutation.defer(path, method, body) } - collaborationError.value = payload.message || t('proofing_gallery', 'The review change could not be saved.') + collaborationError.value = t('proofing_gallery', 'The review change could not be saved.') return false } await loadCollaboration() @@ -869,7 +867,7 @@ function upOneLevel() { - -
+
{{ t('proofing_gallery', 'Export my data') }}