diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f5a2cf..8b7a63a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/.gitignore b/.gitignore index 7968b25..792a587 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,14 @@ __pycache__/ /.env /.env.local /.env.studio +/.env.* +!/.env.example /.local/ /.tmp/ /tmp/ /.idea/ /.vscode/ *.log + +# graft's local graph cache — regenerable, not committed (run `graft build`). +/graft/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..615e581 --- /dev/null +++ b/.ignore @@ -0,0 +1,5 @@ +# graft's cards are gitignored but should stay greppable: ripgrep reads +# .ignore before .gitignore, so this re-admits the tree to search only. +!graft/ +graft/.cache/ +graft/.graph/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c36f4c..6ebe818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- separate general feedback from expandable annotation conversations, keep + panels beside the image detail without dimming artwork, and display review + state names with their configured colors +- retain image-relative annotation positions through transform-only zoom and + right-button panning, and refresh thread placement when opened from Pins +- deliver owner selection updates and deletions to the correct private account + without changing event attribution or guest visibility +- accept empty settings objects during additive user migration and report + migration smoke-test failures with a failing exit status +- make signed-in Nextcloud accounts and public guests first-class collaboration + actors across comments, selections, private ratings, picks, and review rounds +- preserve account UIDs in storage while displaying current Nextcloud names, + enforce actor-specific uniqueness, and purge account-authored collaboration + data when its Nextcloud account is deleted +- retain the existing guest session, export, rating, and review behavior for + backward compatibility + ## 0.9.2 — 2026-09-07 - make the mobile gallery and lightbox easier to use with more reliable overlays, 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/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e9ad654..5089823 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: diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 78716d2..710f41c 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 diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 357cc41..1d8d351 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -122,6 +122,21 @@ 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. + +**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. + +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. + 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..a0efa13 100644 --- a/docs/de/benutzerhandbuch.md +++ b/docs/de/benutzerhandbuch.md @@ -131,6 +131,21 @@ 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. + +**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. + +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. + 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..1d8d351 100644 --- a/docs/en/user-guide.md +++ b/docs/en/user-guide.md @@ -122,6 +122,21 @@ 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. + +**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. + +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. + 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..eac0dc8 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", @@ -1585,6 +1611,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..ff4f7aa 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", @@ -1586,6 +1612,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..8a79c68 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", @@ -1585,6 +1611,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..be92f3b 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", @@ -1586,6 +1612,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..2ea3d02 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 []; }); } @@ -163,8 +177,8 @@ public function addComment(int $fileId, string $body, ?array $annotation = null) if ($annotation !== 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), ], 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..e4dc0f2 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'], @@ -53,6 +65,16 @@ public function create(string $displayName = '', ?string $email = null): JSONRes #[FrontpageRoute(verb: 'GET', url: '/public/{token}/session')] public function current(): 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; + } $secret = $this->guestSecret($this->gallery()); $session = $this->guests->resume($this->gallery(), $secret); $response = new JSONResponse(['guest' => $session['guest'], 'nonce' => $session['nonce'], 'expiresIn' => 2592000]); diff --git a/lib/Controller/PublicReviewController.php b/lib/Controller/PublicReviewController.php index d0f45eb..d3b5952 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); } @@ -44,14 +47,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 +60,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..7ae095a 100644 --- a/lib/Db/CollaborationRepository.php +++ b/lib/Db/CollaborationRepository.php @@ -8,8 +8,10 @@ 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', @@ -19,7 +21,7 @@ final class CollaborationRepository { 'proofing_comments' => 10000, 'proofing_selections' => 2000, ]; - public function __construct(private IDBConnection $db) { + public function __construct(private IDBConnection $db, private IUserManager $users) { } /** @@ -39,7 +41,8 @@ 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]; } @@ -58,23 +61,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); @@ -90,18 +91,18 @@ public function state(int $galleryId, CollaborationReadScope $scope, int $cursor /** @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 +117,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 +129,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 +138,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 +173,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 +187,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 +208,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 { $ownsTransaction = !$this->db->inTransaction(); if ($ownsTransaction) $this->db->beginTransaction(); try { @@ -217,7 +216,7 @@ 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), 'body' => $qb->createNamedParameter($body), @@ -235,40 +234,42 @@ public function insertComment(int $galleryId, int $guestId, int $fileId, string } } - public function deleteComment(int $galleryId, int $guestId, int $commentId, int $now): bool { + 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 +277,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 +303,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 +314,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 +436,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 +461,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 +535,83 @@ 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) { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->eq('actor_uid', $qb->createNamedParameter($actorUid)), + $qb->expr()->eq('recipient_uid', $qb->createNamedParameter($actorUid)), + )); + } else { + $this->actorCondition($qb, $guestId, $actorUid); + } 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..b60bb3a 100644 --- a/lib/Db/PurgeRepository.php +++ b/lib/Db/PurgeRepository.php @@ -165,24 +165,45 @@ 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 { $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->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->db->commit(); + return $deleted; + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; } - return $deleted; } /** @return array>> */ @@ -232,6 +253,13 @@ 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())); + } + /** @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..bd20ffd 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,12 +125,12 @@ 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), @@ -140,7 +141,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 +150,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 +195,25 @@ 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'); + public function addComment(Gallery $gallery, CollaborationActor $actor, int $fileId, string $body, ?array $annotation): int { + return $this->atomic(function () use ($gallery, $actor, $fileId, $body, $annotation): int { + $settings = $this->assertCommentingEnabled($gallery); if ($annotation !== null) $this->capabilities->assertFeature('annotations'); - $settings = $this->assertCollaboration($gallery, $fileId); - if (!$settings->review->comments) { - throw new InvalidArgumentException('Comments are disabled'); - } + $this->resolveMedia($gallery, $fileId); if ($annotation !== null && !$settings->review->annotations) { throw new InvalidArgumentException('Image annotations are disabled'); } @@ -225,50 +224,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(), ); - $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 +283,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 +293,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 +372,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 +490,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 +502,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 +512,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 +525,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,7 +559,7 @@ 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'], @@ -509,7 +568,7 @@ private function presentComments(array $rows, ?Guest $viewer): array { '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 +579,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 +587,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 +606,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/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..094e330 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::addScript(Application::APP_ID, $this->publicScriptName()); 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,17 @@ public function renderPage(IShare $share, string $token, string $path): Template ]); return $response; } + + private function publicScriptName(): string { + $directory = dirname(__DIR__, 2) . '/js'; + $pointer = $directory . '/proofing_gallery-public.current'; + if (!is_readable($pointer)) { + return 'proofing_gallery-public'; + } + $candidate = trim((string)file_get_contents($pointer)); + if (!preg_match('/^proofing_gallery-public-[a-f0-9]{12}$/', $candidate)) { + return 'proofing_gallery-public'; + } + return is_file($directory . '/' . $candidate . '.mjs') ? $candidate : 'proofing_gallery-public'; + } } 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/scripts/lib/workflow-policy.test.mjs b/scripts/lib/workflow-policy.test.mjs new file mode 100644 index 0000000..cd97375 --- /dev/null +++ b/scripts/lib/workflow-policy.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import test from 'node:test' +import { parse } from 'yaml' + +const policy = parse(await readFile(new URL('../../.github/workflows/workflow-policy.yml', import.meta.url), 'utf8')) +const fetchStep = policy.jobs.policy.steps.find(step => step.id === 'files') +const auditStep = policy.jobs.policy.steps.find(step => step.uses?.startsWith('zizmorcore/')) + +async function stageFiles(t, files) { + const root = await mkdtemp(join(tmpdir(), 'workflow-policy-')) + t.after(() => rm(root, { recursive: true, force: true })) + const workspace = join(root, 'workspace'), runnerTemp = join(root, 'temp'), bin = join(root, 'bin') + await Promise.all([workspace, runnerTemp, bin].map(path => mkdir(path))) + // Simulate API responses; PR file contents are data, never executed. + await writeFile(join(bin, 'gh'), '#!/bin/sh\nif [ "$2" = "--paginate" ]; then printf "%s" "$POLICY_FILES"; else printf "%s" "$POLICY_CONTENT"; fi\n', { mode: 0o755 }) + const output = join(root, 'output') + await writeFile(output, '') + const result = spawnSync('bash', ['-c', fetchStep.run], { + encoding: 'utf8', + env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: runnerTemp, + GITHUB_OUTPUT: output, GITHUB_REPOSITORY: 'example/gallery', PR_NUMBER: '1', HEAD_SHA: 'test', + POLICY_FILES: files, POLICY_CONTENT: Buffer.from('name: fixture\n').toString('base64') }, + }) + return { result, workspace, output } +} + +test('workflow policy stages changed YAML inside the scanner workspace mount', async (t) => { + const { result, workspace, output } = await stageFiles(t, '.github/workflows/ci.yml\tmodified\n.github/actions/setup/action.yml\tadded\n.github/workflows/removed.yml\tremoved\n') + assert.equal(result.status, 0, result.stderr) + const input = resolve(workspace, auditStep.with.inputs) + assert.ok(input.startsWith(`${workspace}/`), 'scanner input must resolve inside its mounted workspace') + for (const file of ['.github/workflows/ci.yml', '.github/actions/setup/action.yml']) { + assert.equal(await readFile(join(input, file), 'utf8'), 'name: fixture\n') + } + await assert.rejects(readFile(join(input, '.github/workflows/removed.yml')), { code: 'ENOENT' }) + assert.equal(await readFile(output, 'utf8'), 'has_workflows=true\nhas_actions=true\n') +}) + +test('workflow policy skips audit inputs when all changed workflows were removed', async (t) => { + const { result, output } = await stageFiles(t, '.github/workflows/removed.yml\tremoved\n') + assert.equal(result.status, 0, result.stderr) + assert.equal(await readFile(output, 'utf8'), 'has_workflows=false\nhas_actions=false\n') +}) + +test('workflow policy rejects traversal in changed workflow paths', async (t) => { + const { result } = await stageFiles(t, '.github/workflows/../../escape.yml\tadded\n') + assert.notEqual(result.status, 0) + assert.match(result.stderr, /Unsafe workflow path/) +}) diff --git a/src/PublicApp.vue b/src/PublicApp.vue index 3e5b445..9c87950 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() @@ -502,7 +503,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 +870,7 @@ function upOneLevel() { - -
+
{{ t('proofing_gallery', 'Export my data') }}