feat: record attachments through qbit-object-storage #3

Merged
KofTwentyTwo merged 1 commits from feature/attachments into develop 2026-10-13 21:15:25 +00:00
20 changed files with 1935 additions and 17 deletions

No files matched your search

+6
View File
@@ -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.
+47 -9
View File
@@ -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<FavoriteEntry> 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<AttachmentEntry> 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 `<permissionBaseName>.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
+13 -1
View File
@@ -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.
+5
View File
@@ -67,6 +67,11 @@
</scm>
<dependencies>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
@@ -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<String> 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<String> getBlockedExtensions()
{
return (blockedExtensions);
}
/*******************************************************************************
** Setter for blockedExtensions.
*******************************************************************************/
public void setBlockedExtensions(List<String> blockedExtensions)
{
this.blockedExtensions = blockedExtensions;
}
/*******************************************************************************
** Fluent setter for blockedExtensions.
*******************************************************************************/
public CollaborationQBitConfig withBlockedExtensions(List<String> blockedExtensions)
{
setBlockedExtensions(blockedExtensions);
return (this);
}
}
@@ -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 QBitMetaDataProducer<Collabora
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
output.add(TrimRecentsProcess.metadata(qBitConfig));
output.add(DeleteAttachmentsProcess.metadata());
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
{
output.add(source);
@@ -57,11 +57,11 @@ public final class CollaborationSchema
for(QFieldMetaData field : table.getFields().values())
{
String type = field.getName().equals(table.getPrimaryKeyField()) ? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field);
columns.add(" " + field.getBackendName() + " " + type + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
}
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
int index = 0;
for(UniqueKey key : table.getUniqueKeys())
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>of() : table.getUniqueKeys())
{
index++;
List<String> 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";
@@ -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("</section>");
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
attachmentSummary(html);
return (html.append("</div>").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<AggregateResult> 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("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
if("0".equals(String.valueOf(rowCount)))
{
html.append("<p>No attachments yet.</p>");
}
html.append("</section>");
}
/*******************************************************************************
** Aggregate rows and distinct owners without loading high-volume personal data.
*******************************************************************************/
@@ -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<? extends Serializable> ids) throws QException
{
if(ids.isEmpty())
{
return;
}
while(true)
{
List<QRecord> 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);
}
}
}
}
@@ -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)
{
}
@@ -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<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
return (filter(records, input instanceof GetInput, new AttachmentsService()));
}
/*******************************************************************************
** Keep filtering every row even when the preview generation cap is reached.
*******************************************************************************/
List<QRecord> filter(List<QRecord> records, boolean isGet, AttachmentsService service) throws QException
{
List<QRecord> 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);
}
}
@@ -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<QRecord> 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<QRecord> 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()));
}
}
}
}
}
@@ -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<String> 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);
}
}
@@ -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));
}
}
@@ -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<QRecord> 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<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
{
String id = canonicalId(tableName, recordId);
List<AttachmentEntry> 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<? extends Serializable> attachmentIds) throws QException
{
List<QRecord> 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<QRecord> 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"));
}
}
@@ -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<String, Serializable> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> 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<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
deleted.addSelfToListIfAnyCount(lines);
if(getTransformStep() instanceof Review review)
{
review.skipped.addSelfToListIfAnyCount(lines);
}
failed.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -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<QTableMetaData> tables = List.of(settings, people, favorites, recents);
List<QTableMetaData> 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")));
}
}
@@ -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."));
}
}
}
@@ -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();
}
@@ -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<QRecord> 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<ProcessSummaryLine> reviewLines = (List<ProcessSummaryLine>) 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<ProcessSummaryLine> resultLines = (List<ProcessSummaryLine>) 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<QRecord> preInsert(InsertInput input, List<QRecord> 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<String, byte[]> objects = new HashMap<>();
private final List<PresignGet> 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));
}
}
}
}