From 0ca89a385b63e56201ef81f5148857cf63259e54 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 13 Oct 2026 16:13:18 -0500 Subject: [PATCH] feat: record attachments stored through qbit-object-storage collaboration_attachment rows per record, uploads streamed into the configured bucket with size limits and sha256, presigned downloads and FILE_PREVIEW previews, a streamed-ETL Delete attachments action, parent cleanup, the home card, and a parent-access stand-in for core's record-reference security (QRun-IO/qqq#1138) to be replaced when released. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 56 +- docs/DESIGN.md | 14 +- pom.xml | 5 + .../CollaborationQBitConfig.java | 90 +++ .../CollaborationQBitProducer.java | 2 + .../collaboration/CollaborationSchema.java | 6 +- .../collaboration/app/CollaborationHome.java | 28 +- .../attachments/AttachmentCleanup.java | 78 +++ .../attachments/AttachmentEntry.java | 32 ++ .../AttachmentPreviewCustomizer.java | 100 ++++ .../attachments/AttachmentRecords.java | 83 +++ .../attachments/AttachmentSecurity.java | 80 +++ .../attachments/AttachmentStorage.java | 81 +++ .../attachments/AttachmentsService.java | 386 +++++++++++++ .../attachments/DeleteAttachmentsProcess.java | 210 +++++++ .../model/CollaborationTables.java | 56 +- .../security/ParentRecordAccess.java | 106 ++++ .../collaboration/CollaborationTest.java | 4 +- .../attachments/AttachmentsServiceTest.java | 529 ++++++++++++++++++ 20 files changed, 1935 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentEntry.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentPreviewCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentStorage.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/attachments/DeleteAttachmentsProcess.java create mode 100644 src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java create mode 100644 src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index bc503b8..61af8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes are documented here, following [Keep a Changelog](https://ke ### Added +- Phase-c attachments against QQQ 4.1.0-RC.5 and object storage 0.1.0-RC.2: parent-checked upload/list/download/rename/describe/delete service, bounded streaming, SHA-256 and configurable byte/extension limits. +- Visible `collaboration_attachment` table, FIELD auditing, file-name/description search fields, virtual presigned FILE_PREVIEW/FILE_DOWNLOAD adornments, PostgreSQL BIGINT size and parent index, and an approximate count/bytes home card. +- Temporary `ParentRecordAccess`, readable-table query restriction through RC.5's permission hook and parent post-filtering. Panel calls do not require the admin browse grant; direct attachment table writes are service-managed. +- Streamed ETL **Delete attachments** Review/Result record action and retryable `AttachmentCleanup.onParentsDeleted` callback. No Attach files table action; endpoint integration awaits #1128 and cleanup registration awaits core. +- Memory-backed attachment/security/process tests. Thumbnails use FILE_PREVIEW; `thumbnailKey` remains nullable/unused because object storage has no generator. + - Repository scaffold; feature settings and people tables. - Phase-b favorites and recents on QQQ 4.1.0-RC.5: visible `collaboration_favorite` and `collaboration_recent` tables, target-type possible values, unique personal targets and audit disabled for high-volume data. - `CollaborationSecurity` owner locks based on the session user's ID reference, with admin all-access keys and automatic initialization before enforced table actions and in services/home. No collaboration permission is required to manage one's own rows. diff --git a/README.md b/README.md index 5c1d6a7..4daccb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QBit Collaboration -Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, and the Collaboration app home with real session-scoped counts. Phases c to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically. +Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phases d to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically. Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md). @@ -9,6 +9,7 @@ Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/ - Java 21. - QQQ 4.1.0-RC.5 or newer compatible 4.1 release. The build pins RC.5. - A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend. +- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets. - Later phases and runtime overrides need the core contracts from the QQQ collaboration PRs. Favorites and recents use only APIs released in RC.5. String enum ids match the spec until the core collaboration enums are released. ## Install @@ -47,20 +48,22 @@ Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation | `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. | | `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. | -All four include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold. +| `collaboration_attachment` (Attachments) | Parent table and record ID, file metadata, SHA-256, bucket/key, optional description/thumbnail key, author and parent-deleted flag. FIELD audit; parent-based security. Preview/download URLs are virtual, never stored. | -The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables. +All five include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold. + +The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, and attachment count and total bytes. Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables. ## Permissions | Permission | Behavior | |---|---| -| `collaboration.view` | Read Feature Settings, People, the app and home. Not required for personal favorites or recents. | -| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Does not permit direct People writes. | +| `collaboration.view` | Browse Attachments, read Feature Settings, People, the app and home. Not required for personal favorites or recents. | +| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments after the parent check. Does not permit direct People or attachment table writes. | | `collaboration.fields.manage` | Reserved constant for the later custom-field designer. | | `collaboration.tags.manage` | Reserved constant for later tag management. | -These use the reference qbit's custom `QPermissionRules` checker. Extra permissions are constants, not automatically registered catalog entries. Configure permission grants in your host. Home count actions explicitly enforce permissions and honor record security locks. Hosts should enable `qInstance.setEnforcePermissionsInCoreActions(true)` for direct model API calls as well as middleware checks. +These use the reference qbit's custom `QPermissionRules` checker. Extra permissions are constants, not automatically registered catalog entries. Configure permission grants in your host. Home count actions explicitly enforce permissions; attachment totals have the parent-lock approximation described below. Hosts should enable `qInstance.setEnforcePermissionsInCoreActions(true)` for direct model API calls as well as middleware checks. Personal tables use `PermissionLevel.NOT_PROTECTED` with a `READ_AND_WRITE` owner lock on `userId`. Every signed-in user can manage their own rows. `CollaborationSecurity` derives `collaboration_user` from `QSession.getUser().getIdReference()` and gives `collaboration_user_all_access` to admins. Its custom permission checker initializes keys before enforced table actions; services and the home also call `ensureSessionKeys`. Hosts may call `applyToSession(qInstance, session)` after authentication. Refreshing keys also removes revoked admin access. System sessions are untouched; create scheduler system sessions after the full instance is registered so QQQ includes the all-access key. @@ -92,12 +95,47 @@ List recents = RecentsService.list(50); The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released**. This PR adds no endpoint or unreleased core dependency. Runtime feature overrides and the record-feature summary SPI also await released core contracts; the home counts are implemented now. +## Attachments + +Call the service inside the caller's `QContext`; configure object storage before using it: + +```java +AttachmentsService attachments = new AttachmentsService(); +AttachmentEntry added = attachments.upload("order", "123", "invoice.pdf", "application/pdf", stream, "Signed invoice"); +List files = attachments.list("order", "123"); +URL download = attachments.downloadUrl(added.id(), false); +attachments.rename(added.id(), "invoice-final.pdf", null); // null leaves a field unchanged +attachments.describe(added.id(), ""); // empty clears the description +attachments.delete(List.of(added.id())); +``` + +Uploads check the parent and `attach`, count bytes and compute SHA-256 while streaming to a temporary file, then stream that file through object storage. This keeps memory bounded and rejects oversize files before a put. Temporary files are removed on success and failure; the caller owns the input stream. A failed row insert triggers object cleanup. Storage and SQL are not a distributed transaction: a cleanup failure is attached to the original error and requires operational reconciliation. Deletion removes the object first and retains the row on a storage failure for retry. + +File names are reduced to a safe basename (maximum 255 characters). Object keys are `attachments/{table}/{recordId}/{uuid}/{fileName}`; table/id segments are URL-encoded to protect string primary keys containing slashes. Renames change the display name and subsequent download filename, retaining the original storage key. `AttachmentEntry` returns display metadata, creation time and `canDelete` (author or admin); it omits bucket/key and signed URLs. Lists are newest first. Changing or deleting an own row also rechecks `attach`; admins may moderate another author's row but must still see its parent. A batch authorizes all selected rows before deleting its first object. + +**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `.attach` or table EDIT plus a visible parent. This is the explicitly temporary approximation of write-lock checks; distinct WRITE-only parent locks are not enforced until core's record-reference helper is released. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents. + +The admin Attachments table additionally requires `collaboration.view` (or `collaboration.admin`). The panel service bypasses that browse grant only after its own parent/feature/ownership checks. Direct table insert/update/delete is denied; use the service and record action so storage and rows remain coordinated. Storage operations use `ObjectStorage.asSystem("collaboration", runId).bucket(alias)` after these checks, preserving calling-qbit/initiating-user operation logging without requiring users to hold `object_storage.*` grants. + +RC.5 has no PRE_QUERY customizer. `AttachmentSecurity` uses its custom permission checker as the pre-query hook, adding `tableName IN (readable parent tables)` to queries, counts and aggregates. Enable core permission enforcement for direct model API use. A POST_QUERY customizer checks each row's parent and removes invisible rows before signing any URLs. This applies to direct get/query as well as browsing. **Counts and aggregate bytes are approximate, and pages may be short** because parent record locks are checked after querying; the home labels the approximation. RC.5 cannot publish an `approximate` flag or refill pages through this hook. Replace this stand-in with core's record-reference security when released. Per-table feature enablement/settings and permission catalog integration also await those contracts. + +**Preview and download.** The record view has `FILE_PREVIEW` and `FILE_DOWNLOAD` virtual fields, filled with five-minute presigned GETs only for visible rows (up to 200 rows per query). Downloads use RC.2's `PresignGet.downloadAs(fileName)`. RC.2 has no arbitrary response-header override for explicit inline disposition, so uploads store `Content-Disposition: inline`, and previews leave that header unchanged. URLs are never persisted. Images/PDF use the standard media preview; the host must allow its storage host in the frontend CSP (PDF frames need compatible `frame-src`). `thumbnailKey` is nullable and unused: object storage has no thumbnail generator, so thumbnails use FILE_PREVIEW rather than generated thumbnail files. + +**Actions and cleanup.** `Delete attachments` is a standard streamed ETL record action with Review/Result counts, skipped unauthorized rows, permission rechecks at execution and per-row failure summaries. It is not in navigation. Soft deletion follows the bucket's versioning/trash; without either, deletion is permanent. There is deliberately no **Attach files** table action: uploads come through the API/panel. The current browse action requires `collaboration.view`; the panel may call the service without that grant. + +After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. Wiring to core's `RecordReferenceCleanupInterface` waits for its release. UI API endpoints and the panel transport wait for [#1128](https://github.com/QRun-IO/qqq/pull/1128); direct/presigned upload endpoints, activity entries and realtime events remain future integrations. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider. + +Tests use real QQQ memory actions and record locks plus an in-memory `AttachmentStorage` adapter. Object storage RC.2 exposes no fake bucket; its provider conformance suite uses MinIO/Testcontainers. This suite requires no Docker and does not claim MinIO/S3 conformance. + ## Configuration | Property | Default | Meaning | |---|---|---| -| `backendName` | Required | Existing QQQ backend used by all four tables. | +| `backendName` | Required | Existing QQQ backend used by all five tables. | | `schedulerName` | None | Existing scheduler for the hidden daily recents trim process (86400-second interval and initial delay). Without it, the process is available for explicit admin/system execution. | +| `attachmentsBucketName` | `collaboration` | PRIVATE object-storage bucket alias for new uploads; existing rows retain their bucket alias. | +| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. | +| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. | | `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. | | `appName` | `collaboration` | Metadata name of the app. | | `parentAppName` | None | Optional existing parent, such as `admin`. | @@ -108,8 +146,8 @@ The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's U 1. Register the host backend and optional Admin app, then invoke the producer above. 2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes. 3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events. -4. Validate the assembled instance. Navigate to Collaboration for Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows. -5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites and recents services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in. +4. Validate the assembled instance. Navigate to Collaboration for Attachments, Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows. +5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents and attachments services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in. ## Build diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 56ecd2e..fcd8240 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)). -Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phases c to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. +Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phase-c attachments are implemented on object storage 0.1.0-RC.2; phases d to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality. | Phase | Core | Qbit | Next UI | |---|---|---|---| @@ -16,3 +16,15 @@ Collaboration adds metadata-driven record features to QQQ: attachments, tags, cu | h. Sharing | | link, shares with preview, access requests | share dialog, request-access page | | i. Tasks, owner/assignee | `CurrentUser` expression, ownership metadata, "My records" view | tasks, reminders, assignment table and virtual fields, assign actions, My work | Tasks tab, My work page, assign actions | + +## Phase-c implementation choices + +- `ParentRecordAccess` is the named replacement point for the unreleased core helper. GetAction enforces parent READ and locks. Attach is an explicit feature grant or EDIT plus visible parent; separate WRITE locks remain an approximation. +- Admin browsing requires collaboration.view, while panel service calls authorize the parent directly. Direct table writes are denied. Internal persistence uses explicit per-action permission bypasses after service checks, and per-input metadata clones without preview customizers. The caller's session is never elevated or replaced. Cloning also lets delete prefetch find rows after the parent is deleted, while preserving FIELD audits and record-change listeners. +- RC.5 offers no PRE_QUERY customizer. The attachment permission checker adds a readable-table IN filter before query/count/aggregate; POST_QUERY filters parent locks before presigning. Approximate counts/bytes and short pages are documented. Full mirrored locks, page refill and API approximate flags await core. +- Uploads spool to bounded temporary disk while hashing; no full-file heap buffer. The bucket cap also applies. Keys contain a UUID and safe basename; renames do not move bytes. Object/row writes are ordered with best-effort compensation, not a distributed transaction. +- The object-storage adapter uses the calling-qbit system facade only after authorization. Soft deletion follows bucket policy. Object retention failures leave retryable rows. In-memory adapter tests exercise QQQ actions and security; provider conformance stays with object storage's MinIO suite. +- Previews/downloads are virtual fields in the RECORD_VIEW alternative section (RC.5 disallows virtual fields in base sections). Five-minute URLs are never stored. RC.2 supports attachment filename overrides, but not explicit inline overrides; uploads carry an inline header. There is no thumbnail generator or generated thumbnail file. +- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup is a trusted public after-commit callback pending RecordReferenceCleanupInterface. Endpoints await #1128; runtime feature settings, activity and realtime publication are future integrations. Native search fields are present; ESB and Quick Search publication are host integrations. + +See [README](../README.md#attachments) for the service contract, permissions and configuration. diff --git a/pom.xml b/pom.xml index 0e88971..7cd418d 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,11 @@ + + com.kingsrook.qbits + qbit-object-storage + 0.1.0-RC.2 + com.kingsrook.qqq qqq-backend-core diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java index 29e6fae..d63345d 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitConfig.java @@ -42,6 +42,9 @@ public class CollaborationQBitConfig implements QBitConfig private String parentAppName; private Boolean adminApp = true; private Integer recentsPerUser = 200; + private String attachmentsBucketName = "collaboration"; + private Long defaultMaxBytes = 100L * 1024 * 1024; + private List blockedExtensions = List.of(); @@ -241,6 +244,9 @@ public class CollaborationQBitConfig implements QBitConfig { assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors); } + assertCondition(StringUtils.hasContent(attachmentsBucketName), "attachmentsBucketName is required", errors); + assertCondition(defaultMaxBytes != null && defaultMaxBytes > 0, "defaultMaxBytes must be positive", errors); + assertCondition(blockedExtensions != null && blockedExtensions.stream().allMatch(value -> value != null && !value.isBlank()), "blockedExtensions must contain nonblank extensions", errors); assertCondition(recentsPerUser != null && recentsPerUser > 0, "recentsPerUser must be positive", errors); if(schedulerName != null) { @@ -266,4 +272,88 @@ public class CollaborationQBitConfig implements QBitConfig { return (backendName); } + + + /******************************************************************************* + ** Getter for attachmentsBucketName. + *******************************************************************************/ + public String getAttachmentsBucketName() + { + return (attachmentsBucketName); + } + + + /******************************************************************************* + ** Setter for attachmentsBucketName. + *******************************************************************************/ + public void setAttachmentsBucketName(String attachmentsBucketName) + { + this.attachmentsBucketName = attachmentsBucketName; + } + + + /******************************************************************************* + ** Fluent setter for attachmentsBucketName. + *******************************************************************************/ + public CollaborationQBitConfig withAttachmentsBucketName(String attachmentsBucketName) + { + setAttachmentsBucketName(attachmentsBucketName); + return (this); + } + + + /******************************************************************************* + ** Getter for defaultMaxBytes. + *******************************************************************************/ + public Long getDefaultMaxBytes() + { + return (defaultMaxBytes); + } + + + /******************************************************************************* + ** Setter for defaultMaxBytes. + *******************************************************************************/ + public void setDefaultMaxBytes(Long defaultMaxBytes) + { + this.defaultMaxBytes = defaultMaxBytes; + } + + + /******************************************************************************* + ** Fluent setter for defaultMaxBytes. + *******************************************************************************/ + public CollaborationQBitConfig withDefaultMaxBytes(Long defaultMaxBytes) + { + setDefaultMaxBytes(defaultMaxBytes); + return (this); + } + + + /******************************************************************************* + ** Getter for blockedExtensions. + *******************************************************************************/ + public List getBlockedExtensions() + { + return (blockedExtensions); + } + + + /******************************************************************************* + ** Setter for blockedExtensions. + *******************************************************************************/ + public void setBlockedExtensions(List blockedExtensions) + { + this.blockedExtensions = blockedExtensions; + } + + + /******************************************************************************* + ** Fluent setter for blockedExtensions. + *******************************************************************************/ + public CollaborationQBitConfig withBlockedExtensions(List blockedExtensions) + { + setBlockedExtensions(blockedExtensions); + return (this); + } } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java index 2c7b4f3..133d374 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/CollaborationQBitProducer.java @@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration; import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess; import com.kingsrook.qbits.collaboration.model.CollaborationTables; import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -127,6 +128,7 @@ public class CollaborationQBitProducer implements QBitMetaDataProducerof() : table.getUniqueKeys()) { index++; List names = key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList(); @@ -69,6 +69,7 @@ public final class CollaborationSchema + (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : "")); } } + statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)"); return (String.join(";\n", statements) + ";\n"); } @@ -84,6 +85,7 @@ public final class CollaborationSchema case STRING -> "VARCHAR(" + (field.getMaxLength() == null ? 255 : field.getMaxLength()) + ")"; case TEXT -> "TEXT"; case INTEGER -> "INTEGER"; + case LONG -> "BIGINT"; case BOOLEAN -> "BOOLEAN"; case DATE_TIME -> "TIMESTAMPTZ"; case DATE -> "DATE"; diff --git a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java index 8286218..1ce8dac 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/app/CollaborationHome.java @@ -82,7 +82,7 @@ public final class CollaborationHome .withPermissionRules(CollaborationPermissions.permissionRules()) .withWidgets(List.of(HOME_WIDGET)) .withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum")) - .withTables(List.of(CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); + .withTables(List.of(CollaborationTables.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER))); app.setParentAppName(config.getParentAppName()); return (app); } @@ -139,11 +139,37 @@ public final class CollaborationHome html.append(""); personalSummary(html, CollaborationTables.FAVORITE, "Favorites"); personalSummary(html, CollaborationTables.RECENT, "Recently viewed"); + attachmentSummary(html); return (html.append("").toString()); } + /******************************************************************************* + ** Real table aggregates, restricted to readable parent tables. Parent locks + ** are post-filtered on rows, so these aggregate totals are approximate. + *******************************************************************************/ + private static void attachmentSummary(StringBuilder html) throws QException + { + Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT); + Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM); + AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes)); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + List results = new AggregateAction().execute(input).getResults(); + Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count); + Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes); + html.append("

Attachments

Attachments: ").append(rowCount) + .append("

Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes) + .append("

Approximate totals: parent record locks are applied when browsing attachments.

"); + if("0".equals(String.valueOf(rowCount))) + { + html.append("

No attachments yet.

"); + } + html.append("
"); + } + + + /******************************************************************************* ** Aggregate rows and distinct owners without loading high-volume personal data. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java new file mode 100644 index 0000000..8d344a6 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentCleanup.java @@ -0,0 +1,78 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.io.Serializable; +import java.util.List; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Trusted after-commit callback. Wire to core RecordReferenceCleanupInterface + ** when released. Never expose as a user endpoint: the parent is already gone. + *******************************************************************************/ +public class AttachmentCleanup +{ + private final AttachmentsService service; + + /******************************************************************************* + ** Production cleanup using the installed qbit and object storage. + *******************************************************************************/ + public AttachmentCleanup() + { + this(new AttachmentsService()); + } + + /******************************************************************************* + ** Use the same storage boundary as normal deletion. + *******************************************************************************/ + public AttachmentCleanup(AttachmentsService service) + { + this.service = service; + } + + /******************************************************************************* + ** Repeated calls are safe; provider failures leave the row for a later retry. + *******************************************************************************/ + public void onParentsDeleted(String table, List ids) throws QException + { + if(ids.isEmpty()) + { + return; + } + while(true) + { + List rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table) + .withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200)); + if(rows.isEmpty()) + { + return; + } + for(QRecord row : rows) + { + service.deleteStoredRow(row); + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentEntry.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentEntry.java new file mode 100644 index 0000000..236938a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentEntry.java @@ -0,0 +1,32 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.time.Instant; + +/******************************************************************************* + ** Panel DTO. Storage locations and ephemeral signed URLs stay server-side. + *******************************************************************************/ +public record AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType, + Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete) +{ +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentPreviewCustomizer.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentPreviewCustomizer.java new file mode 100644 index 0000000..f08972d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentPreviewCustomizer.java @@ -0,0 +1,100 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Filter invisible parents before generating ephemeral preview/download URLs. + ** Counts are approximate and pages can be short until core mirrors locks. + *******************************************************************************/ +public class AttachmentPreviewCustomizer implements TableCustomizerInterface +{ + private static final QLogger LOG = QLogger.getLogger(AttachmentPreviewCustomizer.class); + + /******************************************************************************* + ** GetAction expects one result: throw Not found rather than return an empty list. + *******************************************************************************/ + @Override + public List postQuery(QueryOrGetInputInterface input, List records) throws QException + { + return (filter(records, input instanceof GetInput, new AttachmentsService())); + } + + /******************************************************************************* + ** Keep filtering every row even when the preview generation cap is reached. + *******************************************************************************/ + List filter(List records, boolean isGet, AttachmentsService service) throws QException + { + List visible = new ArrayList<>(); + for(QRecord record : records) + { + try + { + if(Boolean.TRUE.equals(record.getValueBoolean("parentDeleted"))) + { + throw (new QNotFoundException("Not found")); + } + ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId")); + } + catch(QNotFoundException expected) + { + if(isGet) + { + throw (expected); + } + continue; + } + // Never retain URLs from a previous read of a memory-backend record. + QRecord row = new QRecord(record); + row.setValue("previewUrl", null); + row.setValue("downloadUrl", null); + row.withDisplayValue("downloadUrl", null).withDisplayValue("downloadUrl:downloadUrlDynamic", null); + visible.add(row); + if(visible.size() <= 200) + { + try + { + row.setValue("previewUrl", service.url(row, true).toString()); + String downloadUrl = service.url(row, false).toString(); + row.setValue("downloadUrl", downloadUrl); + row.withDisplayValue("downloadUrl", row.getValueString("fileName")) + .withDisplayValue("downloadUrl:downloadUrlDynamic", downloadUrl); + } + catch(RuntimeException e) + { + LOG.debug("Could not make an attachment preview URL", e); + } + } + } + return (visible); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java new file mode 100644 index 0000000..92075b9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentRecords.java @@ -0,0 +1,83 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.util.List; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + +/******************************************************************************* + ** Package-private raw reads for services and trusted parent cleanup. Callers + ** must check the parent before exposing any values or performing user writes. + *******************************************************************************/ +final class AttachmentRecords +{ + /******************************************************************************* + ** Static persistence helpers only. + *******************************************************************************/ + private AttachmentRecords() + { + } + + /******************************************************************************* + ** Use a per-input clone so shared metadata and session remain unchanged. + *******************************************************************************/ + static List query(QQueryFilter filter) throws QException + { + QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withFilter(filter).withShouldFetchHeavyFields(true); + input.setTableMetaData(storageTable()); + input.withPermissionBypass("Attachment service checks parent access; cleanup is a trusted after-delete callback."); + return (new QueryAction().execute(input).getRecords()); + } + + /******************************************************************************* + ** Internal persistence must not generate URLs or filter a deleted parent. + ** Clone only this action's metadata; retain auditing and backend behavior. + *******************************************************************************/ + static QTableMetaData storageTable() + { + return (QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT).clone().withCustomizers(null)); + } + + /******************************************************************************* + ** QQQ validation errors may be returned on rows rather than thrown. + *******************************************************************************/ + static void checkErrors(List records) throws QException + { + if(records != null) + { + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + throw (new QException("Could not save attachment: " + record.getErrors())); + } + } + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java new file mode 100644 index 0000000..22f128d --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentSecurity.java @@ -0,0 +1,80 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.util.List; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrCountInputInterface; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules; + +/******************************************************************************* + ** RC.5 has no PRE_QUERY customizer. Its permission checker provides the + ** pre-query restriction for query, count and aggregate; post-query checks + ** handle parent record locks. Direct writes must use the attachment service. + *******************************************************************************/ +public class AttachmentSecurity implements CustomPermissionChecker +{ + /******************************************************************************* + ** Browse requires collaboration.view; writes cannot bypass storage lifecycle. + *******************************************************************************/ + @Override + public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException + { + if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput) + { + throw (new QPermissionDeniedException("Use the attachment service or Delete attachments action.")); + } + new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata); + if(input instanceof QueryOrCountInputInterface query) + { + restrict(query); + } + } + + /******************************************************************************* + ** AND the restriction outside any user OR clauses; preserve paging and sort. + ** Counts and aggregates are approximate because parent locks are post-filtered. + *******************************************************************************/ + public static void restrict(QueryOrCountInputInterface input) + { + List readable = QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList(); + QQueryFilter original = input.getFilter(); + QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.IN, readable) + .withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false); + if(original != null) + { + filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip()); + filter.setOrderBys(original.getOrderBys()); + } + input.setFilter(filter); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentStorage.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentStorage.java new file mode 100644 index 0000000..d99acbb --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentStorage.java @@ -0,0 +1,81 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.net.URL; +import java.util.UUID; +import com.kingsrook.qbits.objectstorage.api.DeleteMode; +import com.kingsrook.qbits.objectstorage.api.DeleteRequest; +import com.kingsrook.qbits.objectstorage.api.ObjectStorage; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qbits.objectstorage.api.StorageBucket; + +/******************************************************************************* + ** Narrow storage boundary. The caller must check collaboration access first. + ** Tests can substitute an in-memory implementation without an S3 service. + *******************************************************************************/ +public class AttachmentStorage +{ + /******************************************************************************* + ** Tag storage operations as the calling qbit, retaining the initiating user. + *******************************************************************************/ + private StorageBucket bucket(String name) + { + return (ObjectStorage.asSystem("collaboration", UUID.randomUUID().toString()).bucket(name)); + } + + /******************************************************************************* + ** Enforce the bucket's cap as well as collaboration's configured cap. + *******************************************************************************/ + public long maxBytes(String name) + { + return (bucket(name).config().getMaxUploadBytes()); + } + + /******************************************************************************* + ** Put a bounded stream through object storage's multipart implementation. + *******************************************************************************/ + public void put(String name, PutRequest request) + { + bucket(name).put(request); + } + + /******************************************************************************* + ** Prefer soft deletion; allow permanent deletion only where no soft mode exists. + *******************************************************************************/ + public void delete(String name, String key) + { + if(bucket(name).delete(DeleteRequest.of(key).withAllowPermanentWhenNoSoftDelete(true)).getMode() == DeleteMode.SKIPPED_OBJECT_LOCK) + { + throw (new IllegalStateException("Object retention prevents attachment deletion.")); + } + } + + /******************************************************************************* + ** Create a temporary link, never a persistent table value. + *******************************************************************************/ + public URL presign(String name, PresignGet request) + { + return (bucket(name).presignGet(request)); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java new file mode 100644 index 0000000..89ceb06 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsService.java @@ -0,0 +1,386 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Serializable; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** Attachment operations with parent access checked using the caller's session. + *******************************************************************************/ +public class AttachmentsService +{ + private final CollaborationQBitConfig config; + private final AttachmentStorage storage; + + /******************************************************************************* + ** Resolve the installed configuration without a global mutable singleton. + *******************************************************************************/ + public AttachmentsService() + { + this((CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream() + .filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig(), new AttachmentStorage()); + } + + /******************************************************************************* + ** Injectable storage boundary for deterministic service tests. + *******************************************************************************/ + public AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage) + { + this.config = config; + this.storage = storage; + } + + /******************************************************************************* + ** Stream to a bounded temporary file while hashing, then put and insert. + ** The caller owns the input stream. Oversize files never reach object storage. + *******************************************************************************/ + public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException + { + ParentRecordAccess.requireFeature(tableName, recordId, "attach"); + String user = requireUser(); + String id = canonicalId(tableName, recordId); + String name = sanitize(fileName); + String bucket = config.getAttachmentsBucketName(); + String key = "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name; + if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024 + || (contentType != null && contentType.length() > 255)) + { + throw (new QException("Attachment metadata exceeds the supported field lengths.")); + } + long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket)); + Path temporary = null; + try + { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + temporary = Files.createTempFile("collaboration-attachment-", ".upload"); + long size = 0; + try(OutputStream out = Files.newOutputStream(temporary)) + { + byte[] buffer = new byte[8192]; + int read; + while((read = stream.read(buffer)) != -1) + { + if(read > limit - size) + { + throw (new QException("Attachment exceeds the size limit of " + limit + " bytes.")); + } + size += read; + digest.update(buffer, 0, read); + out.write(buffer, 0, read); + } + } + String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType; + try(InputStream upload = Files.newInputStream(temporary)) + { + storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline")); + } + try + { + QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name) + .withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest())) + .withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description) + .withValue("createdByUserId", user).withValue("parentDeleted", false); + InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withRecords(List.of(row)); + input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload."); + List records = new InsertAction().execute(input).getRecords(); + AttachmentRecords.checkErrors(records); + return (entry(records.getFirst())); + } + catch(QException | RuntimeException failure) + { + try + { + storage.delete(bucket, key); + } + catch(RuntimeException cleanupFailure) + { + failure.addSuppressed(cleanupFailure); + } + throw (failure); + } + } + catch(IOException | NoSuchAlgorithmException e) + { + throw (new QException("Could not read attachment upload", e)); + } + finally + { + if(temporary != null) + { + try + { + Files.deleteIfExists(temporary); + } + catch(IOException e) + { + temporary.toFile().deleteOnExit(); + } + } + } + } + + /******************************************************************************* + ** Newest first, independently of collaboration.view. + *******************************************************************************/ + public List list(String tableName, Serializable recordId) throws QException + { + String id = canonicalId(tableName, recordId); + List entries = new ArrayList<>(); + for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName) + .withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true) + .withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)))) + { + entries.add(entry(row)); + } + return (entries); + } + + /******************************************************************************* + ** Re-check the attachment's actual parent before signing a five-minute GET. + ** Inline uses the stored inline header; RC.2 only overrides attachment names. + *******************************************************************************/ + public URL downloadUrl(Serializable attachmentId, boolean inline) throws QException + { + return (url(requireRow(attachmentId), inline)); + } + + /******************************************************************************* + ** Sign an already-authorized row, also used by the preview customizer. + *******************************************************************************/ + URL url(QRecord row, boolean inline) + { + PresignGet request = PresignGet.of(row.getValueString("storageKey")).ttl(Duration.ofMinutes(5)); + if(!inline) + { + request.downloadAs(row.getValueString("fileName")); + } + return (storage.presign(row.getValueString("bucketName"), request)); + } + + /******************************************************************************* + ** Authorize all selected rows before the first storage mutation. + *******************************************************************************/ + public void delete(List attachmentIds) throws QException + { + List rows = new ArrayList<>(); + for(Serializable id : attachmentIds.stream().distinct().toList()) + { + rows.add(requireMutable(id)); + } + for(QRecord row : rows) + { + deleteStoredRow(row); + } + } + + /******************************************************************************* + ** Change display metadata only; the immutable storage key stays stable. + ** Null means unchanged; an empty description clears it. + *******************************************************************************/ + public AttachmentEntry rename(Serializable attachmentId, String fileName, String description) throws QException + { + QRecord row = requireMutable(attachmentId); + QRecord change = new QRecord().withValue("id", row.getValue("id")); + if(fileName != null) + { + change.setValue("fileName", sanitize(fileName)); + } + if(description != null) + { + change.setValue("description", description); + } + UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withRecords(List.of(change)); + input.setTableMetaData(AttachmentRecords.storageTable()); + input.withPermissionBypass("AttachmentsService checked the actual parent and author/admin before metadata update."); + AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords()); + return (entry(requireRow(attachmentId))); + } + + /******************************************************************************* + ** Description-only convenience operation. + *******************************************************************************/ + public AttachmentEntry describe(Serializable attachmentId, String description) throws QException + { + return (rename(attachmentId, null, description)); + } + + /******************************************************************************* + ** Process Review uses persisted identity rather than trusting client rows. + *******************************************************************************/ + public QRecord requireMutable(Serializable attachmentId) throws QException + { + QRecord row = requireRow(attachmentId); + requireUser(); + if(!canDelete(row)) + { + throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment.")); + } + if(!isAdmin()) + { + ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach"); + } + return (row); + } + + /******************************************************************************* + ** Delete storage first so a provider failure leaves a retryable row. + *******************************************************************************/ + void deleteStoredRow(QRecord row) throws QException + { + storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey")); + DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id"))); + input.setTableMetaData(AttachmentRecords.storageTable()); + input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row."); + AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors()); + } + + /******************************************************************************* + ** Missing rows and invisible parents have the same response. + *******************************************************************************/ + private QRecord requireRow(Serializable id) throws QException + { + List rows = AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)); + if(rows.isEmpty() || Boolean.TRUE.equals(rows.getFirst().getValueBoolean("parentDeleted"))) + { + throw (new QNotFoundException("Not found")); + } + QRecord row = rows.getFirst(); + ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId")); + return (row); + } + + /******************************************************************************* + ** Use the parent's actual primary key so alternate numeric strings converge. + *******************************************************************************/ + private String canonicalId(String table, Serializable id) throws QException + { + return (ParentRecordAccess.requireVisibleParent(table, id).getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField())); + } + + /******************************************************************************* + ** Ownership is layered on parent visibility; admins still need that visibility. + *******************************************************************************/ + private boolean canDelete(QRecord row) + { + String user = CollaborationSecurity.userId(QContext.getQSession()); + return (user != null && (isAdmin() || user.equals(row.getValueString("createdByUserId")))); + } + + /******************************************************************************* + ** Collaboration moderation grant. + *******************************************************************************/ + private boolean isAdmin() + { + return (QContext.getQSession() != null && QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN)); + } + + /******************************************************************************* + ** Stable author identity is mandatory for writes. + *******************************************************************************/ + private String requireUser() throws QPermissionDeniedException + { + String user = CollaborationSecurity.userId(QContext.getQSession()); + if(user == null || user.isBlank()) + { + throw (new QPermissionDeniedException("Sign in to manage attachments.")); + } + return (user); + } + + /******************************************************************************* + ** Return display data, without internal storage coordinates. + *******************************************************************************/ + private AttachmentEntry entry(QRecord row) + { + return (new AttachmentEntry(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"), + row.getValueString("fileName"), row.getValueString("contentType"), row.getValueLong("sizeBytes"), row.getValueString("sha256"), + row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canDelete(row))); + } + + /******************************************************************************* + ** Strip paths and unsafe header/key characters; enforce extension policy. + *******************************************************************************/ + private String sanitize(String fileName) throws QException + { + if(fileName == null || fileName.isBlank()) + { + throw (new QException("A file name is required.")); + } + String name = fileName.replace('\\', '/'); + name = name.substring(name.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._ -]", "_").strip(); + if(name.isBlank() || name.equals(".") || name.equals("..") || name.length() > 255) + { + throw (new QException("Invalid file name.")); + } + String extension = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); + if(config.getBlockedExtensions().stream().anyMatch(blocked -> extension.equals(blocked.replaceFirst("^\\.", "").toLowerCase(Locale.ROOT)))) + { + throw (new QException("This file extension is blocked.")); + } + return (name); + } + + /******************************************************************************* + ** Keep arbitrary string primary keys inside one object-key segment. + *******************************************************************************/ + private String segment(String value) + { + return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20").replace(".", "%2E")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/attachments/DeleteAttachmentsProcess.java b/src/main/java/com/kingsrook/qbits/collaboration/attachments/DeleteAttachmentsProcess.java new file mode 100644 index 0000000..118868a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/attachments/DeleteAttachmentsProcess.java @@ -0,0 +1,210 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + +/******************************************************************************* + ** Record action with the standard streamed ETL Review and Result screens. + *******************************************************************************/ +public final class DeleteAttachmentsProcess +{ + public static final String NAME = "collaborationDeleteAttachments"; + + /******************************************************************************* + ** Metadata factory only. + *******************************************************************************/ + private DeleteAttachmentsProcess() + { + } + + /******************************************************************************* + ** Register on the table, never in navigation. + *******************************************************************************/ + public static QProcessMetaData metadata() + { + Map values = new HashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.ATTACHMENT); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.ATTACHMENT); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, + "Delete the selected attachments. Storage uses versioning or trash where configured; otherwise deletion is permanent."); + return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Delete.class, values) + .withName(NAME).withLabel("Delete attachments").withIcon(new QIcon("delete")) + .withTableName(CollaborationTables.ATTACHMENT).withPermissionRules(CollaborationPermissions.permissionRules())); + } + + /******************************************************************************* + ** Future and past wording for the standard summary renderer. + *******************************************************************************/ + private static ProcessSummaryLine line(Status status, String future, String past) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage("attachment " + future); + line.setPluralFutureMessage("attachments " + future); + line.setSingularPastMessage("attachment " + past); + line.setPluralPastMessage("attachments " + past); + return (line); + } + + /******************************************************************************* + ** Review re-reads persisted rows and skips unauthorized selections. + *******************************************************************************/ + public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final AttachmentsService service; + private final ProcessSummaryLine selected = line(Status.OK, "will be deleted", "deleted"); + private final ProcessSummaryLine skipped = line(Status.WARNING, "cannot be deleted (skipped)", "skipped"); + + /******************************************************************************* + ** QQQ constructor. + *******************************************************************************/ + public Review() + { + this(new AttachmentsService()); + } + + /******************************************************************************* + ** Injectable service for process tests. + *******************************************************************************/ + public Review(AttachmentsService service) + { + this.service = service; + } + + /******************************************************************************* + ** Dry run only; no object or row mutations. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord row : input.getRecords()) + { + try + { + QRecord checked = service.requireMutable(row.getValue("id")); + output.getRecords().add(checked); + selected.incrementCountAndAddPrimaryKey(checked.getValue("id")); + } + catch(QException expected) + { + skipped.incrementCountAndAddPrimaryKey(row.getValue("id")); + } + } + } + + /******************************************************************************* + ** Review summary includes authorized and skipped counts. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + selected.addSelfToListIfAnyCount(lines); + skipped.addSelfToListIfAnyCount(lines); + return (lines); + } + } + + /******************************************************************************* + ** Execute and re-authorize each row; one failure does not stop the batch. + *******************************************************************************/ + public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final AttachmentsService service; + private final ProcessSummaryLine deleted = line(Status.OK, "will be deleted", "deleted"); + private final ProcessSummaryLine failed = line(Status.ERROR, "could not be deleted", "could not be deleted (retry required)"); + + /******************************************************************************* + ** QQQ constructor. + *******************************************************************************/ + public Delete() + { + this(new AttachmentsService()); + } + + /******************************************************************************* + ** Injectable service for process tests. + *******************************************************************************/ + public Delete(AttachmentsService service) + { + this.service = service; + } + + /******************************************************************************* + ** Count success only after both storage and row deletion succeed. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord row : input.getRecords()) + { + try + { + service.delete(List.of(row.getValue("id"))); + deleted.incrementCountAndAddPrimaryKey(row.getValue("id")); + } + catch(QException | RuntimeException expected) + { + failed.incrementCountAndAddPrimaryKey(row.getValue("id")); + } + } + } + + /******************************************************************************* + ** Actual results, plus the Review's skipped rows. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + deleted.addSelfToListIfAnyCount(lines); + if(getTransformStep() instanceof Review review) + { + review.skipped.addSelfToListIfAnyCount(lines); + } + failed.addSelfToListIfAnyCount(lines); + return (lines); + } + } +} diff --git a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java index 2819889..0142bac 100644 --- a/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java +++ b/src/main/java/com/kingsrook/qbits/collaboration/model/CollaborationTables.java @@ -26,13 +26,19 @@ import java.util.ArrayList; import java.util.List; import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; import com.kingsrook.qbits.collaboration.CollaborationSecurity; +import com.kingsrook.qbits.collaboration.attachments.AttachmentPreviewCustomizer; +import com.kingsrook.qbits.collaboration.attachments.AttachmentSecurity; import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher; import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel; import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; @@ -40,6 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleVal import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource; import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; @@ -51,6 +58,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; *******************************************************************************/ public final class CollaborationTables { + public static final String ATTACHMENT = "collaboration_attachment"; public static final String FEATURE_SETTING = "collaboration_feature_setting"; public static final String FAVORITE = "collaboration_favorite"; public static final String RECENT = "collaboration_recent"; @@ -78,7 +86,7 @@ public final class CollaborationTables { return (List.of( enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"), - QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT), + QPossibleValueSource.newForTable(ATTACHMENT), QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT), enumSource(SCOPE_TYPE, "INSTANCE", "APP", "TABLE"), enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING", "FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"), @@ -144,7 +152,7 @@ public final class CollaborationTables .withField(new QFieldMetaData("lastViewedAt", QFieldType.DATE_TIME).withLabel("Last Viewed At").withIsRequired(true)) .withField(new QFieldMetaData("viewCount", QFieldType.INTEGER).withLabel("View Count").withIsRequired(true).withDefaultValue(1)) .withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details")); - List tables = List.of(settings, people, favorites, recents); + List tables = List.of(settings, people, favorites, recents, attachment(config)); for(QTableMetaData table : tables) { table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates")); @@ -200,4 +208,48 @@ public final class CollaborationTables { return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withMaxLength(length)); } + + /******************************************************************************* + ** Main attachment table; virtual URLs never have backend columns. + *******************************************************************************/ + private static QTableMetaData attachment(CollaborationQBitConfig config) + { + return (base(config, ATTACHMENT, "Attachments", "attach_file") + .withPermissionRules(CollaborationPermissions.permissionRules() + .withCustomPermissionChecker(new QCodeReference(AttachmentSecurity.class))) + .withRecordLabelFormatAndFields("%s", "fileName") + .withSearchFields(List.of("fileName", "description")) + .withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD)) + .withField(string("tableName", "Table", 128).withIsRequired(true)) + .withField(string("recordId", "Record ID", 64).withIsRequired(true)) + .withField(string("fileName", "File Name", 255).withIsRequired(true)) + .withField(string("contentType", "Content Type", 255).withIsRequired(true)) + .withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsRequired(true)) + .withField(string("sha256", "SHA-256", 64).withIsRequired(true)) + .withField(string("bucketName", "Bucket", 255).withIsRequired(true)) + .withField(string("storageKey", "Storage Key", 1024).withIsRequired(true)) + .withField(string("thumbnailKey", "Thumbnail Key", 1024)) + .withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description")) + .withField(string("createdByUserId", "Created By", 255).withIsRequired(true)) + .withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false)) + .withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true) + .withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment() + .withValue(AdornmentType.FilePreviewValues.FILE_NAME_FIELD, "fileName") + .withValue(AdornmentType.FilePreviewValues.MIME_TYPE_FIELD, "contentType") + .withValue(AdornmentType.FilePreviewValues.SIZE_BYTES_FIELD, "sizeBytes") + .withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48))) + .withVirtualField(new QVirtualFieldMetaData("downloadUrl", QFieldType.STRING).withLabel("Download").withIsEditable(false).withIsQuerySelectable(true) + .withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD) + .withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "fileName") + .withValue(AdornmentType.FileDownloadValues.DOWNLOAD_URL_DYNAMIC, true))) + .withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(AttachmentPreviewCustomizer.class)) + .withSection(new QFieldSection("file", new QIcon("attach_file"), Tier.T1, + List.of("fileName", "contentType", "sizeBytes", "description")).withLabel("File") + .withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, section -> section.setFieldNames(List.of("previewUrl", "downloadUrl", "fileName", "contentType", "sizeBytes", "description")))) + .withSection(new QFieldSection("parent", new QIcon("link"), Tier.T2, + List.of("tableName", "recordId", "createdByUserId", "parentDeleted")).withLabel("Parent")) + .withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T3, + List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256")).withLabel("Storage"))); + } + } diff --git a/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java new file mode 100644 index 0000000..7c1dfe3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/collaboration/security/ParentRecordAccess.java @@ -0,0 +1,106 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.security; + +import java.io.Serializable; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.exceptions.QValueException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + +/******************************************************************************* + ** Stand-in for QQQ core RecordReferenceSecurityHelper (QRun-IO/qqq collaboration phase a); replace when released + *******************************************************************************/ +public final class ParentRecordAccess +{ + /******************************************************************************* + ** Static operations only. + *******************************************************************************/ + private ParentRecordAccess() + { + } + + /******************************************************************************* + ** Exclude collaboration tables to avoid recursive references. + *******************************************************************************/ + public static boolean canReadTable(String tableName) + { + QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName); + return (table != null && table.getPrimaryKeyField() != null && !tableName.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX) + && PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.READ)); + } + + /******************************************************************************* + ** Get as the caller, enforcing table permissions and the parent's read locks. + *******************************************************************************/ + public static QRecord requireVisibleParent(String tableName, Serializable recordId) throws QException + { + if(!canReadTable(tableName) || recordId == null) + { + throw (new QNotFoundException("Not found")); + } + QTableMetaData table = QContext.getQInstance().getTable(tableName); + try + { + Serializable key = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), recordId); + GetInput input = new GetInput(tableName).withPrimaryKey(key); + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + QRecord parent = new GetAction().execute(input).getRecord(); + if(parent == null) + { + throw (new QNotFoundException("Not found")); + } + return (parent); + } + catch(QPermissionDeniedException | QValueException expected) + { + throw (new QNotFoundException("Not found")); + } + } + + /******************************************************************************* + ** Temporary approximation: EDIT plus a visible parent, not WRITE lock checks. + *******************************************************************************/ + public static void requireFeature(String tableName, Serializable recordId, String feature) throws QException + { + requireVisibleParent(tableName, recordId); + QTableMetaData table = QContext.getQInstance().getTable(tableName); + String base = table.getPermissionRules() == null ? null : table.getPermissionRules().getPermissionBaseName(); + base = base == null || base.isBlank() ? tableName : base; + if(!QContext.getQSession().hasPermission(base + "." + feature) + && !PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.EDIT)) + { + throw (new QPermissionDeniedException("This needs the " + base + "." + feature + " permission or table EDIT.")); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java index 0c2cd6a..5831f89 100644 --- a/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java +++ b/src/test/java/com/kingsrook/qbits/collaboration/CollaborationTest.java @@ -114,7 +114,7 @@ class CollaborationTest CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE); assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12); assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()) - .containsExactlyInAnyOrder(CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); + .containsExactlyInAnyOrder(CollaborationTables.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER); assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET); assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull(); assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD); @@ -177,7 +177,7 @@ class CollaborationTest QInstance headless = new QInstance(); headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class)); new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless); - assertThat(headless.getTables()).hasSize(4); + assertThat(headless.getTables()).hasSize(5); assertThat(headless.getApps()).isEmpty(); assertThat(headless.getWidgets()).isEmpty(); } diff --git a/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java new file mode 100644 index 0000000..a10d60a --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/collaboration/attachments/AttachmentsServiceTest.java @@ -0,0 +1,529 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package com.kingsrook.qbits.collaboration.attachments; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.collaboration.CollaborationQBitConfig; +import com.kingsrook.qbits.collaboration.CollaborationSchema; +import com.kingsrook.qbits.collaboration.PersonalServiceFixture; +import com.kingsrook.qbits.collaboration.app.CollaborationHome; +import com.kingsrook.qbits.collaboration.model.CollaborationTables; +import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions; +import com.kingsrook.qbits.collaboration.security.ParentRecordAccess; +import com.kingsrook.qbits.objectstorage.api.PresignGet; +import com.kingsrook.qbits.objectstorage.api.PutRequest; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/******************************************************************************* + ** Real QQQ memory actions and parent locks, with an in-memory storage boundary. + *******************************************************************************/ +public class AttachmentsServiceTest extends PersonalServiceFixture +{ + private static AttachmentsService processService; + private FakeStorage storage; + private AttachmentsService service; + + /******************************************************************************* + ** No Docker, network, object-storage grants or collaboration.view needed. + *******************************************************************************/ + @BeforeEach + void attachments() + { + storage = new FakeStorage(); + service = new AttachmentsService(config, storage); + } + + /******************************************************************************* + ** Upload, metadata, newest-first list, signed download and deletion round trip. + *******************************************************************************/ + @Test + void roundTrip() throws Exception + { + AttachmentEntry first = upload("../hello.txt", "hello"); + AttachmentEntry second = upload("C:\\fakepath\\other.txt", "other"); + assertThat(first.fileName()).isEqualTo("hello.txt"); + assertThat(first.sizeBytes()).isEqualTo(5L); + assertThat(first.sha256()).isEqualTo("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + assertThat(first.createdByUserId()).isEqualTo("alice"); + assertThat(first.canDelete()).isTrue(); + assertThat(service.list("document", "01")).extracting(AttachmentEntry::id).containsExactly(second.id(), first.id()); + assertThat(storage.objects.values()).anySatisfy(bytes -> assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("hello")); + assertThat(storage.lastPut.getContentType()).isEqualTo("text/plain"); + assertThat(storage.lastPut.getContentDisposition()).isEqualTo("inline"); + assertThat(storage.objects.keySet()).allMatch(key -> key.startsWith("collaboration/attachments/document/1/")); + assertThat(service.downloadUrl(first.id(), false).toString()).contains("signed.example"); + assertThat(storage.lastGet.getTtl()).isEqualTo(Duration.ofMinutes(5)); + assertThat(storage.lastGet.getDownloadAs()).isEqualTo("hello.txt"); + service.downloadUrl(first.id(), true); + assertThat(storage.lastGet.getDownloadAs()).isNull(); + assertThat(raw(first.id()).getValue("previewUrl")).isNull(); + service.delete(List.of(first.id(), first.id())); + assertThat(storage.objects).hasSize(1); + assertThat(service.list("document", 1)).extracting(AttachmentEntry::id).containsExactly(second.id()); + } + + /******************************************************************************* + ** Both collaboration and bucket size limits are enforced while streaming. + *******************************************************************************/ + @Test + void limitsAndExtensions() throws Exception + { + config.withDefaultMaxBytes(4L); + assertThatThrownBy(() -> upload("large.txt", "hello")).hasMessageContaining("size limit"); + assertThat(storage.objects).isEmpty(); + assertThat(service.list("document", 1)).isEmpty(); + upload("exact.txt", "four"); + config.withDefaultMaxBytes(100L); + storage.limit = 2; + assertThatThrownBy(() -> upload("bucket.txt", "abc")).hasMessageContaining("size limit of 2"); + config.withBlockedExtensions(List.of(".EXE")); + assertThatThrownBy(() -> upload("bad.exe", "x")).hasMessageContaining("blocked"); + assertThatThrownBy(() -> upload("..", "x")).hasMessageContaining("Invalid file name"); + assertThatThrownBy(() -> upload(" ", "x")).hasMessageContaining("file name is required"); + assertThatThrownBy(() -> service.upload("document", 1, "x", "x".repeat(256), stream("x"), null)).hasMessageContaining("field lengths"); + } + + /******************************************************************************* + ** A locked, missing or table-denied parent always gives the same Not found. + *******************************************************************************/ + @Test + void invisibleParents() throws Exception + { + AttachmentEntry row = upload("visible.txt", "hello"); + for(String id : List.of("3", "999", "invalid")) + { + assertThatThrownBy(() -> service.upload("document", id, "a.txt", null, stream("x"), null)) + .isInstanceOf(QNotFoundException.class).hasMessage("Not found"); + assertThatThrownBy(() -> service.list("document", id)).hasMessage("Not found"); + } + QContext.getQSession().getSecurityKeyValues().remove("tenant"); + assertThatThrownBy(() -> service.downloadUrl(row.id(), false)).hasMessage("Not found"); + assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Not found"); + QContext.getQSession().getPermissions().remove("document.read"); + assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found"); + assertThatThrownBy(() -> ParentRecordAccess.requireVisibleParent("missing", 1)).hasMessage("Not found"); + assertThatThrownBy(() -> ParentRecordAccess.requireVisibleParent(CollaborationTables.ATTACHMENT, row.id())).hasMessage("Not found"); + } + + /******************************************************************************* + ** Explicit attach uses the table's permission base and does not require EDIT. + *******************************************************************************/ + @Test + void explicitAttachWithoutEdit() throws Exception + { + QContext.getQSession().getPermissions().remove("document.write"); + assertThatThrownBy(() -> upload("denied.txt", "x")).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().withPermissions("document.attach"); + AttachmentEntry row = upload("allowed.txt", "x"); + service.delete(List.of(row.id())); + instance.getTable("document").getPermissionRules().setPermissionBaseName("documents"); + QContext.getQSession().withPermissions("documents.read", "documents.attach"); + upload("custom-base.txt", "x"); + } + + /******************************************************************************* + ** Ownership, moderation, and feature revocation are independent checks. + *******************************************************************************/ + @Test + void ownOtherAndAdmin() throws Exception + { + AttachmentEntry alice = upload("alice.txt", "a"); + login("bob"); + AttachmentEntry bob = upload("bob.txt", "b"); + assertThat(service.list("document", 1)).filteredOn(row -> row.id().equals(alice.id())).extracting(AttachmentEntry::canDelete).containsExactly(false); + assertThatThrownBy(() -> service.delete(List.of(bob.id(), alice.id()))).isInstanceOf(QPermissionDeniedException.class); + assertThat(storage.objects).hasSize(2); + assertThatThrownBy(() -> service.rename(alice.id(), "stolen.txt", null)).isInstanceOf(QPermissionDeniedException.class); + QContext.getQSession().getPermissions().remove("document.write"); + assertThatThrownBy(() -> service.delete(List.of(bob.id()))).isInstanceOf(QPermissionDeniedException.class); + admin(); + QContext.getQSession().getPermissions().remove("document.write"); + assertThat(service.rename(alice.id(), "renamed.txt", "new description").fileName()).isEqualTo("renamed.txt"); + assertThat(service.describe(alice.id(), "").description()).isEmpty(); + service.downloadUrl(alice.id(), false); + assertThat(storage.lastGet.getDownloadAs()).isEqualTo("renamed.txt"); + service.delete(List.of(alice.id(), bob.id())); + assertThat(storage.objects).isEmpty(); + } + + /******************************************************************************* + ** Direct table writes cannot bypass authorization or the storage lifecycle. + *******************************************************************************/ + @Test + void directWritesDeniedAndPanelBypassesBrowseGrant() throws Exception + { + AttachmentEntry row = upload("ok.txt", "x"); + assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class); + assertThat(service.list("document", 1)).hasSize(1); + admin(); + assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.ATTACHMENT).withRecords(List.of(new QRecord())))) + .isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.ATTACHMENT) + .withRecords(List.of(new QRecord().withValue("id", row.id()).withValue("createdByUserId", "admin"))))) + .isInstanceOf(QPermissionDeniedException.class); + } + + /******************************************************************************* + ** The real pre-query hook restricts query and count, including an empty IN. + *******************************************************************************/ + @Test + void tableReadRestrictionAndPostFiltering() throws Exception + { + AttachmentEntry visible = upload("visible.txt", "x"); + QContext.getQSession().withSecurityKeyValue("allTenants", true); + service.upload("document", 3, "secret.txt", "text/plain", stream("secret"), null); + login("alice", CollaborationPermissions.VIEW); + QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT); + assertThat(new QueryAction().execute(query).getRecords()).extracting(row -> row.getValueInteger("id")).containsExactly(visible.id()); + assertThat(query.getFilter().toString()).contains("tableName", "document"); + assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isEqualTo(2); + int hiddenId = AttachmentRecords.query(new QQueryFilter().withCriteria("recordId", QCriteriaOperator.EQUALS, "3")).getFirst().getValueInteger("id"); + assertThat(new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(hiddenId)).getRecord()).isNull(); + QContext.getQSession().getPermissions().remove("document.read"); + assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT)).getRecords()).isEmpty(); + assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isZero(); + } + + /******************************************************************************* + ** Signed previews are generated only after checking visibility and stay virtual. + *******************************************************************************/ + @Test + void previews() throws Exception + { + AttachmentEntry row = upload("photo.png", "image"); + QRecord visible = raw(row.id()); + QRecord hidden = new QRecord(visible).withValue("recordId", "3"); + AttachmentPreviewCustomizer customizer = new AttachmentPreviewCustomizer(); + List results = customizer.filter(List.of(visible, hidden), false, service); + assertThat(results).hasSize(1); + assertThat(storage.signatures).hasSize(2); + assertThat(results.getFirst().getValueString("previewUrl")).contains("signed.example"); + assertThat(results.getFirst().getValueString("downloadUrl")).contains("signed.example"); + assertThat(results.getFirst().getDisplayValue("downloadUrl")).isEqualTo("photo.png"); + assertThat(results.getFirst().getDisplayValue("downloadUrl:downloadUrlDynamic")).isEqualTo(results.getFirst().getValueString("downloadUrl")); + assertThat(visible.getValue("previewUrl")).isNull(); + assertThat(raw(row.id()).getValue("previewUrl")).isNull(); + assertThatThrownBy(() -> customizer.filter(List.of(hidden), true, service)).hasMessage("Not found"); + } + + /******************************************************************************* + ** Review is dry, Result is actual, and authority is checked again after Review. + *******************************************************************************/ + @Test + void deleteProcessReviewAndResult() throws Exception + { + AttachmentEntry alice = upload("alice.txt", "a"); + login("bob"); + AttachmentEntry bob = upload("bob.txt", "b"); + DeleteAttachmentsProcess.Review review = new DeleteAttachmentsProcess.Review(service); + RunBackendStepOutput reviewed = new RunBackendStepOutput(); + review.runOnePage(new RunBackendStepInput().withRecords(List.of(raw(alice.id()), raw(bob.id()))), reviewed); + assertThat(reviewed.getRecords()).extracting(row -> row.getValueInteger("id")).containsExactly(bob.id()); + assertThat(storage.objects).hasSize(2); + assertThat(review.getProcessSummary(reviewed, false)).hasSize(2); + ProcessSummaryLine planned = (ProcessSummaryLine) review.getProcessSummary(reviewed, false).getFirst(); + assertThat(planned.getCount()).isEqualTo(1); + assertThat(planned.getSingularFutureMessage()).contains("will be deleted"); + DeleteAttachmentsProcess.Delete delete = new DeleteAttachmentsProcess.Delete(service); + delete.setTransformStep(review); + delete.runOnePage(new RunBackendStepInput().withRecords(reviewed.getRecords()), new RunBackendStepOutput()); + assertThat(delete.getProcessSummary(new RunBackendStepOutput(), true)).hasSize(2); + ProcessSummaryLine result = (ProcessSummaryLine) delete.getProcessSummary(new RunBackendStepOutput(), true).getFirst(); + assertThat(result.getSingularPastMessage()).isEqualTo("attachment deleted"); + assertThat(result.getCount()).isEqualTo(1); + assertThat(storage.objects).hasSize(1); + delete.runOnePage(new RunBackendStepInput().withRecords(List.of(raw(alice.id()))), new RunBackendStepOutput()); + assertThat(delete.getProcessSummary(new RunBackendStepOutput(), true)).hasSize(3); + assertThat(instance.getProcess(DeleteAttachmentsProcess.NAME).getLabel()).isEqualTo("Delete attachments"); + } + + /******************************************************************************* + ** Run the actual streamed ETL through its frontend break and resume boundary. + *******************************************************************************/ + @Test + @SuppressWarnings("unchecked") + void streamedProcess() throws Exception + { + login("alice", CollaborationPermissions.VIEW); + AttachmentEntry row = upload("streamed.txt", "x"); + processService = service; + instance.getProcess(DeleteAttachmentsProcess.NAME).getBackendStep(StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE) + .getInputMetaData().getFieldThrowing(StreamedETLWithFrontendProcess.FIELD_LOAD_CODE).setDefaultValue(new QCodeReference(FakeDelete.class)); + try + { + RunProcessInput start = new RunProcessInput(); + start.setProcessName(DeleteAttachmentsProcess.NAME); + start.addValue("recordsParam", "recordIds"); + start.addValue("recordIds", row.id().toString()); + start.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK); + RunProcessOutput review = new RunProcessAction().execute(start); + List reviewLines = (List) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY); + assertThat(reviewLines).anySatisfy(line -> + { + line.pickMessage(false); + assertThat(line.getMessage()).contains("will be deleted"); + assertThat(line.getCount()).isEqualTo(1); + }); + assertThat(storage.objects).hasSize(1); + RunProcessInput go = new RunProcessInput(); + go.setProcessName(DeleteAttachmentsProcess.NAME); + go.setProcessUUID(review.getProcessUUID()); + go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW); + go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + RunProcessOutput result = new RunProcessAction().execute(go); + List resultLines = (List) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY); + assertThat(resultLines).anySatisfy(line -> + { + line.pickMessage(true); + assertThat(line.getMessage()).isEqualTo("attachment deleted"); + assertThat(line.getCount()).isEqualTo(1); + }); + assertThat(storage.objects).isEmpty(); + assertThat(service.list("document", 1)).isEmpty(); + } + finally + { + processService = null; + } + } + + /******************************************************************************* + ** Only storage changes in the end-to-end process test, via the normal service. + *******************************************************************************/ + public static class FakeDelete extends DeleteAttachmentsProcess.Delete + { + /******************************************************************************* + ** Instantiated by the real process loader. + *******************************************************************************/ + public FakeDelete() + { + super(processService); + } + } + + /******************************************************************************* + ** Storage failures leave rows for retries; trusted cleanup works after parent deletion. + *******************************************************************************/ + @Test + void failuresAndCleanup() throws Exception + { + AttachmentEntry row = upload("file.txt", "x"); + storage.failDelete = true; + assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Storage unavailable"); + assertThat(service.list("document", 1)).hasSize(1); + storage.failDelete = false; + new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1))); + assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found"); + QContext.getQSession().getPermissions().clear(); + AttachmentCleanup cleanup = new AttachmentCleanup(service); + cleanup.onParentsDeleted("document", List.of()); + cleanup.onParentsDeleted("document", List.of(1)); + cleanup.onParentsDeleted("document", List.of(1)); + assertThat(storage.objects).isEmpty(); + assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty(); + } + + /******************************************************************************* + ** A rejected row compensates the put; a broken upload never creates a row. + *******************************************************************************/ + @Test + void failedInsertAndInputStream() throws Exception + { + instance.getTable(CollaborationTables.ATTACHMENT).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class)); + assertThatThrownBy(() -> upload("long.txt", "x")).isInstanceOf(QException.class).hasMessageContaining("Could not save attachment"); + assertThat(storage.objects).isEmpty(); + assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty(); + assertThatThrownBy(() -> service.upload("document", 1, "x", null, new java.io.InputStream() + { + @Override + public int read() throws IOException + { + throw (new IOException("broken source")); + } + }, null)).isInstanceOf(QException.class).hasMessageContaining("Could not read attachment"); + assertThat(storage.objects).isEmpty(); + } + + /******************************************************************************* + ** Exercise the real QQQ per-record validation failure/compensation path. + *******************************************************************************/ + public static class RejectInsert implements TableCustomizerInterface + { + @Override + public List preInsert(InsertInput input, List records, boolean isPreview) + { + records.forEach(row -> row.withError(new BadInputStatusMessage("Rejected by host validation"))); + return (records); + } + } + + /******************************************************************************* + ** Real counts and bytes on the home, with explicit approximation and empty state. + *******************************************************************************/ + @Test + void homeAndSchema() throws Exception + { + admin(); + assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0"); + upload("a.txt", "12345"); + upload("b.txt", "123"); + assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet."); + String ddl = CollaborationSchema.postgresScript(config); + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL", + "ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url"); + QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT); + assertThat(table.getField("sizeBytes").getType()).isEqualTo(QFieldType.LONG); + assertThat(table.getVirtualField("previewUrl")).isNotNull(); + assertThat(config.getDefaultMaxBytes()).isEqualTo(104857600L); + assertThat(config.getAttachmentsBucketName()).isEqualTo("collaboration"); + assertThat(config.getBlockedExtensions()).isEmpty(); + assertThatThrownBy(() -> config.withDefaultMaxBytes(0L).validate(instance)).hasMessageContaining("defaultMaxBytes"); + } + + /******************************************************************************* + ** Short upload helper using the first visible parent. + *******************************************************************************/ + private AttachmentEntry upload(String name, String value) throws QException + { + return (service.upload("document", 1, name, "text/plain", stream(value), "description")); + } + + /******************************************************************************* + ** UTF-8 payload. + *******************************************************************************/ + private ByteArrayInputStream stream(String value) + { + return (new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8))); + } + + /******************************************************************************* + ** Inspect persistence without producing URLs. + *******************************************************************************/ + private QRecord raw(Integer id) throws QException + { + return (AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)).getFirst()); + } + + /******************************************************************************* + ** Fake bucket preserving bytes and capturing API requests. No provider emulation. + *******************************************************************************/ + private static class FakeStorage extends AttachmentStorage + { + private final Map objects = new HashMap<>(); + private final List signatures = new ArrayList<>(); + private long limit = Long.MAX_VALUE; + private PutRequest lastPut; + private PresignGet lastGet; + private boolean failDelete; + + @Override + public long maxBytes(String name) + { + return (limit); + } + + @Override + public void put(String name, PutRequest request) + { + lastPut = request; + try + { + objects.put(name + "/" + request.getKey(), request.getStream().readAllBytes()); + } + catch(IOException e) + { + throw (new IllegalStateException(e)); + } + } + + @Override + public void delete(String name, String key) + { + if(failDelete) + { + throw (new IllegalStateException("Storage unavailable")); + } + objects.remove(name + "/" + key); + } + + @Override + public URL presign(String name, PresignGet request) + { + lastGet = request; + signatures.add(request); + try + { + return (URI.create("https://signed.example/" + request.getKey()).toURL()); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } + } +} -- 2.54.0