Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53bd8f00d8 | ||
|
|
2f227579b8 | ||
|
|
9b35a68773 | ||
|
|
32eb2fdae9 | ||
|
|
7d61f29e92
|
||
|
|
6e60585af1
|
||
|
|
594e755da4
|
||
|
|
4679821cb4
|
||
|
|
f8337989e8
|
||
|
|
6cf98bf1fd | ||
|
|
0ae1e62b7b | ||
|
|
47cbc7c72d | ||
|
|
1ae6508793 | ||
|
|
03407ae79a | ||
|
|
9d6767e731 | ||
|
|
aa6a4a3b58 | ||
|
|
d9a46afbf9 | ||
|
|
b5308a8bab | ||
|
|
0caaa3717e | ||
|
|
464db76bc3 | ||
|
|
19a06166f5 | ||
|
|
61060e2793
|
||
|
|
4e88ed8303 | ||
|
|
7a7b804106
|
||
|
|
4c95151a86 | ||
|
|
41bbf2c428
|
||
|
|
2f6be765f2
|
||
|
|
92dd901409 | ||
|
|
6a4ea584f5
|
||
|
|
03b3bbaca2 | ||
|
|
31ce528064
|
||
|
|
90b3956cf7 | ||
|
|
e4f92192c6
|
||
|
|
675d30568e | ||
|
|
3df535453e
|
||
|
|
d302f1dc1f | ||
|
|
58c87b0378
|
||
|
|
e5647e6020
|
||
|
|
3d2a1ded72
|
||
|
|
662a9f1a6d | ||
|
|
e014c00428
|
||
|
|
ea0b74f94c | ||
|
|
e051c74273
|
||
|
|
680fd5fa02 | ||
|
|
1e0a5773bb | ||
|
|
83b253a94f |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original release run id (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
release-rc:
|
||||
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
|
||||
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
|
||||
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
pull-requests: write
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ inputs.bump_sha || '' }}
|
||||
branch: ${{ inputs.branch || '' }}
|
||||
version: ${{ inputs.version || '' }}
|
||||
secrets:
|
||||
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -42,7 +42,7 @@ This file provides guidance to Codex when working with code in this repository.
|
||||
## Dependencies
|
||||
|
||||
- Java 21.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
|
||||
- Parent `com.kingsrook:qbit-build-parent:2.1.1`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-SNAPSHOT`; build the matching Core integration branch first (see `docs/CORE-DEPENDENCY.md`). Public RC.21 lacks required rollback callbacks.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
+21
-1
@@ -4,14 +4,34 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Attachment lifecycle SPI (`AttachmentLifecyclePolicy`, `CollaborationQBitConfig.withAttachmentLifecycle`) for retention, registration and mandatory access events; durable upload journal (`collaboration_upload_journal`, H2/MySQL/PostgreSQL), upload recovery and existing-attachment adoption processes, durable cleanup queue with operator settlement, verified/authenticated delivery (`AttachmentDownload`, `AttachmentContentIntegrityException`), media policy, aggregate guard and bounded attachment pagination. See `docs/ATTACHMENT-LIFECYCLE.md`, `docs/ATTACHMENT-RECOVERY.md`, `docs/RETAINED-UPLOAD-JOURNAL.md`, `docs/ATTACHMENT-PAGINATION.md`.
|
||||
- Requires a core whose `QBackendTransaction` provides `supportsRollbackCallbacks()` and `addAfterRollbackCallback(Runnable)` (not in 4.1.0-RC.21); see `docs/CORE-DEPENDENCY.md`.
|
||||
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
|
||||
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
|
||||
- Host-neutral QBIT help bundle covering tables, fields, sections, built-in actions, the app, widgets and possible-value sources; core registration and `HelpBundleAssertions` coverage replace the temporary help loader.
|
||||
- `CollaborationTablesGuardrailTest` and `FeatureSettingsDefaultsTest`.
|
||||
|
||||
### Changed
|
||||
|
||||
- UI API extension collaboration 1.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
|
||||
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
|
||||
|
||||
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
|
||||
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
|
||||
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
|
||||
- Verify RC.7 timeline audit masking and HIDDEN-field omission through enforced viewer queries, including canaries in values and messages.
|
||||
|
||||
- Switch the optional qbit-email integration to 0.1.0-RC.2 and its published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and enqueue-status handling. Narrow the email package allowance to the public API.
|
||||
|
||||
- Email through qbit-email 0.1.0-RC.1: new optional dependency and `EmailCollaborationNotifier` (templates `collaboration_mention`, `collaboration_share`, `collaboration_task_reminder`), the default notifier when qbit-email is produced in the instance and no `notifier` is configured; `NoOpNotifier` otherwise. One NOTIFICATION message per recipient, no comment bodies, record labels only when field access allows. New `recipientEmailResolver` config hook; `CollaborationNotification` gains optional `sourceId` and `actorUserId`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
|
||||
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
|
||||
|
||||
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
|
||||
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
|
||||
- The share recipient search and mention candidates no longer offer the caller to themself.
|
||||
@@ -38,7 +58,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
|
||||
- Adopt core feature overrides, record capabilities and implied WRITE locks, reference metadata, mirrored locks, after-commit cleanup and summaries. Core registers feature permissions in the policy editor.
|
||||
- Replace `OtherUserAccess`/`UserSessionSupplier` with `CollaborationRecordAccessChecker` and core `UserSessionProviderInterface`; configure `withUserSessionProvider`.
|
||||
- Remove `ParentTableRestriction` and `TagFilterTranslator`; runtime `collaborationTags` filtering/search uses core subqueries. Retain the unpinned visibility fallback still missing from core.
|
||||
- Publish identifier-only realtime hints after commit. Selectable Tags output and the optional followers SPI await missing core hooks.
|
||||
- Publish identifier-only realtime hints after commit. Selectable Tags output awaits a core hook; the followers SPI is adopted above.
|
||||
|
||||
- Build against locally installed QQQ `4.1.0-SNAPSHOT` until RC.6 is released; `qqq-middleware-javalin` is a compile dependency. Object storage remains pinned to `0.1.0-RC.2`.
|
||||
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
# QBit Collaboration
|
||||
|
||||
> **Core integration verified locally (2026-11-04).** All 269 Collaboration tests pass against the matching Core snapshot, including attachment recovery and both journal providers. See [current integration status](docs/QBIT-RELEASE-STATUS.md), [dependency setup](docs/CORE-DEPENDENCY.md) and [remaining release requirements](docs/QBIT-KNOWN-ISSUES.md). This feature branch is not a published release.
|
||||
|
||||
|
||||
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. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
|
||||
|
||||
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
|
||||
|
||||
## In-app help
|
||||
|
||||
The qbit registers [help/qbit-collaboration.yaml](src/main/resources/help/qbit-collaboration.yaml)
|
||||
with QQQ's help-bundle registry. Core startup sync applies it when the host has the
|
||||
`helpContent` table; registration is safe without that table. Update the bundle's
|
||||
literal version alongside the POM version when releasing.
|
||||
|
||||
To customize text, register a separate bundle with `layer: HOST` and the same entry
|
||||
key and role using `HelpBundles.register(instance, "help/my-host.yaml")`. HOST text
|
||||
takes precedence over QBIT text; startup sync preserves user edits. Hosts should
|
||||
supply help for their configured table-specific follow, tag and assignment actions,
|
||||
and for a renamed Collaboration app.
|
||||
|
||||
`CollaborationHelpBundleTest` runs `HelpBundleAssertions` against the produced
|
||||
metadata and checks widget and possible-value-source coverage. The table guardrail
|
||||
also checks that every table explains how its rows are created. Run `./mvnw -q verify`.
|
||||
|
||||
## Dependent fields
|
||||
|
||||
Comment parents list the selected thread's root and replies. Feature-setting scope names follow `scopeType` (apps, eligible tables, or `instance`). Comment field anchors follow `tableName` and offer non-hidden fields visible to the session. These use RC.5 filter variables and custom providers receiving `otherValues`; automatic dependency refresh awaits core `dependsOn`. See [dependent-field details](docs/CONTINGENT-POSSIBLE-VALUES.md).
|
||||
@@ -11,16 +31,33 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
|
||||
## Requirements
|
||||
|
||||
- Java 21.
|
||||
- QQQ `4.1.0-SNAPSHOT` installed from develop, including UI API extension point #1128. The build will move to `4.1.0-RC.6` once released; middleware Javalin is a compile dependency.
|
||||
- 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.
|
||||
- QQQ `4.1.0-SNAPSHOT` built from the compatible Core integration branch; see [Core dependency setup](docs/CORE-DEPENDENCY.md). Middleware Javalin is a compile dependency. Public RC.21 lacks the required rollback API.
|
||||
- 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 memory, H2 and disposable PostgreSQL/MySQL containers.
|
||||
- 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.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-SNAPSHOT`, targeting RC.6.
|
||||
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use the same matching Core snapshot.
|
||||
|
||||
## Followers and secured data
|
||||
|
||||
The instance registers a core `RecordFollowersProviderInterface` backed by
|
||||
`collaboration_follow`, so alerts and other qbits can use `RecordFollowers.get`.
|
||||
CHANGE and ALERT select `notifyOnChange`; COMMENT selects `notifyOnComment`.
|
||||
Future mutes are excluded, and disabling FOLLOW returns no subscribers. The
|
||||
provider reads only the requested table/record as the system. Consumers must
|
||||
check each recipient's record access before sending data.
|
||||
|
||||
Favorites and recents cache a record label only when every label field is FULL
|
||||
for the current session; otherwise the stored label is null. Lists continue to
|
||||
re-read labels as the viewer. Comment and share notification titles resolve
|
||||
labels in each recipient's session and fall back to the table label and id
|
||||
(for example, `Order #1042`) when label access is restricted or unavailable.
|
||||
Timeline audit reads use core's enforced query boundary: MASKED values are
|
||||
masked, HIDDEN details are omitted, and restricted audit messages are cleared.
|
||||
Comment bodies remain user content; realtime field-name filtering is handled by core.
|
||||
|
||||
## Demo server
|
||||
|
||||
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
|
||||
Next dashboard and taking screenshots. It needs Java 21 and the locally installed
|
||||
QQQ `4.1.0-SNAPSHOT` from develop. No database, Docker or MinIO is needed. It binds
|
||||
Next dashboard and taking screenshots. It needs Java 21 and the matching Core integration snapshot. No database, Docker or MinIO is needed. It binds
|
||||
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
|
||||
|
||||
Build the Next static export in `qqq-frontend-next`:
|
||||
@@ -116,6 +153,15 @@ new CollaborationQBitProducer()
|
||||
|
||||
Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation after all producers.
|
||||
|
||||
## Starter defaults and setup
|
||||
|
||||
Collaboration ships no SQL seed. The one configuration table, `collaboration_feature_setting`, depends on the host's configuration (`withDefaultFeatures`, `withTableFeatures`, the default tag mode, features declared on apps and tables), so its rows are computed at run time:
|
||||
|
||||
- Open Feature Settings and run the table action **Create rows from configuration** (collaboration administrators). It writes one Instance row per feature, plus an App or Table row wherever the code declares something at that scope. The rows say exactly what the code already says, so nothing changes; `FeatureSettingsDefaultsTest` proves every table resolves identically with and without them.
|
||||
- It only creates rows when the table is empty, so it never touches an administrator's rows. Rows are applied after all code configuration. An Instance row overrides what code declares on any app or table that has no row of its own, so once rows exist, change features in the rows, not in code.
|
||||
- The **Setup** card on the Collaboration home shows each feature, whether it is on, for which tables, and whether that comes from Feature Settings rows or the configuration.
|
||||
- Every collaboration table has help (shown when it is empty) that says how its rows get created. The other tables hold user content or runtime data and start empty by design.
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | Purpose |
|
||||
@@ -367,13 +413,13 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
|
||||
|
||||
## UI API
|
||||
|
||||
The producer registers extension `collaboration` version `1.0.0` at
|
||||
The producer registers extension `collaboration` version `1.2.0` at
|
||||
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
|
||||
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
|
||||
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
|
||||
of this extension version.
|
||||
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
|
||||
the collaboration tag and standard error responses.
|
||||
|
||||
All successes return HTTP 200. All bodies have named `Collaboration*V1` schemas
|
||||
All successes return HTTP 200. Success and request bodies have named `Collaboration*V1` schemas
|
||||
in [the extension contract](src/main/resources/openapi/qqq-v1/collaboration.yaml).
|
||||
The server publishes these alongside core at `/qqq/v1/openapi.yaml` and `.json`.
|
||||
|
||||
@@ -435,9 +481,11 @@ attachment filename; preview requests inline content. QQQ's current OpenAPI
|
||||
`Response` model cannot declare the `Location` header, so this contract uses the
|
||||
JSON URL alternative consistently.
|
||||
|
||||
A missing or invisible parent returns identical `404 {"error":"Not found"}`.
|
||||
Missing write permissions return 403; invalid input returns 400 with an `error`
|
||||
message through QQQ's exception mapper. Provider/backend failures remain server
|
||||
A missing or invisible parent returns 404 with the same `Not found` error message.
|
||||
Missing write permissions return 403; invalid input returns 400 through QQQ's
|
||||
exception mapper. Errors use `BasicErrorResponseV1` with `error`, `code`, `message`,
|
||||
`traceId` and optional `fieldErrors`. `CollaborationErrorV1` remains defined but is
|
||||
deprecated since 1.1.0. Provider/backend failures remain server
|
||||
errors. Services continue to own author, record-lock and permission checks.
|
||||
Mention candidates expose `canRead` as the string enum `"true"`, `"false"`,
|
||||
`"unknown"`; unknown does not imply the recipient can read the record.
|
||||
@@ -563,7 +611,7 @@ host integrations as other collaboration tables.
|
||||
|
||||
## Core contracts and completed UI API
|
||||
|
||||
The qbit targets QQQ RC.6 using the locally installed `4.1.0-SNAPSHOT`. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
|
||||
The qbit uses QQQ `4.1.0-RC.7` from Maven Central. Defaults enable FAVORITES, RECENTS and TIMELINE for eligible host tables; all other features start off. Configure before producing metadata:
|
||||
|
||||
```java
|
||||
config.withDefaultFeatures(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE)
|
||||
@@ -593,9 +641,9 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
|
||||
| PUT | `/records/{table}/{pk}/assignment` |
|
||||
| GET | `/my-work` |
|
||||
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
|
||||
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension is now **1.1.0**, including the UI API 1.6.0 error contract and published presence schemas.
|
||||
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. The optional followers provider is skipped because `RecordFollowersProviderInterface` is absent from the installed jar. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
|
||||
|
||||
|
||||
## Custom fields (phase f)
|
||||
@@ -676,7 +724,7 @@ Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
|
||||
| --- | --- |
|
||||
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
|
||||
| `DELETE /presence` | `{connectionId, table, pk?}` |
|
||||
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
|
||||
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
|
||||
|
||||
Session identity supplies userId and displayName. Every call rechecks table READ and record
|
||||
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
|
||||
@@ -688,6 +736,17 @@ sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets
|
||||
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
|
||||
editor supplies focus. Nothing is persisted, including directory rows.
|
||||
|
||||
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
|
||||
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
|
||||
two terminals of one user read as one person with two connections. `perConnection=true` adds
|
||||
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
|
||||
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
|
||||
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
|
||||
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
|
||||
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
|
||||
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
|
||||
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
|
||||
|
||||
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
|
||||
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
|
||||
snapshots aggregate users without record ids or focused field names; those stay on record
|
||||
@@ -696,5 +755,5 @@ refetch after reconnecting.
|
||||
|
||||
The initial overlay refresh starts the registry on every pod, including those without local
|
||||
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
|
||||
per table, and core record-feature summaries include the presence snapshot.
|
||||
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user and
|
||||
connection counts per table, and core record-feature summaries include the presence snapshot.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Upgrading
|
||||
|
||||
## Unreleased
|
||||
|
||||
No schema change and nothing required of hosts.
|
||||
|
||||
- New table action **Create rows from configuration** on Feature Settings. Run it once if you want the effective feature settings visible and editable as rows. It changes no behavior and does nothing once the table has any row. Note that after rows exist they win over later changes to `withDefaultFeatures` or `withTableFeatures` in code, so change those settings in the rows from then on.
|
||||
- New **Setup** widget first on the Collaboration home, and table help text. Hosts that override help on a table keep their own.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Attachment lifecycle extension
|
||||
|
||||
Collaboration owns standard attachment storage and record access. A host can configure `CollaborationQBitConfig.withAttachmentLifecycle(new QCodeReference(HostPolicy.class))` to enforce retention, registration and mandatory access-event persistence. `HostPolicy` extends `AttachmentLifecyclePolicy`. Collaboration has no dependency on Record Files, generation, scripting or a particular event store.
|
||||
|
||||
The default policy preserves legacy behavior for ordinary attachments. Invalid policy loading fails before storage mutation. This is a trusted Java extension, never an entry point for untrusted user scripts. Policy implementations must be safe for concurrent requests and may not depend on per-instance state surviving between operations.
|
||||
|
||||
## Contract
|
||||
|
||||
| Hook | Timing and meaning |
|
||||
| --- | --- |
|
||||
| `isRetained(attachment)` | Called before object storage during intake with complete metadata but no generated id. Its result is persisted in the server-controlled `retained` field. Also consulted for legacy rows during deletion and display hints. A persisted true value always wins. |
|
||||
| `beforeStored(attachment)` | Called with canonical metadata and actual checksum immediately before the first object PUT, after matching retry replay returns. Retained files default to refusal; a mandatory provider commits storage intent independently. Retained uploads reject ambient transactions. |
|
||||
| `afterStored(attachment)` | Called after object and attachment metadata insertion, before upload returns. Register the retained identity and ingestion status. Failure preserves the object and inserted row for reconciliation when the enclosing transaction permits it. |
|
||||
| `beforeAccess(attachment, inline)` | Called after parent authorization, before signing either standard preview or download URLs. Throw to deny. Runtime policy failures are converted to checked failures so preview code cannot silently swallow them. |
|
||||
| `beforeMetadataChange(attachment, changes)` | Called after parent/ownership checks, before applying filename/description changes. The second record contains id and changed fields only. This represents an intent, not a committed change. |
|
||||
|
||||
Callbacks receive record copies; scalar attachment metadata is the contract. They must not mutate the passed records to influence storage or authorization. They may throw `QException` to fail the operation. A failed registration must remain inaccessible through the policy until reconciliation succeeds; absence of a retained registry entry must never mean unrestricted access.
|
||||
|
||||
## Persistent retention
|
||||
|
||||
`collaboration_attachment.retained` is a required boolean with false default for existing ordinary attachments. Apply the generated PostgreSQL schema migration through the host's normal migration tooling before installing this version. The migration is `ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE`. It does not automatically classify pre-existing files; any historical backfill requires a deliberate host migration.
|
||||
|
||||
There is no public API to clear retention. Direct user table writes remain denied. Deletion first authorizes and checks retention for the complete selection; it checks retention again immediately before storage deletion. Parent cleanup tombstones retained attachment rows and excludes completed tombstones from subsequent pages. The original bytes remain. The ordinary parent-based UI remains inaccessible after parent deletion; a retained-history view must supply its own preserved authorization policy. This extension does not grant archive access.
|
||||
|
||||
Removing the configured provider cannot restore deletion of rows already marked retained. The no-op provider also refuses signed URLs and metadata edits for those retained rows. Replacing a provider is a trusted host operation: the replacement must still enforce audit and quarantine for every row marked retained, even if its table is no longer configured for new retention.
|
||||
|
||||
Attachment DTOs expose separate `canEdit` and `canDelete` hints. Both require current ownership/admin and attach capability; retention disables deletion. Existing constructor overloads preserve source compatibility. The server rechecks all permissions on mutation. The old `canDelete` flag must no longer control client rename/description actions.
|
||||
|
||||
## Verified content for integrations
|
||||
|
||||
`AttachmentsService.readContent(attachmentId, maximumBytes)` authorizes the actual parent/current actor, enforces both content-read policy phases, validates stored length and SHA-256, closes storage on all paths and returns immutable `AttachmentContent(attachmentId, fileName, contentType, sha256, bytes)`. No storage coordinates or signed URL escape. The caller chooses a limit up to 20 MiB; ordinary large-file download remains a separate streaming/presigned path. No bytes leave this method before integrity verification and the result audit hook succeed.
|
||||
|
||||
`beforeContentRead(attachment)` must authorize and durably record the attempt before storage opens. `afterContentRead(attachment, bytesRead)` must record verified server-read completion before returning content. Both default to refusal for retained files until the lifecycle provider implements them. These facts are distinct from URL issuance, user download, printer spooling and physical output.
|
||||
|
||||
## Retryable intake
|
||||
|
||||
An upload overload adds `intakeKey` and `manifestSha256` after the ordinary upload arguments. Both are server-coordinator SHA-256 identifiers. The owner additionally hashes actual authenticated actor and canonical parent into the storage scope; never trust a client key as authority. The intake fingerprint includes the caller's manifest, filename, declared type, description, actual length and actual content hash. Every retry reads and checks its supplied bytes. A matching attachment is returned and its idempotent registration hook is retried; changed inputs conflict.
|
||||
|
||||
This path rejects ambient transactions. It conditionally creates a deterministic storage key with fingerprint metadata and never overwrites or deletes an uncertain original. If storage succeeded but metadata insertion failed, the next attempt verifies the existing bytes/fingerprint and completes registration. Host migration must add nullable `intake_key`/`intake_fingerprint` columns and the unique nonnull intake-key index from `CollaborationSchema`. That index is the database race boundary; sequential memory fixtures do not prove its concurrency behavior. The calling coordinator still owns durable request reservation, lifecycle and reconciliation scheduling.
|
||||
|
||||
Retryable files are bounded to 20 MiB and use a conditional single-object PUT. The inspected object-storage multipart implementation does not carry conditional-create semantics through completion; it is deliberately not used here. Larger retryable originals need a separately qualified conditional multipart path before this bound changes. This is an advertised intake limit, not a claim of unlimited file sizes or storage immutability. Real storage conditional behavior, object version/lock and backup retention still require provider acceptance.
|
||||
|
||||
## Atomic display metadata auditing
|
||||
|
||||
`beforeMetadataChange` records an independent attempt. Before the update, `validateMetadataTransaction(attachment, transaction)` validates the actual transaction and audit backend; retained files default to denial. After the update, `afterMetadataChange(attachment, changes, transaction)` inserts the success event into that same transaction and also defaults to denial for retained files. The provider must not commit or close the supplied transaction. Reject no-op transactions before mutation; memory tests only establish callback behavior.
|
||||
|
||||
When no caller transaction exists, Collaboration opens, commits, rolls back and closes the owner transaction. If a caller supplied a transaction, that caller owns completion and must roll back on any propagated failure. Successful operation and its event become durable together on commit. Record Files must qualify this with a real supported JDBC backend; this generic owner SPI cannot prove provider durability.
|
||||
|
||||
## Durability boundaries and remaining integration
|
||||
|
||||
The callback is not an audit database. Before returning from `beforeAccess`, a mandatory audit provider must have durably committed its intent independently of a caller transaction that may later roll back. An event means URL issuance was requested, not completed download or physical printing. Storage signing can still fail after the intent is recorded. Signed URLs can be reused until expiration; each use is not observed by Collaboration. Storage access telemetry or a serving gateway is required for transfer events.
|
||||
|
||||
Retained originals are never deleted when metadata insertion fails; ordinary non-retained failed inserts retain legacy cleanup. A committed STORAGE_WRITE_ATTEMPT can identify an orphan even when no attachment row was inserted.
|
||||
|
||||
An object put and QQQ metadata insert are not an atomic transaction. If a surrounding transaction rolls back after object creation, reconciliation must recover an orphaned object. The retained-only journal integration below provides explicit registration recovery; it does not provide malware scanning, WORM storage protection, bucket lifecycle protection or backup verification. Those remain deployment requirements.
|
||||
|
||||
QQQ record-reference write validation issues an internal GetAction using global metadata. Attachment previews use the existing `QueryOrGetInputInterface.getShouldGenerateDisplayValues()` contract: visibility filtering always runs, while URL generation runs only when display values were requested. Core validation GETs default to false; the standard middleware's display GET/query executors explicitly request true. This avoids incidental signing during validation without removing validators or modifying QQQ core. Trusted callers requesting presentation URLs directly through GetAction/QueryAction must explicitly enable display values; `AttachmentsService.downloadUrl` remains an explicit authorized access operation.
|
||||
|
||||
## Aggregate visibility policy
|
||||
|
||||
The optional configuration-only `requiresPinnedAggregateParent()` hook defaults to false. Owners whose parent visibility relies on dynamic archive/project authorization opt in. AttachmentAggregateGuard preserves the normal collaboration permission checker and additionally requires Count/Aggregate inputs to contain one literal top-level AND equality for both `tableName` and `recordId`. It checks current native parent visibility before SQL; joins, set-operation filters, OR roots, missing and conflicting pins are rejected. It does not grant view access.
|
||||
|
||||
Opt-in table metadata hides generic TABLE_COUNT and QUERY_STATS capabilities in both instance and table metadata. The current frontend's use-filter-setup hook already skips totals when TABLE_COUNT is absent and represents them as unknown; record listings and the attachment panel remain available. Native CountAction still permits an authorized pinned count. Do not substitute zero for an unavailable total or issue an unpinned aggregate and rely on a post-query filter: aggregate SQL cannot use that filter. Ordinary policies preserve the existing count capability. Verification is pending the new focused guard suite.
|
||||
|
||||
|
||||
## Retained upload journal integration (verification in progress)
|
||||
|
||||
Public attachment services always use JdbcUploadJournal for retained originals. Ordinary non-retained streaming behavior is unchanged. Apply UploadJournalTable.h2Script() as an owner migration before enabling this path: the immutable physical manifest includes private storage coordinates and lease tokens which deliberately are not QQQ metadata fields. Generating SQL from metadata alone is insufficient. Only shared file-H2 with actual WRITE_DELAY=0 is currently qualified; memory journals are explicitly injected test fixtures only.
|
||||
|
||||
Retained uploads are capped at20MiB and use conditional single-request creation. The journal commits the candidate, fences a lease, and records UNKNOWN before PUT. A conditional collision or uncertain response is accepted only after reading the bounded original and verifying actual length and SHA-256. Metadata insertion and journal REGISTERED checkpoint share one real owner transaction. The mandatory idempotent afterStored lifecycle callback runs after that commit; COMPLETE follows only after it succeeds. A failed callback leaves the same attachment id recoverable. Source archival refuses transfer while the owner checkpoint is incomplete.
|
||||
|
||||
Renewal occurs at synchronous stage boundaries. A storage call that outlives its lease cannot register under its expired fence; a newer recovery may inspect an existing matching original but never overwrite or delete it. No background task synthesizes or carries a browser session. Provider request deadlines and operational supervision remain host acceptance requirements.
|
||||
|
||||
The native Retained Upload Journal table exposes status to its original actor only, subject to current source visibility; after a completed archive transfer, visibility follows the exact attachment's current parent. Native totals are disabled and private-field predicate/order/unique-key/join probes are denied. The Recover retained upload action reauthorizes the original actor and current attach capability, verifies existing bytes and retries registration. It does not accept bytes, a locator, another actor or a coordinator scope. Coordinated intakeKey entries must retry through their original coordinator, preserving origin/version/provenance. The beforeRecovery(candidate) lifecycle hook is default-deny for retained originals and records a recovery read attempt separately from STORAGE_WRITE_ATTEMPT and user downloads.
|
||||
|
||||
Missing bytes or corrupt bytes remain unresolved, never timeout into cancellation or permission to delete possible evidence. A disabled/deleted original actor has no administrative impersonation escape; an explicitly audited trusted administrative reconciliation path remains to be designed. Existing attachment panels also need discoverable pending-upload recovery navigation; the standard table action alone is not a complete panel UX.
|
||||
|
||||
|
||||
### Recovery navigation metadata
|
||||
|
||||
Retention owners can call public UploadRecoveryNavigation.configure(sourceTable) after producing source metadata. It adds ATTACHMENTS.recoveryView containing the native owner table, label and actor/status filter. The generic Files panel adds literal canonical tableName/recordId AND criteria and navigates to the standard QQQ table and its recovery action. CurrentUser expressions are accepted only for createdByUserId/EQUALS with one value. Filters aid discovery and never replace original-actor/current-parent authorization. Missing or malformed metadata hides this optional navigation on ordinary installations. An interrupted-upload message does not claim saved/completed bytes; recovery still verifies the owner journal and exact object.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Standard attachment list pagination
|
||||
|
||||
The standard `GET /records/{table}/{pk}/attachments` endpoint now applies its
|
||||
page offset and limit inside the native QQQ attachment query. It reads at most
|
||||
`limit + 1` rows (maximum 101), projects the requested page, and uses the extra
|
||||
authorized row only to determine whether to emit the next cursor. It performs no
|
||||
count query and does not load the complete attachment list before slicing it.
|
||||
|
||||
The route, response schema, default limit 20, allowed limits 1–100, and existing
|
||||
version-1 scoped offset cursor bytes are unchanged. Sorting is still descending
|
||||
`createDate`, then descending `id`; equal timestamps therefore retain a stable tie
|
||||
order. These are offset pages, not snapshots: concurrent insertion or deletion can
|
||||
shift offsets. High offsets can require backend scanning; bounded returned rows
|
||||
and host allocation are not a constant-time database performance guarantee.
|
||||
|
||||
Each request still touches the current person, resolves and authorizes the actual
|
||||
source record, canonicalizes its primary key, excludes deleted-parent attachments,
|
||||
and uses the existing per-entry edit/delete policy. The same entry projection is
|
||||
evaluated for the lookahead row. There is no generic post-filter refill or inference
|
||||
from a denied row. Sources transferred to retained archive records use the exact
|
||||
current parent identity and its native authorization policy. This owner artifact
|
||||
does not import Record Files or reconstruct its archive policy.
|
||||
|
||||
Metadata visibility is separate from content release. Listing retained or
|
||||
quarantined metadata does not issue a download, preview or print capability; those
|
||||
paths retain their lifecycle and current authorization checks. The cursor is a
|
||||
routing/offset hint, not a signed authorization token. Current source access is
|
||||
required even when the caller presents a previously valid cursor.
|
||||
|
||||
`AttachmentsService.listPage` is the bounded public service operation. The older
|
||||
unpaged `list` method remains for compatibility; new page consumers should use
|
||||
`listPage`. Cursor parsing/encoding is shared with the existing complete-list
|
||||
pagination helper, so other Collaboration endpoints keep their previous behavior.
|
||||
|
||||
Verification: local focused run `target/attachment-pagination-focused.log` passed
|
||||
40 tests on 2026-10-31 at 02:01:44, with zero failures/errors/skips and Checkstyle.
|
||||
Two new tests execute the actual QQQ RDBMS action against H2 and observe native
|
||||
offset/limit and returned-row bounds, equal timestamp ordering, deleted/other
|
||||
source exclusion, retained flags, terminal pages and current revocation before
|
||||
attachment SQL. The other 38 tests cover existing attachment service behavior and
|
||||
the unchanged OpenAPI fixture. Root HTTP run47446 also passed all12 tests, including
|
||||
the new regression covering three pages,
|
||||
cursor scope, malformed/oversized input and a denied actor. Cross-qbit archive integration, full release verification and installation
|
||||
of this new owner snapshot remain separate gates. No installed or frozen consumer
|
||||
artifact has been replaced by this change.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Attachment cleanup recovery
|
||||
|
||||
Deletion writes a standard `collaboration_attachment_cleanup` row and removes attachment metadata in the same backend transaction. Only after commit does it delete the stored object, then acknowledge the cleanup row. PostgreSQL rejection of metadata deletion leaves both the original row and bytes intact. A provider failure or lost callback leaves the cleanup locator available after restart. The service returns a failure when immediate cleanup fails; the logical attachment deletion may already have committed.
|
||||
|
||||
The cleanup table is visible in the Collaboration app to administrators. Direct table mutations are denied, including to administrators, so callers cannot forge deletion targets. Recovery runs through `collaborationRetryAttachmentCleanup`, guarded for system sessions or `collaboration.admin`. With the host's configured scheduler, it retries a bounded batch of 100 every minute. Kof22 already supplies a scheduler name. Without a scheduler, operators must invoke the recovery process; it is not safe to assume background retries exist.
|
||||
|
||||
Apply the generated PostgreSQL schema before enabling this version; it creates the additional cleanup table and due-time index. Attachment ID is optional because rejected uploads may never have a committed ID. The upgrade preserves existing cleanup rows and removes the earlier attachment-ID uniqueness constraint. Do not remove that table during application rollback while cleanup remains pending. Existing releases do not know how to drain it.
|
||||
|
||||
Verification covers real PostgreSQL metadata rejection, outer upload/delete rollback, commit, provider failure followed by a new recovery service, loss of post-commit callbacks, failed queue acknowledgement and repeat replay. Deterministic storage faults also cover a provider that writes bytes before reporting upload failure and two concurrent cleanup workers. A real MinIO fixture, accessed through the published object-storage QBit, verifies deletion replay in plain and versioned buckets while preserving another object's exact bytes. This is not evidence for every object-storage provider. The separate abrupt-JVM test described below uses fixture file storage.
|
||||
|
||||
Failed upload rollback compensation now records its locator independently of the rolled-back transaction before attempting storage cleanup. Failed worker attempts persist a next-attempt time at least 60 seconds later; this lets healthy requests beyond a failing batch proceed. Tests verify later recovery once the failed requests become due.
|
||||
|
||||
Upload I/O and runtime failures attempt durable compensation and preserve the original exception, adding cleanup failure as a suppressed exception. Deferred provider completion is covered by the settlement protocol below.
|
||||
|
||||
Full Maven verification including operator settlement passes 206 tests, zero failures, errors or skips, plus packaging, help coverage, architecture guardrails and JaCoCo checks. PostgreSQL tests cover the retained receipt, repeated requests, active owners, existing references, rejected receipt writes, provider failure and the real form/Review/Result flow with permission revocation. Coordinated host adoption and staging rollback still require separate verification.
|
||||
|
||||
On backends supporting transactional FOR UPDATE SKIP LOCKED (including the PostgreSQL host), upload now commits a cleanup locator before calling storage and holds its row lock for the upload transaction. Metadata insertion and removal of that locator share the same transaction. Recovery workers skip active uploads and hold the same lock while deleting abandoned bytes and acknowledging cleanup. An existing caller transaction retains ownership until it ends; otherwise the service starts its own transaction after spooling the input. Backends without this locking capability retain callback-based compensation and are not covered by this ownership guarantee.
|
||||
|
||||
Focused PostgreSQL tests verify the locator exists before provider I/O, cleanup skips an active upload, and recovery succeeds after an interrupted upload whose rollback callbacks were deliberately suppressed. That test simulates callback loss. A separate child JVM executes the actual upload service against the disposable PostgreSQL database, persists fixture bytes, then calls Runtime.halt before returning from the provider. The parent verifies exit code 73, absent metadata, retained intent and bytes, and successful idempotent recovery using the original locator. This proves abrupt JVM termination recovery, not power-loss durability or late remote-provider completion. Another test rejects intent insertion in PostgreSQL and verifies zero provider writes or deletes; a completed upload in an open outer transaction remains protected from cleanup until commit. Concurrent workers verify that only one worker enters provider deletion for a locator.
|
||||
|
||||
Issue #1289 remains open for coordinated host adoption and application rollback acceptance. Row locking does not make external object storage and the database one atomic transaction.
|
||||
|
||||
## Unreleased late-upload settlement change
|
||||
|
||||
A PostgreSQL regression reproduced a PUT finishing after timeout rollback had deleted the still-empty location and acknowledged its cleanup row. Upload intents (no attachment ID) now use AttachmentStorage.cleanupUpload, which removes current bytes and reports whether an earlier PUT can still complete. The generic adapter cannot prove settlement and returns false. Recovery retains the locator for later sweeps, with the existing 60-second backoff and 100-row batch. Ordinary committed deletion and successful upload acknowledgement remain unchanged.
|
||||
|
||||
Adapters may report settlement only from authoritative completion/cancellation information, never merely object absence. A deterministic deferred-write fixture verifies the late-completion ordering; this does not establish remote S3 settlement. Generic uncertain-upload records remain pending until the provider proves settlement or an administrator performs the explicit operation below. There is no age-based expiry.
|
||||
|
||||
## Resolve a settled upload
|
||||
|
||||
In the Attachment Cleanup table, select upload intents and run **Resolve settled uploads**. An identified user with `collaboration.admin` must supply provider completion or cancellation evidence covering every selected locator, then confirm the standard Review screen. A support case or request receipt establishing completion is suitable; object absence, an elapsed timeout, or an application process stopping does not establish that an external provider has stopped writing. If settlement cannot be established, leave the intent pending.
|
||||
|
||||
Apply rechecks permission, locks each row, rejects active ownership and existing attachment references, and deletes current bytes before saving completion. Provider or database failures leave the intent unresolved for retry. Committed attachment deletions use the ordinary cleanup retry operation instead.
|
||||
|
||||
The service-managed row retains `resolvedAt`, `resolvedBy` and `settlementEvidence`, together with the original locator. Repeated settlement does not overwrite that receipt; scheduled retries exclude resolved rows. Direct edits and deletes remain denied even to administrators. These fields provide the durable operational receipt without relying on the framework's best-effort audit writer. Store a reference to provider evidence, never credentials or private object contents.
|
||||
|
||||
Apply the additive settlement columns and pending-row index before this code runs. Kof22's corresponding migration is `2026-10-26-collaboration-upload-settlement.sql`. Retain receipts and pending intents during rollback. Do not start an earlier cleanup worker that ignores `resolvedAt`: it could consume retained receipts. Roll back to the pre-cleanup application with cleanup processing disabled, or use a version that understands settlement receipts.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Core dependency: after-rollback callbacks
|
||||
|
||||
Collaboration's retained-upload and cleanup paths compensate external object storage only after a transaction's
|
||||
rollback is confirmed. They need two methods on `com.kingsrook.qqq.backend.core.actions.QBackendTransaction` that the
|
||||
released core `4.1.0-RC.21` does not declare. This module does not compile against RC.21 until a core release that has them
|
||||
is available. This branch now defaults to `4.1.0-SNAPSHOT`; the verified source is Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f`. Build/install that Core branch first for local development. No compatibility shim is provided (a shim could not confirm rollback and would weaken the guarantee).
|
||||
|
||||
## Required API (reviewed implementation: `b6fb2382`, `third-party-src/qqq-core`)
|
||||
|
||||
`qqq-backend-core`, class `QBackendTransaction`:
|
||||
|
||||
```java
|
||||
public boolean supportsRollbackCallbacks() // default: false
|
||||
public void addAfterRollbackCallback(Runnable callback) // throws UnsupportedOperationException unless supportsRollbackCallbacks(); Objects.requireNonNull(callback)
|
||||
protected void runAfterRollbackCallbacks() // run once, in order, then clear; exceptions (incl. LinkageError) logged and swallowed
|
||||
```
|
||||
|
||||
Pending rollback callbacks are preserved when a required operation marks the transaction rollback-only, and run only after confirmed JDBC rollback. A failed physical commit clears them because its outcome is uncertain. Successful commit discards them.
|
||||
|
||||
`qqq-backend-module-rdbms`, class `RDBMSTransaction`:
|
||||
|
||||
```java
|
||||
@Override public boolean supportsRollbackCallbacks() // true
|
||||
@Override public void addAfterRollbackCallback(Runnable callback) // super.add...; then rolledBackSinceCommit = false
|
||||
// rollback()/close(): after connection.rollback() succeeds -> rolledBackSinceCommit = true; runAfterRollbackCallbacks()
|
||||
// close() rolls back (and so runs callbacks) unless a rollback already happened since the last commit / callback registration
|
||||
```
|
||||
|
||||
## Callers in this module
|
||||
- `attachments/AttachmentsService.storeUpload`: refuses an ambient transaction where `!supportsRollbackCallbacks()`; registers storage compensation.
|
||||
- `attachments/AttachmentCleanupQueue.beginUpload`: registers the same cleanup on both `addAfterRollbackCallback` and `addAfterCommitCallback`.
|
||||
|
||||
## Core tests to carry with the change
|
||||
- Base class: `supportsRollbackCallbacks()` is false; `addAfterRollbackCallback` throws `UnsupportedOperationException`.
|
||||
- RDBMS: callback runs after `rollback()`; does not run after `commit()`; runs when a transaction with a registered callback is closed without commit;
|
||||
a callback registered after an earlier rollback still runs on close; a throwing callback does not stop later callbacks.
|
||||
- Integration (already in this module, needs the core): `attachments/AttachmentTransactionsPostgresTest` (needs Docker) incl. `interruptedUploadSurvivesLostRollbackCallback`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# QBit integration known issues
|
||||
|
||||
Updated: 2026-11-04.
|
||||
|
||||
- This branch requires the Core integration snapshot described in [CORE-DEPENDENCY.md](CORE-DEPENDENCY.md). Public Core RC.21 lacks rollback callbacks; public Collaboration RC.6 lacks the retained-upload SPI. A compatible published dependency set remains a release requirement.
|
||||
- Local owner verification is complete: 269 tests and style/coverage gates pass, including both journal providers. Deployment-specific object storage, database failover, backup/restore and retention policies still require host qualification.
|
||||
- Docker must be running for attachment transaction tests. Enable both `mysqlJournalTests` and `postgresJournalTests` for the additional journal-provider cases. On Windows use `-Dexec.skip=true` for the inherited POSIX console-summary command; all verification gates still run.
|
||||
- Redis presence and unrelated private-cohort features were intentionally excluded. This attachment integration has no compile-time dependency on Record Files, Scripting or Document Generation.
|
||||
- No release tag, public artifact publication or deployment is claimed by this branch. The matching Next UI and host configuration must be verified before rollout.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Collaboration attachment integration status
|
||||
|
||||
Updated: 2026-11-04. Implementation and local owner verification complete; this feature branch is not a published release.
|
||||
|
||||
## What changed
|
||||
|
||||
The attachment lifecycle, retained upload journal, recovery and cleanup APIs are preserved on canonical Collaboration. The default Core dependency is now the compatible `4.1.0-SNAPSHOT` from Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f` (see [setup](CORE-DEPENDENCY.md)). Recovery queries now handle tables without supplemental metadata instead of throwing a null-pointer exception.
|
||||
|
||||
## Verification
|
||||
|
||||
Using Java 21, the Maven wrapper, Docker and that locally installed Core snapshot:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd '-DmysqlJournalTests=true' '-DpostgresJournalTests=true' '-Dexec.skip=true' install
|
||||
```
|
||||
|
||||
All **269 tests passed**, with zero failures, errors or skips. Checkstyle and the JaCoCo coverage gates passed. This includes 29 PostgreSQL attachment transaction/recovery cases, 11 MySQL journal cases, 12 PostgreSQL journal cases, HTTP API tests and dependency-boundary guardrails. Each provider fixture creates and removes its own disposable services; no existing database is used. The first run exposed the fixed null handling and one transient Windows socket allocation failure; the complete corrected run passed.
|
||||
|
||||
`-Dexec.skip=true` avoids the inherited POSIX console-summary command on Windows; it does not skip tests, Checkstyle or coverage. The artifact was installed into an isolated local Maven repository as `com.kingsrook.qbits:qbit-collaboration:0.1.0-SNAPSHOT`.
|
||||
|
||||
## Downstream consumer verification
|
||||
|
||||
Record Files `develop` at `162af9a7b8cd9c2673c26c88f00323cd90e85e48` compiled and passed its default `verify` lifecycle against this locally installed Collaboration snapshot and Core `4.1.0-SNAPSHOT` using `-Dqqq.version=4.1.0-SNAPSHOT`. Surefire reported 278 tests: 220 executed successfully and 58 optional MySQL/PostgreSQL cases skipped. Style/coverage gates passed. This proves consumer API compatibility and the exercised HTTP, retention and recovery paths; it does not claim the skipped Record Files provider qualification or its full release.
|
||||
|
||||
## Release boundary
|
||||
|
||||
Core and Collaboration must be published as a compatible dependency set before adoption from public Maven coordinates. Passing local fixtures does not qualify a deployment's storage, backup/restore or database failover. See [known issues and remaining release requirements](QBIT-KNOWN-ISSUES.md).
|
||||
@@ -0,0 +1,49 @@
|
||||
# Retained upload journal
|
||||
|
||||
The Collaboration-owned journal is wired into retained `AttachmentsService` upload and recovery. It persists candidate metadata before object creation, owns the attachment/checkpoint transaction, and exposes native read-only recovery navigation. It has no Record Files dependency and stores no second copy of file bytes.
|
||||
|
||||
Historical private-cohort evidence (superseded by [current verification](QBIT-RELEASE-STATUS.md)): the integrated owner snapshot passed all **214 tests**, including 10 real MySQL provider cases and 11 H2 journal regressions, and was installed under `0.1.0-record-files-SNAPSHOT`. The subsequent doublewrite/session-read-only delta compiles and awaits its provider rerun. This document does not qualify Aurora, other SQL vendors, external object storage, disks, backups, or a host deployment.
|
||||
|
||||
## Ownership and recovery
|
||||
|
||||
1. `prepare` independently commits the immutable server-built candidate before the first PUT. Matching retries reuse it; changed manifests conflict.
|
||||
2. `reserve` locks the row and grants a bounded lease with a monotonically increasing fence. `renew` extends only a still-live exact reservation at synchronous stage boundaries.
|
||||
3. `recordStorage(UNKNOWN)` precedes conditional object creation. `VERIFIED` means the storage owner proved the exact length and SHA-256.
|
||||
4. `register` passes its real transaction to the attachment registrar. The attachment insert and REGISTERED checkpoint commit together; callback failure rolls both back.
|
||||
5. The retained lifecycle registration runs after commit and is retried idempotently. The journal becomes COMPLETE only after it succeeds. Completed coordinated retries still invoke required lifecycle registration without adding a second attachment or activity event.
|
||||
6. Recovery reauthorizes the original actor and current source. It verifies the stored original without overwriting it and resumes registration. Missing or unreadable bytes remain uncertain; timeout never proves absence.
|
||||
|
||||
The owner rejects ambient/no-op transactions. SQL values are bound; mutable operations lock rows, check the token/fence/expiry, and require exactly one guarded update. Reads reconstruct manifest hashes. Private locator and lease columns are absent from QQQ metadata and forbidden in native filters/projections; native direct writes and unscoped totals are denied. Current actor/source authorization remains in the owner service and native journal security, not in the low-level JDBC port.
|
||||
|
||||
Disabled or deleted original actors still need an explicitly designed administrative reconciliation flow. No fabricated session or broad archive permission substitutes for that flow. Broad post-filtered listings can contain short pages; source-pinned native recovery navigation is the supported user entry point.
|
||||
|
||||
## Database boundaries
|
||||
|
||||
Both owner tables must use the same QQQ backend. Hosts apply DDL through their migrations; runtime code never creates tables or changes server-global settings.
|
||||
|
||||
| Backend | Runtime requirements | Schema |
|
||||
| --- | --- | --- |
|
||||
| File H2 | Native `h2`, `jdbc:h2:file:`, actual configured connection, writable real transaction, live `WRITE_DELAY=0` | `UploadJournalTable.h2Script()` |
|
||||
| MySQL 8.4 | Native `mysql`, actual MySQL 8.4 product/version, exact configured URL target and catalog, writable real transaction, both owner tables InnoDB, fixed owner table mappings, journal `utf8mb4_0900_bin` columns/table, single-column unique locator/intake indexes, synchronous commit settings | `UploadJournalTable.mysqlScript()` |
|
||||
| PostgreSQL 17 | Native `postgres` backend, configured database, permanent owner tables, deterministic comparison, READ COMMITTED, UTC, 1–5000 ms lock timeout, synchronous commits with fsync/full_page_writes enabled | `UploadJournalTable.postgresScript()` |
|
||||
| Aurora MySQL, MariaDB, other versions/providers | Refused; no inferred compatibility or durability claim | None |
|
||||
|
||||
The MySQL adapter resolves explicit or native-generated URLs through QQQ `ConnectionManager`. It accepts a single ordinary `jdbc:mysql://host:port/catalog` target with a simple catalog identifier. Proxy/load-balancing URLs, alternate catalogs or table remapping fail closed. Error messages do not echo URLs or credentials. Driver options remain host-owned; TLS and credentials are not changed by the journal.
|
||||
|
||||
Each MySQL operation checks `innodb_flush_log_at_trx_commit=1`, and `sync_binlog=1` when binary logging is enabled; a read-only server or session is refused. It also requires `innodb_doublewrite` to be `ON` or `DETECT_AND_RECOVER`; [detect-only mode does not retain recoverable page copies](https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html). These settings follow the [MySQL 8.4 InnoDB durability documentation](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) and [binary-log durability documentation](https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_sync_binlog). Hardware that misreports flush completion can still lose data; replication/failover, backups, restore and disk guarantees remain deployment qualifications. Runtime settings are checked at transaction entry; an administrator changing server settings concurrently is outside the application trust boundary.
|
||||
|
||||
The MySQL journal uses millisecond timestamps with explicit UTC JDBC calendars, InnoDB, enforced CHECK constraints and binary NO PAD string comparisons. Its live session must report UTC (+00:00/UTC, or SYSTEM with a UTC system zone); the journal does not silently change a pooled session timezone. Hosts should configure the native JDBC driver timezone consistently for generic QQQ date fields as well. The host must configure each journal connection with `innodb_lock_wait_timeout` between 1 and 5 seconds, through its backend JDBC sessionVariables/pool setup (or a dedicated database default). Qualification reads and preserves that session setting; it never changes it or other pooled-session/global settings. Each statement also has a ten-second query timeout. Duplicate error 1062 is distinguished from unrelated integrity failures; deadlock/lock-timeout failures remain typed BUSY responses, never fabricated success.
|
||||
|
||||
## Reproducible tests
|
||||
|
||||
Use Java 21, the Maven wrapper and the matching Core integration snapshot. Docker is required by the attachment transaction tests. Enable both additional journal-provider fixtures for complete verification:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd '-DmysqlJournalTests=true' '-DpostgresJournalTests=true' '-Dexec.skip=true' verify
|
||||
```
|
||||
|
||||
`MySqlUploadJournalTest` creates only its own Testcontainers MySQL 8.4.13 container, random synthetic credentials and private anonymous data volume, and destroys that container on completion. It does not use existing host services. The image is pinned to `mysql@sha256:a0225d1ea6631c8bc2385dc4c2faba17bbf1834ecf369e1659a9a382bd8d5dd7`. Credentials are not printed by the fixture.
|
||||
|
||||
The eleven provider cases cover acknowledged prepare surviving a real database process restart, attachment/checkpoint rollback and completion replay, simultaneous prepare/lease fencing, wrong backend/catalog/mapping/ambient/Aurora refusal, actual non-durable server settings and MyISAM refusal, binary identifier comparisons and native write denial, actual lock-timeout/BUSY behavior, column/index drift and immutable conflict, and lease instants across differing JVM default zones, preservation/refusal of host session lock-timeout settings, and refusal of detect-only doublewrite/read-only sessions without changing their settings. The attachment schema in this suite is deliberately a minimal transaction fixture; this is not an end-to-end MySQL object-storage or tenant/archive qualification. Record Files owns its separate provider integration tests.
|
||||
|
||||
First provider run compiled but failed its restart fixture because Docker reassigned the dynamic port and Testcontainers retained a cached mapping; two subsequent setup errors followed that stale endpoint. The fixture now refreshes the inspected binding after restart and uses bounded JDBC readiness checks. Docker wire logs are suppressed before container creation. Expanded snapshot verification: **20 tests passed (9 MySQL, 11 H2), zero failures/errors/skips**, `target/mysql-journal-focused-verification.log`, completed 2026-10-31 00:01:44. The tenth provider case and full 214-test owner quality gates subsequently passed; the corrected development-GAV artifact SHA-256 is `72D6E36E77B92BE3ECFE9BE7042D292C76B308AA05A9E43BD490B229BD1A75E2`. Fresh verification on 2026-11-04 passed all 269 tests, including all 11 MySQL journal cases, 12 PostgreSQL journal cases and 29 PostgreSQL attachment transaction cases. Checkstyle and coverage gates passed against Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f`; no provider cases were skipped.
|
||||
@@ -110,10 +110,88 @@
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- upload journal and attachment transaction suites: real JDBC (H2, MySQL and PostgreSQL 17 containers) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.4.240</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.8</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- native PostgreSQL module (vendor/type "postgres"), version managed by the qqq BOM -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-collaboration.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-collaboration.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version>
|
||||
<executions><execution><id>package-license-notices</id><phase>process-resources</phase><goals><goal>copy-resources</goal></goals>
|
||||
<configuration><outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory><resources><resource>
|
||||
<directory>${project.basedir}</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes>
|
||||
</resource></resources></configuration>
|
||||
</execution></executions>
|
||||
</plugin>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -196,14 +274,14 @@
|
||||
<profiles>
|
||||
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
|
||||
<!-- imports the released 4.1 BOM, this profile is active by default and -->
|
||||
<!-- imports qqq-bom-pom 4.1.0-RC.6 ahead of the parent's 4.0.0 BOM. -->
|
||||
<!-- imports the Core integration snapshot with confirmed rollback callbacks. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-SNAPSHOT</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -64,6 +64,7 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
private Boolean adminApp = true;
|
||||
private Boolean uiApi = true;
|
||||
private QCodeReference attachmentStorage = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentStorage.class);
|
||||
private QCodeReference attachmentLifecycle = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy.class);
|
||||
private Integer recentsPerUser = 200;
|
||||
private String attachmentsBucketName = "collaboration";
|
||||
private Long defaultMaxBytes = 100L * 1024 * 1024;
|
||||
@@ -103,6 +104,23 @@ public class CollaborationQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host policy for retained attachments and access auditing.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getAttachmentLifecycle()
|
||||
{
|
||||
return (attachmentLifecycle);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure lifecycle enforcement without coupling to a consuming qbit.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAttachmentLifecycle(QCodeReference attachmentLifecycle)
|
||||
{
|
||||
this.attachmentLifecycle = java.util.Objects.requireNonNull(attachmentLifecycle, "attachmentLifecycle");
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
package com.kingsrook.qbits.collaboration;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
@@ -29,6 +32,7 @@ import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
@@ -43,13 +47,41 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-collaboration";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private CollaborationQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = CollaborationQBitProducer.class.getClassLoader().getResourceAsStream("qbit-collaboration.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-collaboration.properties; build with Maven to generate the artifact version.");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-collaboration.properties; build with Maven resource filtering.");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read the artifact version from qbit-collaboration.properties.", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the qbit.
|
||||
*******************************************************************************/
|
||||
@@ -151,6 +183,11 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
|
||||
}
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RetryAttachmentCleanupProcess.metadata(qBitConfig));
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.ResolveAttachmentUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.RecoverRetainedUploadProcess.metadata());
|
||||
output.add(com.kingsrook.qbits.collaboration.attachments.AdoptRetainedAttachmentProcess.metadata());
|
||||
output.add(FeatureSettingsDefaultsProcess.process());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
output.add(source);
|
||||
@@ -178,6 +215,8 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
|
||||
QAppMetaData parent = instance.getApp(qBitConfig.getParentAppName());
|
||||
parent.withChild(instance.getApp(qBitConfig.getAppName()));
|
||||
}
|
||||
// Registration is metadata-only; core startup sync skips hosts without helpContent.
|
||||
HelpBundles.register(instance, "help/qbit-collaboration.yaml");
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,10 @@ public final class CollaborationRecords
|
||||
public static QTableMetaData cleanupMetadata(String table)
|
||||
{
|
||||
QTableMetaData metadata = serviceMetadata(table);
|
||||
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
|
||||
if(metadata.getSupplementalMetaData() != null)
|
||||
{
|
||||
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
|
||||
}
|
||||
metadata.setRecordSecurityLocks(null);
|
||||
return (metadata);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ 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 + (field.getName().equals("parentDeleted") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") || field.getName().equals("retained") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
@@ -78,6 +78,10 @@ public final class CollaborationSchema
|
||||
}
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_key VARCHAR(64)");
|
||||
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_fingerprint VARCHAR(64)");
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_attachment_intake ON collaboration_attachment (intake_key) WHERE intake_key IS NOT NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)");
|
||||
@@ -95,6 +99,15 @@ public final class CollaborationSchema
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_owner ON collaboration_record_assignment (owner_user_id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_assignee ON collaboration_record_assignment (assignee_user_id)");
|
||||
statements.add("ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_by VARCHAR(255)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS settlement_evidence VARCHAR(2000)");
|
||||
statements.add("ALTER TABLE collaboration_attachment_cleanup ALTER COLUMN attachment_id DROP NOT NULL");
|
||||
statements.add("DROP INDEX IF EXISTS uq_collaboration_attachment_cleanup_1");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_storage_key ON collaboration_attachment USING HASH (storage_key)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_due ON collaboration_attachment_cleanup (next_attempt_at, id)");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_pending ON collaboration_attachment_cleanup (next_attempt_at, id) WHERE resolved_at IS NULL");
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_mention_unread ON collaboration_mention (mentioned_user_id) WHERE notified_at IS NOT NULL AND read_at IS NULL");
|
||||
return (String.join(";\n", statements) + ";\n");
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
|
||||
entry.put("displayName", viewer.displayName());
|
||||
entry.put("mode", viewer.mode());
|
||||
entry.put("fieldName", viewer.fieldName());
|
||||
entry.put("connections", viewer.connections());
|
||||
return (entry);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ public final class CoreCollaboration
|
||||
{
|
||||
config.applyFeatures(instance);
|
||||
var features = RecordFeaturesMetaData.ofOrWithNew(instance);
|
||||
features.withFollowersProvider(new QCodeReference(com.kingsrook.qbits.collaboration.follow.CollaborationFollowersProvider.class));
|
||||
features.withOverrideProvider(new QCodeReference(FeatureSettingsProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.tags.TagsMetaDataProvider.class));
|
||||
features.getRuntimeProviders().add(new QCodeReference(com.kingsrook.qbits.collaboration.customfields.CustomFieldsProvider.class));
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FeaturePermissionMode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The collaboration_feature_setting rows that say, in the database, exactly
|
||||
** what the code configuration already says: one INSTANCE row per feature
|
||||
** (enabled for the configured default features, disabled for the rest, with
|
||||
** the default permission mode and the TAGS mode), plus an APP or TABLE row
|
||||
** wherever the code declares something at that scope.
|
||||
**
|
||||
** Why rows computed at run time and not a SQL seed: the right rows depend on
|
||||
** the host's configuration (withDefaultFeatures, withTableFeatures, the
|
||||
** default tag mode, features declared on apps and tables), which SQL cannot
|
||||
** see.
|
||||
**
|
||||
** Why every declaring scope gets its own row: runtime overrides are applied
|
||||
** AFTER the code declarations, instance first, then app, then table. A bare
|
||||
** INSTANCE row would therefore overwrite what a table declares in code.
|
||||
** Restating each declaring scope in its own row keeps the resolved result of
|
||||
** every table identical with and without the rows (FeatureSettingsDefaultsTest
|
||||
** proves that for several configurations).
|
||||
**
|
||||
** Rows are only ever created when the table is empty, so they can never
|
||||
** disagree with rows an administrator already wrote.
|
||||
*******************************************************************************/
|
||||
public final class FeatureSettingsDefaults
|
||||
{
|
||||
public static final String INSTANCE_SCOPE_NAME = "instance";
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FeatureSettingsDefaults()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows to create: the configured rows when the table is empty, none
|
||||
** when an administrator already has rows.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> missingRows(QInstance instance, CollaborationQBitConfig config) throws QException
|
||||
{
|
||||
if(!CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1)).isEmpty())
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (configuredRows(instance, config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every row the configuration implies (whether or not it exists yet).
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> configuredRows(QInstance instance, CollaborationQBitConfig config)
|
||||
{
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// instance: every feature, so each one has a visible row //
|
||||
///////////////////////////////////////////////////////////
|
||||
RecordFeaturesMetaData instanceLevel = RecordFeaturesMetaData.of(instance);
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
Map<String, Serializable> settings = new LinkedHashMap<>(declaredSettings(instanceLevel, feature));
|
||||
if(feature == RecordFeature.TAGS)
|
||||
{
|
||||
settings.putIfAbsent("mode", config.getDefaultTagMode());
|
||||
}
|
||||
FeaturePermissionMode mode = declaredMode(instanceLevel, feature);
|
||||
rows.add(row("INSTANCE", INSTANCE_SCOPE_NAME, feature, enabledThrough(feature, instanceLevel), mode == null ? FeaturePermissionMode.EDIT_OR_GRANTED : mode, settings));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// apps and tables: only where the code declares something for the feature //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
|
||||
{
|
||||
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(app);
|
||||
for(RecordFeature feature : declared(level))
|
||||
{
|
||||
rows.add(row("APP", app.getName(), feature, enabledThrough(feature, instanceLevel, level), modeThrough(feature, instanceLevel, level), declaredSettings(level, feature)));
|
||||
}
|
||||
}
|
||||
for(QTableMetaData table : CollectionUtils.nonNullMap(instance.getTables()).values())
|
||||
{
|
||||
if(table.getName().startsWith("collaboration_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
RecordFeaturesMetaData level = RecordFeaturesMetaData.of(table);
|
||||
List<RecordFeaturesMetaData> apps = new ArrayList<>();
|
||||
for(QAppMetaData app : CollectionUtils.nonNullMap(instance.getApps()).values())
|
||||
{
|
||||
boolean contains = CollectionUtils.nonNullList(app.getSections()).stream().anyMatch(section -> CollectionUtils.nonNullList(section.getTables()).contains(table.getName()));
|
||||
if(contains && RecordFeaturesMetaData.of(app) != null)
|
||||
{
|
||||
apps.add(RecordFeaturesMetaData.of(app));
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// a table row restates the whole chain (instance, its apps, the table). //
|
||||
// Needed where the table declares something itself, and where several //
|
||||
// apps declare the same feature: one row per app cannot restate the //
|
||||
// combination, but the last override, the table row, can. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
Set<RecordFeature> features = new TreeSet<>(declared(level));
|
||||
if(apps.size() > 1)
|
||||
{
|
||||
apps.forEach(app -> features.addAll(declared(app)));
|
||||
}
|
||||
List<RecordFeaturesMetaData> chain = new ArrayList<>();
|
||||
chain.add(instanceLevel);
|
||||
chain.addAll(apps);
|
||||
chain.add(level);
|
||||
RecordFeaturesMetaData[] levels = chain.toArray(new RecordFeaturesMetaData[0]);
|
||||
for(RecordFeature feature : features)
|
||||
{
|
||||
Map<String, Serializable> settings = new LinkedHashMap<>();
|
||||
for(RecordFeaturesMetaData each : levels)
|
||||
{
|
||||
settings.putAll(declaredSettings(each, feature));
|
||||
}
|
||||
rows.add(row("TABLE", table.getName(), feature, enabledThrough(feature, levels), modeThrough(feature, levels), settings));
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The features a scope declares anything about (enabled, mode or settings).
|
||||
*******************************************************************************/
|
||||
private static Set<RecordFeature> declared(RecordFeaturesMetaData level)
|
||||
{
|
||||
if(level == null)
|
||||
{
|
||||
return (Set.of());
|
||||
}
|
||||
return (Stream.of(level.getEnabled().keySet(), level.getPermissionModes().keySet(), level.getSettings().keySet())
|
||||
.flatMap(Set::stream).collect(Collectors.toCollection(TreeSet::new)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled as the code resolves it through the given levels, in order, the
|
||||
** same way core does (a later level that declares the feature wins).
|
||||
*******************************************************************************/
|
||||
private static boolean enabledThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
|
||||
{
|
||||
boolean enabled = false;
|
||||
for(RecordFeaturesMetaData level : levels)
|
||||
{
|
||||
if(level != null && level.getEnabled().containsKey(feature))
|
||||
{
|
||||
enabled = Boolean.TRUE.equals(level.getEnabled().get(feature));
|
||||
}
|
||||
}
|
||||
return (enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The permission mode through the given levels (a later declaration wins),
|
||||
** EDIT_OR_GRANTED when none declares one, as core resolves it.
|
||||
*******************************************************************************/
|
||||
private static FeaturePermissionMode modeThrough(RecordFeature feature, RecordFeaturesMetaData... levels)
|
||||
{
|
||||
FeaturePermissionMode mode = FeaturePermissionMode.EDIT_OR_GRANTED;
|
||||
for(RecordFeaturesMetaData level : levels)
|
||||
{
|
||||
if(declaredMode(level, feature) != null)
|
||||
{
|
||||
mode = declaredMode(level, feature);
|
||||
}
|
||||
}
|
||||
return (mode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static FeaturePermissionMode declaredMode(RecordFeaturesMetaData level, RecordFeature feature)
|
||||
{
|
||||
return (level == null ? null : level.getPermissionModes().get(feature));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Serializable> declaredSettings(RecordFeaturesMetaData level, RecordFeature feature)
|
||||
{
|
||||
return (level == null || level.getSettings().get(feature) == null ? Map.of() : level.getSettings().get(feature));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One feature_setting record; no settings means a null settings column.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String scopeType, String scopeName, RecordFeature feature, boolean enabled, FeaturePermissionMode mode, Map<String, Serializable> settings)
|
||||
{
|
||||
QRecord record = new QRecord().withValue("scopeType", scopeType).withValue("scopeName", scopeName).withValue("feature", feature.name())
|
||||
.withValue("enabled", enabled).withValue("permissionMode", mode == null ? null : mode.name());
|
||||
if(!settings.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
record.withValue("settings", MAPPER.writeValueAsString(settings));
|
||||
}
|
||||
catch(JsonProcessingException e)
|
||||
{
|
||||
throw (new IllegalStateException("Feature settings are not serializable", e));
|
||||
}
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.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.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
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.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Create rows from configuration": a table action on Feature Settings that
|
||||
** writes the code configuration out as editable rows (FeatureSettingsDefaults).
|
||||
** It changes no behavior on its own, only makes the effective settings visible
|
||||
** and editable. It does nothing once the table has any row. Standard streamed
|
||||
** ETL: Review lists the rows that will be created, Result the rows created.
|
||||
*******************************************************************************/
|
||||
public final class FeatureSettingsDefaultsProcess
|
||||
{
|
||||
public static final String NAME = "collaborationCreateFeatureSettings";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FeatureSettingsDefaultsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.FEATURE_SETTING);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.FEATURE_SETTING);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These rows will be created from the current configuration. Nothing changes: the rows say what the configuration already says, and you can edit them afterwards.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Create rows from configuration");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(CollaborationTables.FEATURE_SETTING);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("collaboration")
|
||||
.withCustomPermissionChecker(new QCodeReference(AdminOnly.class)));
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration administrators only (the table itself is admin-write).
|
||||
*******************************************************************************/
|
||||
public static class AdminOnly implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || !session.hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + CollaborationPermissions.ADMIN + " permission."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the configured rows (none when the table already has rows).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : FeatureSettingsDefaults.missingRows(QContext.getQInstance(), CollaborationRecords.config()))
|
||||
{
|
||||
getRecordPipe().addRecord(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: counts by scope for the review screen.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byScope = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String scope = record.getValueString("scopeType");
|
||||
byScope.computeIfAbsent(scope, s -> line(Status.OK, scope + " setting will be created", scope + " settings will be created",
|
||||
scope + " setting was created", scope + " settings were created")).incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byScope.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Feature settings already has rows, so nothing was created. Edit the rows directly."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the standard action with the administrator's own session.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine created = line(Status.OK, "setting was created", "settings were created", "setting was created", "settings were created");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One transaction for the whole load: the override refresh waits for the
|
||||
** commit, so a failure on a later page can never leave some rows (for
|
||||
** example Instance rows without the App and Table rows that restate the
|
||||
** code) in effect.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QBackendTransaction> openTransaction(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (Optional.of(QBackendTransaction.openFor(new InsertInput(CollaborationTables.FEATURE_SETTING))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> inserted = new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withRecords(input.getRecords())
|
||||
.withTransaction(getTransaction().orElse(null))).getRecords();
|
||||
for(QRecord record : inserted)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not create feature settings: " + record.getErrors()));
|
||||
}
|
||||
created.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
created.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with its four messages.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String singularFuture, String pluralFuture, String singularPast, String pluralPast)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(singularFuture);
|
||||
line.setPluralFutureMessage(pluralFuture);
|
||||
line.setSingularPastMessage(singularPast);
|
||||
line.setPluralPastMessage(pluralPast);
|
||||
return (line);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
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.permissions.fieldaccess.FieldAccessHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -55,7 +56,7 @@ import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves targets only with released RC.5 APIs. Each list batches record label
|
||||
** Resolves targets through core permission and field-access APIs. Each list batches record label
|
||||
** refreshes by table, preserving table permissions and record security locks.
|
||||
** Next accepts the flat /app/name/ routes, including dotted process names.
|
||||
*******************************************************************************/
|
||||
@@ -131,6 +132,17 @@ public final class TargetResolver
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist record labels only when all label fields are FULL for this session.
|
||||
*******************************************************************************/
|
||||
public static String cacheableLabel(FavoriteEntry target)
|
||||
{
|
||||
return ("RECORD".equals(target.targetType())
|
||||
&& !FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(target.targetName())) ? null : target.label());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One QueryAction per referenced record table; no cached labels escape.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class CollaborationApi
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.0.0")
|
||||
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.2.0")
|
||||
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
|
||||
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
|
||||
.withSpec(ListFavoritesSpec.class)
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -112,10 +113,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
*******************************************************************************/
|
||||
static List<BasicResponse> errors()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -160,13 +161,14 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
|
||||
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
|
||||
case "includeDone" -> "Include completed and cancelled tasks; default false.";
|
||||
case "perConnection" -> "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.";
|
||||
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
|
||||
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
|
||||
case "q" -> "Text to search in display names or vocabulary.";
|
||||
default -> name;
|
||||
};
|
||||
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
|
||||
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
|
||||
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") || name.equals("perConnection") ? Type.BOOLEAN : Type.STRING)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -212,6 +214,19 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
protected static <T> Page<T> page(JsonInput input, List<T> values) throws QBadRequestException
|
||||
{
|
||||
int limit = limit(input);
|
||||
PagePosition position = pagePosition(input);
|
||||
int start = Math.min(position.offset(), values.size());
|
||||
int end = start + Math.min(limit, values.size() - start);
|
||||
String next = end < values.size() ? position.cursor(end) : null;
|
||||
return (new Page<>(values.subList(start, end), next));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared existing offset cursor contract, also used by bounded native lists.
|
||||
** Scope is a routing check, never authorization or a signed capability.
|
||||
*******************************************************************************/
|
||||
protected static PagePosition pagePosition(JsonInput input) throws QBadRequestException
|
||||
{
|
||||
int offset = 0;
|
||||
String scope = input.getSpec().defineBasicOperation().getPath() + input.getPathParams().toString()
|
||||
+ ":" + input.getQueryParam("table") + ":" + input.getQueryParam("pk") + ":" + input.getQueryParam("q");
|
||||
@@ -237,10 +252,25 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
}
|
||||
int start = Math.min(offset, values.size());
|
||||
int end = start + Math.min(limit, values.size() - start);
|
||||
String next = end < values.size() ? Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + end).getBytes(StandardCharsets.UTF_8)) : null;
|
||||
return (new Page<>(values.subList(start, end), next));
|
||||
return (new PagePosition(offset, prefix));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Same version, scope and offset bytes as previously emitted by page().
|
||||
*******************************************************************************/
|
||||
protected record PagePosition(int offset, String prefix)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Encode only a representable nonnegative continuation offset.
|
||||
*******************************************************************************/
|
||||
String cursor(long next) throws QBadRequestException
|
||||
{
|
||||
if(next < 0 || next > Integer.MAX_VALUE)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid cursor"));
|
||||
}
|
||||
return (Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + next).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+26
-6
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration.api;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
@@ -80,10 +81,22 @@ public final class CollaborationPresenceSchemas
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The snapshot is also the data shape of presence.changed events.
|
||||
** The snapshot is also the data shape of presence.changed events. Viewers are
|
||||
** included even when empty, whatever inclusion the serializer defaults to.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
|
||||
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers,
|
||||
Integer leaseCount, @JsonInclude(JsonInclude.Include.NON_NULL) List<PresenceRegistry.Viewer> leases) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The merged snapshot without per-connection detail.
|
||||
*******************************************************************************/
|
||||
public CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers)
|
||||
{
|
||||
this(table, pk, viewers, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table snapshot deliberately excludes record ids and focused field names.
|
||||
*******************************************************************************/
|
||||
@@ -93,14 +106,17 @@ public final class CollaborationPresenceSchemas
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
|
||||
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1")),
|
||||
"leaseCount", new Schema().withType(Type.INTEGER).withDescription("Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.").withNullable(true),
|
||||
"leases", new Schema().withType(Type.ARRAY).withDescription("One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.")
|
||||
.withNullable(true).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only public identity and focus are carried, never field contents.
|
||||
*******************************************************************************/
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
|
||||
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName, Integer connections, String connectionId, String sessionId, String since) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Editing takes precedence when one user has several open tabs.
|
||||
@@ -108,10 +124,14 @@ public final class CollaborationPresenceSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs (EDITING takes precedence), or one live connection in leases.")
|
||||
.withProperties(new TreeMap<>(Map.of(
|
||||
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
|
||||
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true),
|
||||
"connections", new Schema().withType(Type.INTEGER).withDescription("Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.").withNullable(true),
|
||||
"connectionId", text("Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.", true),
|
||||
"sessionId", text("Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.", true),
|
||||
"since", text("When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.", true).withFormat("date-time")))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,8 +70,17 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** An attachment visible through its parent record; no storage coordinates are exposed.
|
||||
*******************************************************************************/
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
|
||||
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName, Boolean canEdit) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve construction compatibility with the earlier attachment body.
|
||||
*******************************************************************************/
|
||||
public CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName)
|
||||
{
|
||||
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, createdByDisplayName, canDelete);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@@ -89,7 +98,8 @@ public final class CollaborationSchemas
|
||||
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
|
||||
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
|
||||
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
|
||||
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may delete this attachment; retained files cannot be deleted.").withNullable(true)),
|
||||
Map.entry("canEdit", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change the attachment filename or description.").withNullable(true)),
|
||||
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
@@ -490,8 +500,16 @@ public final class CollaborationSchemas
|
||||
/*******************************************************************************
|
||||
** Temporary access to file bytes. Clients follow url.
|
||||
*******************************************************************************/
|
||||
public record CollaborationDownloadV1(String url) implements ToSchema
|
||||
public record CollaborationDownloadV1(String url, String delivery) implements ToSchema
|
||||
{
|
||||
/***************************************************************************
|
||||
** Existing callers retain the storage URL contract.
|
||||
***************************************************************************/
|
||||
public CollaborationDownloadV1(String url)
|
||||
{
|
||||
this(url, "SIGNED_URL");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this versioned body for generated clients.
|
||||
*******************************************************************************/
|
||||
@@ -499,7 +517,8 @@ public final class CollaborationSchemas
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Temporary access to file bytes. Clients follow url.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Presigned URL, valid for five minutes. Clients follow url; never persist it.").withNullable(true))))));
|
||||
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Temporary delivery URL; never persist it.").withNullable(true)),
|
||||
Map.entry("delivery", new Schema().withType(Type.STRING).withDescription("SIGNED_URL or AUTHENTICATED_FETCH. Authenticated fetch requires the trusted native API transport and a durable receipt check.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +533,7 @@ public final class CollaborationSchemas
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Standard QQQ error response.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withDescription("Human-readable reason the operation failed.").withNullable(true))))));
|
||||
}
|
||||
}
|
||||
@@ -714,6 +733,10 @@ public final class CollaborationSchemas
|
||||
schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema());
|
||||
schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
|
||||
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
schemas.putAll(CollaborationWorkSchemas.components());
|
||||
return (schemas);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ public class DownloadAttachmentSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), false).toExternalForm()));
|
||||
var delivery = new AttachmentsService().downloadDelivery(id(input));
|
||||
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.api;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
@@ -48,7 +49,7 @@ public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (parameters("table!", "pk"));
|
||||
return (parameters("table!", "pk", "perConnection"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -68,6 +69,14 @@ public class GetPresenceSpec extends CollaborationJsonSpec
|
||||
{
|
||||
String table = required(input.getQueryParam("table"), "table");
|
||||
String pk = input.getQueryParam("pk");
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
|
||||
String perConnection = input.getQueryParam("perConnection");
|
||||
if(perConnection != null && !perConnection.equalsIgnoreCase("true") && !perConnection.equalsIgnoreCase("false"))
|
||||
{
|
||||
throw (new QBadRequestException("perConnection must be true or false"));
|
||||
}
|
||||
var service = new PresenceService();
|
||||
var viewers = service.snapshot(table, pk);
|
||||
var leases = service.connections(table, pk);
|
||||
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, viewers, leases.size(), "true".equalsIgnoreCase(perConnection) ? leases : null));
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,10 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
|
||||
int limit = limit(input);
|
||||
var position = pagePosition(input);
|
||||
var page = new AttachmentsService().listPage(table(input), pk(input), position.offset(), limit);
|
||||
String next = page.hasMore() ? position.cursor((long) position.offset() + page.items().size()) : null;
|
||||
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), next));
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ public class PreviewAttachmentSpec extends CollaborationJsonSpec
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), true).toExternalForm()));
|
||||
var delivery = new AttachmentsService().downloadDelivery(id(input), true);
|
||||
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
*******************************************************************************/
|
||||
public final class CollaborationHome
|
||||
{
|
||||
public static final String SETUP_WIDGET = "collaborationSetup";
|
||||
public static final String KPI_WIDGET = "collaborationKpis";
|
||||
public static final String TAGS_WIDGET = "collaborationTags";
|
||||
public static final String FIELDS_WIDGET = "collaborationCustomFields";
|
||||
public static final String PRESENCE_WIDGET = "collaborationWhosHere";
|
||||
public static final List<String> HOME_WIDGETS = List.of(KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
public static final List<String> HOME_WIDGETS = List.of(SETUP_WIDGET, KPI_WIDGET, TAGS_WIDGET, FIELDS_WIDGET, PRESENCE_WIDGET);
|
||||
|
||||
public static final String NOBODY_HERE = "Nobody else is here right now.";
|
||||
public static final String NO_TAGS = "No tags yet. Tags appear here once someone creates one on a record.";
|
||||
@@ -107,7 +108,7 @@ public final class CollaborationHome
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(HOME_WIDGETS)
|
||||
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ATTACHMENT_CLEANUP, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
return (app);
|
||||
}
|
||||
@@ -120,6 +121,7 @@ public final class CollaborationHome
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(
|
||||
widget(SETUP_WIDGET, "Setup", WidgetType.TABLE, SetupRenderer.class),
|
||||
widget(KPI_WIDGET, "Overview", WidgetType.MULTI_STATISTICS, KpiRenderer.class),
|
||||
widget(TAGS_WIDGET, "Tags", WidgetType.TABLE, TagsRenderer.class),
|
||||
widget(FIELDS_WIDGET, "Custom fields", WidgetType.TABLE, FieldsRenderer.class),
|
||||
@@ -140,6 +142,30 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each record feature, whether it is on, for which tables,
|
||||
** where that comes from, and the next step.
|
||||
*******************************************************************************/
|
||||
public static TableData setup() throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(CollaborationSetupStatus.Item item : CollaborationSetupStatus.load(QContext.getQInstance()))
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("feature", item.feature());
|
||||
row.put("state", item.state());
|
||||
row.put("where", item.where());
|
||||
row.put("source", item.source());
|
||||
row.put("next", item.nextStep() == null ? "" : item.nextStep());
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Setup", "Nothing to set up.", rows, column("Feature", "feature", "left"), column("State", "state", "left"), column("Where", "where", "left"),
|
||||
column("From", "source", "left"), column("Next step", "next", "left")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The KPI groups: conversations, work, files and custom fields.
|
||||
*******************************************************************************/
|
||||
@@ -200,7 +226,7 @@ public final class CollaborationHome
|
||||
row.put("records", tag.getValueInteger("usageCount") == null ? 0 : tag.getValueInteger("usageCount"));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Tags", NO_TAGS, rows, column("Label", "label", "3fr", "left"), column("Table", "table", "2fr", "left"), column("Records", "records", "1fr", "right")));
|
||||
return (table("Tags", NO_TAGS, rows, column("Label", "label", "left"), column("Table", "table", "left"), column("Records", "records", "right")));
|
||||
}
|
||||
|
||||
|
||||
@@ -224,8 +250,8 @@ public final class CollaborationHome
|
||||
row.put("indexState", friendly(definition.getValueString("indexState")));
|
||||
rows.add(row);
|
||||
}
|
||||
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "3fr", "left"), column("Table", "table", "2fr", "left"),
|
||||
column("Status", "status", "1fr", "left"), column("Index state", "indexState", "1fr", "left")));
|
||||
return (table("Custom fields", NO_FIELDS, rows, column("Name", "name", "left"), column("Table", "table", "left"),
|
||||
column("Status", "status", "left"), column("Index state", "indexState", "left")));
|
||||
}
|
||||
|
||||
|
||||
@@ -241,15 +267,19 @@ public final class CollaborationHome
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
if(admin)
|
||||
{
|
||||
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
|
||||
var registry = PresenceRegistry.get(QContext.getQInstance());
|
||||
Map<String, Integer> connections = registry.connectionCounts();
|
||||
registry.counts().forEach((table, viewers) ->
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("table", tableLabel(table));
|
||||
row.put("viewers", viewers);
|
||||
row.put("connections", connections.getOrDefault(table, viewers));
|
||||
rows.add(row);
|
||||
});
|
||||
}
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "3fr", "left"), column("People", "viewers", "1fr", "right")));
|
||||
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right"),
|
||||
column("Connections", "connections", "right")));
|
||||
}
|
||||
|
||||
|
||||
@@ -267,9 +297,12 @@ public final class CollaborationHome
|
||||
/*******************************************************************************
|
||||
** A plain column; the frontend styles it.
|
||||
*******************************************************************************/
|
||||
private static TableData.Column column(String header, String accessor, String width, String align)
|
||||
private static TableData.Column column(String header, String accessor, String align)
|
||||
{
|
||||
return (new TableData.Column("default", header, accessor, width, align));
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// no width: frontends size the columns to fit narrow (390 px) cards //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
return (new TableData.Column("default", header, accessor, null, align));
|
||||
}
|
||||
|
||||
|
||||
@@ -360,6 +393,23 @@ public final class CollaborationHome
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setup renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class SetupRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(setup()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** KPI renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.app;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Collaboration home: which record features are on, for
|
||||
** which tables, and whether that comes from Feature Settings rows or from the
|
||||
** code defaults. Uses the same resolution the runtime uses
|
||||
** (RecordFeaturesResolver: code declarations, then the committed rows), so the
|
||||
** card never reports something the runtime would not do.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationSetupStatus
|
||||
{
|
||||
private static final int NAMED_TABLES = 5;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String feature, String state, String where, String source, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line for Feature Settings itself, then one per record feature.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(QInstance instance) throws QException
|
||||
{
|
||||
List<QRecord> rows = CollaborationRecords.query(CollaborationTables.FEATURE_SETTING, new QQueryFilter().withLimit(1000));
|
||||
Set<String> featuresWithRows = new HashSet<>();
|
||||
rows.forEach(row -> featuresWithRows.add(row.getValueString("feature")));
|
||||
|
||||
List<Item> items = new ArrayList<>();
|
||||
items.add(rows.isEmpty()
|
||||
? new Item("Feature settings", "Using code defaults", "-", "Configuration", "Run Create rows from configuration on Feature Settings to see and edit every setting.")
|
||||
: new Item("Feature settings", "Set up", rows.size() + " row" + (rows.size() == 1 ? "" : "s"), "Feature Settings", null));
|
||||
|
||||
List<QTableMetaData> hostTables = CollectionUtils.nonNullMap(instance.getTables()).values().stream()
|
||||
.filter(table -> !table.getName().startsWith("collaboration_") && !Boolean.TRUE.equals(table.getIsHidden()) && table.getPrimaryKeyField() != null
|
||||
&& (table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_GET) || table.isCapabilityEnabled(instance.getBackend(table.getBackendName()), Capability.TABLE_QUERY)))
|
||||
.sorted((a, b) -> a.getName().compareTo(b.getName())).toList();
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
List<String> on = new ArrayList<>();
|
||||
for(QTableMetaData table : hostTables)
|
||||
{
|
||||
if(RecordFeaturesResolver.resolve(instance, table).getFeatures().get(feature).getEnabled())
|
||||
{
|
||||
on.add(table.getLabel() == null ? table.getName() : table.getLabel());
|
||||
}
|
||||
}
|
||||
String source = featuresWithRows.contains(feature.name()) ? "Feature Settings" : "Configuration";
|
||||
String name = friendly(feature);
|
||||
if(on.isEmpty())
|
||||
{
|
||||
items.add(new Item(name, "Off", "No table", source, "Add a Feature Settings row (scope INSTANCE, or TABLE for one table) and turn it on."));
|
||||
}
|
||||
else
|
||||
{
|
||||
boolean all = on.size() == hostTables.size();
|
||||
String where = all ? "Every table" : String.join(", ", on.subList(0, Math.min(NAMED_TABLES, on.size()))) + (on.size() > NAMED_TABLES ? " and " + (on.size() - NAMED_TABLES) + " more" : "");
|
||||
items.add(new Item(name, all ? "On" : "On for " + on.size() + " of " + hostTables.size() + " tables", where, source, null));
|
||||
}
|
||||
}
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TAGS becomes Tags, CUSTOM_FIELDS becomes Custom fields.
|
||||
*******************************************************************************/
|
||||
static String friendly(RecordFeature feature)
|
||||
{
|
||||
String lower = feature.name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
|
||||
}
|
||||
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.metadata.code.QCodeReference;
|
||||
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.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible native adoption action for an existing original. It never
|
||||
** accepts replacement bytes, a storage path, an actor, or an intake scope.
|
||||
*******************************************************************************/
|
||||
public final class AdoptRetainedAttachmentProcess
|
||||
{
|
||||
public static final String NAME = "collaborationAdoptRetainedAttachment";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private AdoptRetainedAttachmentProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ confirmation and result screens, attached to the attachment table.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withTableName(CollaborationTables.ATTACHMENT).withLabel("Retain existing original")
|
||||
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
||||
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
|
||||
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(CollaborationTables.ATTACHMENT))))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Retain this original")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("confirmAdoption", QFieldType.BOOLEAN).withLabel("Verify and retain the existing original without copying bytes").withDefaultValue(false).withIsRequired(true)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("adopt").withCode(new QCodeReference(Adopt.class)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original retained")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
|
||||
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve one visible attachment selection before displaying confirmation.
|
||||
*******************************************************************************/
|
||||
public static final class Prepare implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read-only preview under current actor and parent visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getRecords() == null || input.getRecords().size() != 1)
|
||||
{
|
||||
throw (new QException("Select exactly one attachment."));
|
||||
}
|
||||
var row = new AttachmentsService().requireAdoptable(input.getRecords().getFirst().getValueInteger("id"));
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QException("Attachment is unavailable."));
|
||||
}
|
||||
if(row.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its context."));
|
||||
}
|
||||
output.addValue("attachmentId", row.getValue("id"));
|
||||
output.addValue("confirmAdoption", false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution reauthorizes independently of the earlier preview.
|
||||
*******************************************************************************/
|
||||
public static final class Adopt implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The owner returns only after mandatory lifecycle registration completes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(!Boolean.TRUE.equals(input.getValueBoolean("confirmAdoption")))
|
||||
{
|
||||
throw (new QException("Confirm adoption of this existing original to continue."));
|
||||
}
|
||||
output.addValue("attachmentId", new AttachmentsService().adoptRetainedAttachment(input.getValueInteger("attachmentId")).id());
|
||||
}
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
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.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional owner policy for parents whose dynamic visibility cannot be expressed
|
||||
** by aggregate SQL. Count/aggregate require one currently authorized parent.
|
||||
** Ordinary collaboration policies retain their existing count behavior.
|
||||
*******************************************************************************/
|
||||
public final class AttachmentAggregateGuard extends AttachmentSecurity
|
||||
{
|
||||
private static final String PINNED_POLICY = "collaboration.attachmentPinnedAggregateParent";
|
||||
|
||||
/*******************************************************************************
|
||||
** Hide unscoped list totals in both instance and table metadata. Native count
|
||||
** remains available to callers supplying the required authorized parent pin.
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData configure(QTableMetaData table, CollaborationQBitConfig config)
|
||||
{
|
||||
if(policy(config).requiresPinnedAggregateParent())
|
||||
{
|
||||
table.withSupplementalMetaData(new PinnedParentPolicy());
|
||||
}
|
||||
if(table.getSupplementalMetaData(PINNED_POLICY) != null)
|
||||
{
|
||||
table.withoutCapabilities(Capability.TABLE_COUNT, Capability.QUERY_STATS);
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve ordinary view/write rules, then narrow aggregate inputs only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
super.checkPermissionsThrowing(input, metadata);
|
||||
if(!(input instanceof CountInput) && !(input instanceof AggregateInput))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
CollaborationQBitConfig config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
|
||||
QTableMetaData tableMetadata = QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT);
|
||||
if(tableMetadata.getSupplementalMetaData(PINNED_POLICY) == null && !policy(config).requiresPinnedAggregateParent() && !hasRetainedOriginal())
|
||||
{
|
||||
return;
|
||||
}
|
||||
QQueryFilter filter = input instanceof CountInput count ? count.getFilter() : ((AggregateInput) input).getFilter();
|
||||
var joins = input instanceof CountInput count ? count.getQueryJoins() : ((AggregateInput) input).getQueryJoins();
|
||||
if(joins != null && !joins.isEmpty() || filter == null || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND)
|
||||
{
|
||||
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
|
||||
}
|
||||
rejectSetOperations(filter, 0, new int[]{0});
|
||||
Serializable table = literal(filter, "tableName");
|
||||
Serializable record = literal(filter, "recordId");
|
||||
if(!(table instanceof String name) || name.isBlank() || record == null)
|
||||
{
|
||||
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(name, record);
|
||||
}
|
||||
catch(QException | RuntimeException denied)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Counts and aggregates require one currently readable attachment parent."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A literal top-level AND equality constrains every row, even when other
|
||||
** independent subfilters contain OR. Conflicting or computed pins are refused.
|
||||
*******************************************************************************/
|
||||
private static Serializable literal(QQueryFilter filter, String field) throws QException
|
||||
{
|
||||
Serializable result = null;
|
||||
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
|
||||
{
|
||||
if(field.equals(criterion.getFieldName()) && criterion.getOperator() == QCriteriaOperator.EQUALS)
|
||||
{
|
||||
if(criterion.getValues() == null || criterion.getValues().size() != 1)
|
||||
{
|
||||
throw (new QException("An exact parent equality is required."));
|
||||
}
|
||||
Serializable value = criterion.getValues().getFirst();
|
||||
if(!(value instanceof String) && !(value instanceof Number) || result != null && !result.equals(value))
|
||||
{
|
||||
throw (new QException("Computed or conflicting parent equality is not supported."));
|
||||
}
|
||||
result = value;
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set-operation filters may combine independent branches; never assume their
|
||||
** SQL obeys an ordinary root AND pin. Bound traversal before SQL execution.
|
||||
*******************************************************************************/
|
||||
private static void rejectSetOperations(QQueryFilter filter, int depth, int[] visited) throws QException
|
||||
{
|
||||
if(depth > 64 || ++visited[0] > 4096 || filter.getSubFilterSetOperator() != null)
|
||||
{
|
||||
throw (new QException("Unsupported aggregate filter shape."));
|
||||
}
|
||||
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
|
||||
{
|
||||
rejectSetOperations(child, depth + 1, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capability policy is configuration-only and must not depend on a request.
|
||||
*******************************************************************************/
|
||||
private static AttachmentLifecyclePolicy policy(CollaborationQBitConfig config)
|
||||
{
|
||||
return (java.util.Objects.requireNonNull(QCodeLoader.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Attachment lifecycle policy is unavailable."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh instance may have lost its former lifecycle configuration. Inspect
|
||||
** only existence through private owner metadata, never return retained rows.
|
||||
** Failure propagates to aggregate denial; there is no permissive fallback.
|
||||
*******************************************************************************/
|
||||
private static boolean hasRetainedOriginal() throws QException
|
||||
{
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.ATTACHMENT)
|
||||
.withFilter(new QQueryFilter().withCriteria("retained", QCriteriaOperator.EQUALS, true).withLimit(1))
|
||||
.withShouldFetchHeavyFields(false).withShouldGenerateDisplayValues(false);
|
||||
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
input.withPermissionBypass("Owner aggregate guard checks only whether retained originals require pinned-parent authorization; no row data is exposed.");
|
||||
return (!new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords().isEmpty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An installed capability restriction survives subsequent lifecycle changes
|
||||
** and metadata clones. It carries no request or authorization state.
|
||||
*******************************************************************************/
|
||||
public static final class PinnedParentPolicy extends QSupplementalTableMetaData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable owner metadata identity.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (PINNED_POLICY);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The marker is immutable and has no nested mutable state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
|
||||
{
|
||||
return (clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,8 @@ public class AttachmentCleanup
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupQuery(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList())
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
return;
|
||||
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
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.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.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.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryLockMode;
|
||||
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.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable deletion intent, committed with metadata removal before deleting bytes.
|
||||
*******************************************************************************/
|
||||
public class AttachmentCleanupQueue implements CustomPermissionChecker
|
||||
{
|
||||
private final AttachmentStorage storage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata permission checks do not create a storage client.
|
||||
*******************************************************************************/
|
||||
public AttachmentCleanupQueue()
|
||||
{
|
||||
this(new AttachmentStorage());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same provider boundary as the authorized attachment operation.
|
||||
*******************************************************************************/
|
||||
AttachmentCleanupQueue(AttachmentStorage storage)
|
||||
{
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only admins may inspect recovery locations; no direct mutations are allowed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(!(input instanceof QueryInput) && !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput)
|
||||
&& !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Attachment cleanup records are service-managed."));
|
||||
}
|
||||
requireOperator();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller has already authorized the parent and attachment owner.
|
||||
*******************************************************************************/
|
||||
void delete(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
QBackendTransaction outer = QContext.getQBackendTransaction();
|
||||
if(outer != null)
|
||||
{
|
||||
QRecord pending = enqueueAndDelete(row, cleanup);
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
outer.addAfterCommitCallback(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> finish(pending));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Attachment cleanup remains durably queued.", e));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
QRecord pending;
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
pending = enqueueAndDelete(row, cleanup);
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
finish(pending);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store the recovery locator in the same database transaction as row deletion.
|
||||
*******************************************************************************/
|
||||
private QRecord enqueueAndDelete(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
QRecord intent = new QRecord().withValue("attachmentId", row.getValue("id"))
|
||||
.withValue("bucketName", row.getValue("bucketName")).withValue("storageKey", row.getValue("storageKey"));
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(intent))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
insert.withPermissionBypass("Authorized attachment deletion records durable storage cleanup.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id")))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
delete.setTableMetaData(cleanup ? CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
|
||||
delete.withPermissionBypass("Attachment deletion was authorized before recording cleanup.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
return (inserted.getFirst());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable upload ownership requires real transactional row locking.
|
||||
*******************************************************************************/
|
||||
static boolean supportsUploadOwnership() throws QException
|
||||
{
|
||||
var backend = QContext.getQInstance().getBackendForTable(CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
return (new QBackendModuleDispatcher().getQBackendModule(backend).supportsQueryLockMode(backend, QueryLockMode.FOR_UPDATE_SKIP_LOCKED));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit the locator independently, then lock it before any provider write.
|
||||
** If a worker claimed it first, abort without uploading any bytes.
|
||||
*******************************************************************************/
|
||||
QRecord beginUpload(String bucket, String key) throws QException
|
||||
{
|
||||
if(!supportsUploadOwnership())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QBackendTransaction transaction = QContext.getQBackendTransaction();
|
||||
if(transaction == null)
|
||||
{
|
||||
throw (new QException("Durable attachment uploads require an active transaction."));
|
||||
}
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
QRecord[] pending = new QRecord[1];
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
|
||||
insert.withPermissionBypass("Authorized upload records its immutable locator before external I/O.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
pending[0] = inserted.getFirst();
|
||||
});
|
||||
if(lock(pending[0], transaction) == null)
|
||||
{
|
||||
throw (new QException("Upload cleanup intent was already claimed; retry the upload."));
|
||||
}
|
||||
Runnable cleanup = () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () -> finish(pending[0]));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Upload cleanup remains durably queued.", e));
|
||||
}
|
||||
};
|
||||
transaction.addAfterRollbackCallback(cleanup);
|
||||
transaction.addAfterCommitCallback(cleanup);
|
||||
return (pending[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish metadata and retire its upload intent in the same transaction.
|
||||
*******************************************************************************/
|
||||
void completeUpload(QRecord pending) throws QException
|
||||
{
|
||||
if(pending != null)
|
||||
{
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKey(pending.getValue("id"))
|
||||
.withTransaction(QContext.getQBackendTransaction());
|
||||
delete.withPermissionBypass("Attachment metadata now owns the uploaded object in this transaction.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Skip active owners without waiting or updating their locked intent.
|
||||
*******************************************************************************/
|
||||
private QRecord lock(QRecord pending, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
|
||||
.withLockMode(QueryLockMode.FOR_UPDATE_SKIP_LOCKED)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, pending.getValue("id")));
|
||||
query.withPermissionBypass("Internal attachment lifecycle owns this immutable cleanup locator.");
|
||||
List<QRecord> rows = new QueryAction().execute(query).getRecords();
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Distinguish an active owner from a committed row requiring delayed retry.
|
||||
*******************************************************************************/
|
||||
private enum CleanupOutcome
|
||||
{
|
||||
COMPLETED, ACTIVE, REFERENCED, UNCERTAIN_UPLOAD
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hold the same row lock as an uploader until deletion and acknowledgement end.
|
||||
*******************************************************************************/
|
||||
private CleanupOutcome finish(QRecord pending) throws QException
|
||||
{
|
||||
if(!supportsUploadOwnership())
|
||||
{
|
||||
return (finishUnlocked(pending));
|
||||
}
|
||||
CleanupOutcome[] outcome = { CleanupOutcome.ACTIVE };
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
QRecord locked = lock(pending, transaction);
|
||||
if(locked != null)
|
||||
{
|
||||
outcome[0] = finishUnlocked(locked);
|
||||
}
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
});
|
||||
return (outcome[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never erase bytes while metadata still exists, including nontransactional hosts.
|
||||
** Provider or queue deletion failure retains the intent for idempotent replay.
|
||||
*******************************************************************************/
|
||||
private CleanupOutcome finishUnlocked(QRecord pending) throws QException
|
||||
{
|
||||
if(pending.getValue("resolvedAt") != null)
|
||||
{
|
||||
return (CleanupOutcome.COMPLETED);
|
||||
}
|
||||
if(!AttachmentRecords.query(new QQueryFilter()
|
||||
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
|
||||
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
|
||||
{
|
||||
return (CleanupOutcome.REFERENCED);
|
||||
}
|
||||
if(pending.getValue("attachmentId") == null)
|
||||
{
|
||||
if(!storage.cleanupUpload(pending.getValueString("bucketName"), pending.getValueString("storageKey")))
|
||||
{
|
||||
return (CleanupOutcome.UNCERTAIN_UPLOAD);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
|
||||
}
|
||||
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKeys(List.of(pending.getValue("id"))).withTransaction(QContext.getQBackendTransaction());
|
||||
delete.withPermissionBypass("Storage cleanup completed; remove its durable intent.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
|
||||
return (CleanupOutcome.COMPLETED);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rollback compensation must survive a provider outage. Persist independently
|
||||
** of the failed transaction, then attempt cleanup with the original context.
|
||||
*******************************************************************************/
|
||||
void compensateUpload(String bucket, String key)
|
||||
{
|
||||
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
try
|
||||
{
|
||||
QContext.withTemporaryContext(context, () ->
|
||||
{
|
||||
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
|
||||
insert.withPermissionBypass("Failed authorized upload retains its immutable storage locator for compensation.");
|
||||
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
|
||||
AttachmentRecords.checkErrors(inserted);
|
||||
finish(inserted.getFirst());
|
||||
});
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IllegalStateException("Upload compensation could not complete.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded recovery from committed intents, independent of the original service.
|
||||
*******************************************************************************/
|
||||
public int retryPending() throws QException
|
||||
{
|
||||
requireOperator();
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Attachment cleanup must run outside a caller transaction."));
|
||||
}
|
||||
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withFilter(new QQueryFilter()
|
||||
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withSubFilter(new QQueryFilter()
|
||||
.withBooleanOperator(QQueryFilter.BooleanOperator.OR)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(100));
|
||||
query.withPermissionBypass("Authorized operator retries committed storage cleanup.");
|
||||
List<QRecord> pending = new QueryAction().execute(query).getRecords();
|
||||
int completed = 0;
|
||||
QException failure = null;
|
||||
for(QRecord row : pending)
|
||||
{
|
||||
try
|
||||
{
|
||||
CleanupOutcome outcome = finish(row);
|
||||
if(outcome == CleanupOutcome.COMPLETED)
|
||||
{
|
||||
completed++;
|
||||
}
|
||||
else if(outcome == CleanupOutcome.REFERENCED || outcome == CleanupOutcome.UNCERTAIN_UPLOAD)
|
||||
{
|
||||
defer(row);
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
defer(row);
|
||||
}
|
||||
catch(QException | RuntimeException deferFailure)
|
||||
{
|
||||
e.addSuppressed(deferFailure);
|
||||
}
|
||||
if(failure == null)
|
||||
{
|
||||
failure = new QException("Attachment cleanup remains pending; retry after the provider or database recovers.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(failure != null)
|
||||
{
|
||||
throw (failure);
|
||||
}
|
||||
return (completed);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist backoff so failing entries do not monopolize the next bounded batch.
|
||||
*******************************************************************************/
|
||||
private void defer(QRecord pending) throws QException
|
||||
{
|
||||
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
|
||||
new QRecord().withValue("id", pending.getValue("id")).withValue("nextAttemptAt", Instant.now().plusSeconds(60))));
|
||||
update.withPermissionBypass("Committed cleanup failed or is not yet safe; retain it for a later attempt.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An identified administrator attests provider settlement; absence or age alone
|
||||
** is not evidence. Keep the receipt on the service-managed row permanently.
|
||||
*******************************************************************************/
|
||||
public void resolveUpload(Serializable id, String evidence) throws QException
|
||||
{
|
||||
String actor = requireSettlementOperator();
|
||||
validateEvidence(evidence);
|
||||
if(id == null || QContext.getQBackendTransaction() != null || !supportsUploadOwnership())
|
||||
{
|
||||
throw (new QException("Upload settlement requires a record ID and an independent locking transaction."));
|
||||
}
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP)))
|
||||
{
|
||||
QContext.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
QRecord pending = lock(new QRecord().withValue("id", id), transaction);
|
||||
if(pending == null)
|
||||
{
|
||||
throw (new QException("Cleanup record is active or no longer exists. Retry after its owner finishes."));
|
||||
}
|
||||
if(pending.getValue("resolvedAt") != null)
|
||||
{
|
||||
transaction.commit();
|
||||
return;
|
||||
}
|
||||
if(pending.getValue("attachmentId") != null)
|
||||
{
|
||||
throw (new QException("Use normal cleanup retry for committed attachment deletions."));
|
||||
}
|
||||
if(!AttachmentRecords.query(new QQueryFilter()
|
||||
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
|
||||
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
|
||||
{
|
||||
throw (new QException("An attachment still references this object; settlement is not allowed."));
|
||||
}
|
||||
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
|
||||
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
|
||||
.withRecords(List.of(new QRecord().withValue("id", pending.getValue("id"))
|
||||
.withValue("resolvedAt", Instant.now()).withValue("resolvedBy", actor)
|
||||
.withValue("settlementEvidence", evidence.strip())));
|
||||
update.withPermissionBypass("Identified collaboration administrator records confirmed upload settlement after storage cleanup.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
transaction.rollback();
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The form and service both reject missing or oversized evidence.
|
||||
*******************************************************************************/
|
||||
static void validateEvidence(String evidence) throws QException
|
||||
{
|
||||
if(evidence == null || evidence.isBlank() || evidence.strip().length() > 2000)
|
||||
{
|
||||
throw (new QException("Record provider completion or cancellation evidence (1–2000 characters)."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Settlement is a deliberate, attributable admin action, never scheduled work.
|
||||
*******************************************************************************/
|
||||
static String requireSettlementOperator() throws QPermissionDeniedException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
String actor = CollaborationSecurity.userId(session);
|
||||
if(session == null || session instanceof QSystemUserSession || !session.hasPermission(CollaborationPermissions.ADMIN)
|
||||
|| actor == null || actor.isBlank() || actor.length() > 255)
|
||||
{
|
||||
throw (new QPermissionDeniedException("An identified collaboration administrator must confirm upload settlement."));
|
||||
}
|
||||
return (actor);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled system work and explicit admin recovery share one authorization rule.
|
||||
*******************************************************************************/
|
||||
static void requireOperator() throws QPermissionDeniedException
|
||||
{
|
||||
var session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Verified bytes returned only after authorization, integrity and audit checks.
|
||||
** Storage coordinates never cross this boundary. Content is bounded to 20 MiB.
|
||||
*******************************************************************************/
|
||||
public record AttachmentContent(Integer attachmentId, String fileName, String contentType, String sha256, byte[] bytes)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Own the byte array so a caller cannot change a retained receipt's content.
|
||||
*******************************************************************************/
|
||||
public AttachmentContent
|
||||
{
|
||||
if(bytes == null || bytes.length > 20 * 1024 * 1024)
|
||||
{
|
||||
throw (new IllegalArgumentException("Attachment content is missing or exceeds the supported limit."));
|
||||
}
|
||||
bytes = bytes.clone();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return caller-owned bytes without exposing the immutable content buffer.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public byte[] bytes()
|
||||
{
|
||||
return (bytes.clone());
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact stored content failed its immutable metadata check. Consumers must not
|
||||
** retry this as a transport outage or release the unverified bytes.
|
||||
*******************************************************************************/
|
||||
public class AttachmentContentIntegrityException extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Fixed public diagnostic, with no object locator or provider details.
|
||||
*******************************************************************************/
|
||||
public AttachmentContentIntegrityException()
|
||||
{
|
||||
super("Attachment content failed integrity verification.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.Objects;
|
||||
|
||||
/*******************************************************************************
|
||||
** A download destination may require the application's authenticated transport.
|
||||
** The URL is sensitive and never attests that bytes were served.
|
||||
*******************************************************************************/
|
||||
public record AttachmentDownload(URL url, boolean authenticated)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Only ordinary HTTP destinations are eligible for attachment delivery.
|
||||
***************************************************************************/
|
||||
public AttachmentDownload
|
||||
{
|
||||
Objects.requireNonNull(url, "url");
|
||||
if((!"https".equalsIgnoreCase(url.getProtocol()) && !"http".equalsIgnoreCase(url.getProtocol()))
|
||||
|| url.getUserInfo() != null || url.getRef() != null || url.getHost() == null || url.getHost().isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("Attachment download requires HTTP or HTTPS");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Never disclose storage signatures through ordinary object logging.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("AttachmentDownload[authenticated=" + authenticated + ", url=<redacted>]");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ 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)
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, Boolean canEdit)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve source compatibility for callers using the earlier DTO constructor.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
|
||||
{
|
||||
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, canDelete);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted host extension for attachment lifecycle policy. The default preserves
|
||||
** ordinary Collaboration behavior. Implementations must be concurrency-safe and
|
||||
** must not retain request context. Callbacks receive defensive record copies.
|
||||
** This is not an untrusted scripting API or a new file-storage abstraction.
|
||||
*******************************************************************************/
|
||||
public class AttachmentLifecyclePolicy
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Configuration-only capability policy, evaluated during metadata production
|
||||
** without a session. Dynamic parent visibility owners opt in to pinned counts.
|
||||
*******************************************************************************/
|
||||
public boolean requiresPinnedAggregateParent()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An installed retention owner must authorize an exact archival reference
|
||||
** transfer in the caller's real transaction. Ordinary policies deny this.
|
||||
*******************************************************************************/
|
||||
public void beforeReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Retained reference transfer requires an archive policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist the reference-transfer outcome in the same transaction. The policy
|
||||
** must not commit or close the transaction; failure requires caller rollback.
|
||||
*******************************************************************************/
|
||||
public void afterReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Retained reference transfer requires an atomic archive audit policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called with canonical source, actual digest/size and persisted retention
|
||||
** decision immediately before the first object write. There is no row ID yet.
|
||||
** Retained originals require an independently durable intent before storage.
|
||||
*******************************************************************************/
|
||||
public void beforeStored(QRecord attachment) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained storage requires a durable write-intent policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recheck current adoption authority after external operations, without
|
||||
** creating another intent or granting access to the original's bytes.
|
||||
*******************************************************************************/
|
||||
public void authorizeAdoption(QRecord attachment) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption is not configured."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit adoption of an existing owner original, not a new upload. The
|
||||
** policy must authorize the current source and commit its intent before read.
|
||||
*******************************************************************************/
|
||||
public void beforeAdoption(QRecord attachment) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires an audited retention policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the retention transition in the owner's transaction. Registration
|
||||
** follows separately; failure after commit leaves a retained retryable row.
|
||||
*******************************************************************************/
|
||||
public void afterAdoption(QRecord attachment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires an atomic retention audit."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after storage and metadata insertion, before upload returns. Failure
|
||||
** leaves the original intact for reconciliation; it is not a rollback hook.
|
||||
** Enforce incomplete registration/quarantine in beforeAccess as appropriate.
|
||||
*******************************************************************************/
|
||||
public void afterStored(QRecord attachment) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Before reading an existing original for registration recovery. This is not
|
||||
** a write attempt or a user download; retained owners must audit it explicitly.
|
||||
*******************************************************************************/
|
||||
public void beforeRecovery(QRecord candidate) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(candidate.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained recovery requires a durable recovery-attempt policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after parent authorization and before either preview or download URL
|
||||
** signing. Throw to deny. A successful audit here means access was requested,
|
||||
** not that bytes were downloaded. Mandatory audit must commit independently
|
||||
** before returning; the caller transaction may still roll back afterwards.
|
||||
*******************************************************************************/
|
||||
public void beforeAccess(QRecord attachment, boolean inline) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional authenticated delivery after ordinary parent/lifecycle checks.
|
||||
** Return null to retain the existing storage URL. No alternate lookup occurs.
|
||||
*******************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(QRecord attachment) throws QException
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize and durably audit a server-side content read, distinct from URL
|
||||
** issuance. Retained content requires an implementation of this new contract.
|
||||
*******************************************************************************/
|
||||
public void beforeContentRead(QRecord attachment) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained content requires an audited content-read policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist the verified server-read result before content leaves the service.
|
||||
** This does not attest user download, printer spooling or physical output.
|
||||
*******************************************************************************/
|
||||
public void afterContentRead(QRecord attachment, Long bytesRead) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained content requires an audited read-result policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return true to prohibit deletion and preserve bytes on parent cleanup.
|
||||
** Called during intake before storage with complete metadata but no row id;
|
||||
** true is persisted on the attachment and cannot be cleared by this API.
|
||||
** It is also queried for legacy rows during deletion/read hints. Failure
|
||||
** denies deletion. Do not depend on parent existence or caller permissions.
|
||||
*******************************************************************************/
|
||||
public boolean isRetained(QRecord attachment) throws QException
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after ownership/parent checks and before applying display metadata.
|
||||
** The second record contains id and proposed fields, not the complete row.
|
||||
** This records an intent only; it does not attest that a transaction committed.
|
||||
*******************************************************************************/
|
||||
public void beforeMetadataChange(QRecord attachment, QRecord changes) throws QException
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the actual owner transaction before applying display metadata.
|
||||
** Required providers must reject unsupported/no-op transactions and incompatible
|
||||
** audit backends before the first write. This method does not commit anything.
|
||||
*******************************************************************************/
|
||||
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained metadata requires an atomic audit transaction policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the successful display metadata change in the supplied transaction
|
||||
** before its owner commits. Required providers must validate real transaction
|
||||
** and backend compatibility, insert their event using this transaction and
|
||||
** throw on failure. Never commit or close the supplied owner transaction.
|
||||
*******************************************************************************/
|
||||
public void afterMetadataChange(QRecord attachment, QRecord changes, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
|
||||
{
|
||||
throw (new QException("Retained metadata requires an atomic audit-result policy."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/*******************************************************************************
|
||||
** What an uploaded object is allowed to tell a browser. The declared media type
|
||||
** is client-controlled: it is kept as display metadata, but the object itself is
|
||||
** stored with an inert type and an inline disposition only when the type is a
|
||||
** known passive image, PDF, plain-text, audio or video format. Anything else (HTML, SVG,
|
||||
** XML, script, unknown) is stored as an opaque attachment, so a signed storage
|
||||
** link can never render or execute uploaded active content inline.
|
||||
*******************************************************************************/
|
||||
final class AttachmentMediaPolicy
|
||||
{
|
||||
static final String OPAQUE = "application/octet-stream";
|
||||
|
||||
private static final Set<String> PASSIVE_INLINE = Set.of(
|
||||
"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain",
|
||||
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm",
|
||||
"video/mp4", "video/webm", "video/ogg");
|
||||
|
||||
/***************************************************************************
|
||||
** Static policy only.
|
||||
***************************************************************************/
|
||||
private AttachmentMediaPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The base media type of a declared value: lowercase, without parameters.
|
||||
** Anything that is not a plain type/subtype token is treated as unknown.
|
||||
***************************************************************************/
|
||||
static String baseType(String declared)
|
||||
{
|
||||
if(declared == null)
|
||||
{
|
||||
return (OPAQUE);
|
||||
}
|
||||
String base = declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
return (base.matches("[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}") ? base : OPAQUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The media type the stored object will be served with.
|
||||
***************************************************************************/
|
||||
static String storedType(String declared)
|
||||
{
|
||||
String base = baseType(declared);
|
||||
return (PASSIVE_INLINE.contains(base) ? base : OPAQUE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Inline only for passive types; every other object is an attachment.
|
||||
***************************************************************************/
|
||||
static String disposition(String declared)
|
||||
{
|
||||
return (PASSIVE_INLINE.contains(baseType(declared)) ? "inline" : "attachment");
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -46,13 +46,13 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
return (filter(records, input instanceof GetInput, new AttachmentsService()));
|
||||
return (filter(records, input instanceof GetInput, input.getShouldGenerateDisplayValues(), 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> filter(List<QRecord> records, boolean isGet, boolean generateUrls, AttachmentsService service) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
@@ -77,15 +77,21 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
QRecord row = new QRecord(record);
|
||||
row.setValue("previewUrl", null);
|
||||
row.setValue("downloadUrl", null);
|
||||
row.setValue("previewDelivery", null);
|
||||
row.setValue("downloadDelivery", null);
|
||||
row.withDisplayValue("downloadUrl", null).withDisplayValue("downloadUrl:downloadUrlDynamic", null);
|
||||
visible.add(row);
|
||||
if(visible.size() <= 200)
|
||||
if(generateUrls && visible.size() <= 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
row.setValue("previewUrl", service.url(row, true).toString());
|
||||
String downloadUrl = service.url(row, false).toString();
|
||||
AttachmentDownload preview = service.delivery(row, true);
|
||||
AttachmentDownload download = service.delivery(row, false);
|
||||
row.setValue("previewUrl", preview.url().toString());
|
||||
row.setValue("previewDelivery", preview.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
|
||||
String downloadUrl = download.url().toString();
|
||||
row.setValue("downloadUrl", downloadUrl);
|
||||
row.setValue("downloadDelivery", download.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
|
||||
row.withDisplayValue("downloadUrl", row.getValueString("fileName"))
|
||||
.withDisplayValue("downloadUrl:downloadUrlDynamic", downloadUrl);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,17 @@
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.Optional;
|
||||
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.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.Range;
|
||||
import com.kingsrook.qbits.objectstorage.api.StorageBucket;
|
||||
import com.kingsrook.qbits.objectstorage.api.StoredObject;
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow storage boundary. The caller must check collaboration access first.
|
||||
@@ -62,6 +66,22 @@ public class AttachmentStorage
|
||||
bucket(name).put(request);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Open content for an already-authorized caller. The service owns closing it.
|
||||
*******************************************************************************/
|
||||
public ObjectStream open(String name, String key)
|
||||
{
|
||||
return (bucket(name).get(key, Range.all()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect a deterministic intake key after uncertain conditional creation.
|
||||
*******************************************************************************/
|
||||
public Optional<StoredObject> head(String name, String key)
|
||||
{
|
||||
return (bucket(name).head(key));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer soft deletion; allow permanent deletion only where no soft mode exists.
|
||||
*******************************************************************************/
|
||||
@@ -73,6 +93,18 @@ public class AttachmentStorage
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove failed-upload bytes and report whether no earlier PUT can still finish.
|
||||
** Generic object storage cannot prove remote settlement after a timeout. Keep
|
||||
** its durable locator for later sweeps. Adapters may return true only with an
|
||||
** authoritative completion/cancellation guarantee, never from object absence.
|
||||
*******************************************************************************/
|
||||
public boolean cleanupUpload(String name, String key)
|
||||
{
|
||||
delete(name, key);
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a temporary link, never a persistent table value.
|
||||
*******************************************************************************/
|
||||
|
||||
+657
-28
@@ -21,6 +21,8 @@
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
@@ -37,14 +39,17 @@ import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
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.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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;
|
||||
@@ -68,6 +73,8 @@ public class AttachmentsService
|
||||
{
|
||||
private final CollaborationQBitConfig config;
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
private final com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal;
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the installed configuration without a global mutable singleton.
|
||||
@@ -77,15 +84,46 @@ public class AttachmentsService
|
||||
this.config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
|
||||
this.storage = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentStorage.class, config.getAttachmentStorage());
|
||||
this.lifecycle = resolveLifecycle(config);
|
||||
this.uploadJournal = new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable storage boundary for deterministic service tests.
|
||||
*******************************************************************************/
|
||||
public AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage)
|
||||
{
|
||||
this(config, storage, resolveLifecycle(config));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local deterministic lifecycle seam for service tests.
|
||||
*******************************************************************************/
|
||||
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
|
||||
{
|
||||
this(config, storage, lifecycle, new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit test-only package seam; public services always use the durable
|
||||
** qualified journal. It cannot be replaced through application configuration.
|
||||
*******************************************************************************/
|
||||
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle,
|
||||
com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal)
|
||||
{
|
||||
this.config = config;
|
||||
this.storage = storage;
|
||||
this.lifecycle = java.util.Objects.requireNonNull(lifecycle, "lifecycle");
|
||||
this.uploadJournal = java.util.Objects.requireNonNull(uploadJournal, "uploadJournal");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid policy configuration must fail before any storage side effect.
|
||||
*******************************************************************************/
|
||||
private static AttachmentLifecyclePolicy resolveLifecycle(CollaborationQBitConfig config)
|
||||
{
|
||||
return (java.util.Objects.requireNonNull(com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader
|
||||
.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Could not load attachment lifecycle policy."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -93,6 +131,36 @@ public class AttachmentsService
|
||||
** 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
|
||||
{
|
||||
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, null, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retryable intake for a server coordinator. Keys and manifests are SHA-256
|
||||
** identifiers; keys are additionally scoped to actual actor and parent here.
|
||||
** This path owns persistence and refuses ambient transactions. Replays verify
|
||||
** supplied bytes and rerun registration; they never create another original.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
|
||||
String intakeKey, String manifestSha256) throws QException
|
||||
{
|
||||
if(intakeKey == null || !intakeKey.matches("[0-9a-f]{64}") || manifestSha256 == null || !manifestSha256.matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QBadRequestException("Intake key and manifest must be SHA-256 identifiers."));
|
||||
}
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retryable attachment intake cannot join an ambient transaction."));
|
||||
}
|
||||
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, intakeKey, manifestSha256));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared bounded intake; the retryable path preserves uncertain objects for
|
||||
** reconciliation under a deterministic key and never overwrites them.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry uploadInternal(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
|
||||
String intakeKey, String manifestSha256) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
ParentRecordAccess.requireFeature(tableName, recordId, "attach");
|
||||
@@ -100,13 +168,19 @@ public class AttachmentsService
|
||||
String id = canonicalId(tableName, recordId);
|
||||
String name = sanitize(fileName);
|
||||
String bucket = config.getAttachmentsBucketName();
|
||||
String key = "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name;
|
||||
String scopedKey = intakeKey == null ? null : tupleHash("attachment-intake-v1", user, tableName, id, intakeKey);
|
||||
String key = scopedKey == null ? "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name
|
||||
: "attachments/intake/" + scopedKey;
|
||||
if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024
|
||||
|| (contentType != null && contentType.length() > 255))
|
||||
{
|
||||
throw (new QBadRequestException("Attachment metadata exceeds the supported field lengths."));
|
||||
}
|
||||
long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket));
|
||||
if(scopedKey != null)
|
||||
{
|
||||
limit = Math.min(limit, 20L * 1024 * 1024);
|
||||
}
|
||||
Path temporary = null;
|
||||
try
|
||||
{
|
||||
@@ -129,30 +203,69 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
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);
|
||||
row.withValue("retained", lifecycle.isRetained(new QRecord(row)));
|
||||
if(scopedKey != null)
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
|
||||
String fingerprint = tupleHash("attachment-manifest-v1", manifestSha256, name, type, description, String.valueOf(size), row.getValueString("sha256"));
|
||||
row.withValue("intakeKey", scopedKey).withValue("intakeFingerprint", fingerprint);
|
||||
QRecord prior = findIntake(scopedKey);
|
||||
if(prior != null)
|
||||
{
|
||||
if(Boolean.TRUE.equals(prior.getValueBoolean("retained")) && uploadJournal.findByLocation(bucket, key).isPresent())
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
return (replayIntake(prior, fingerprint));
|
||||
}
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
beforeObjectWrite(row);
|
||||
createIntakeObject(bucket, key, Files.readAllBytes(temporary), type, fingerprint);
|
||||
}
|
||||
else
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
{
|
||||
return (journaledUpload(row, temporary));
|
||||
}
|
||||
beforeObjectWrite(row);
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// ordinary uploads keep the transactional cleanup-queue path (355b06d) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
return (storeUpload(row, temporary));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
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).withTransaction(QContext.getQBackendTransaction()).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);
|
||||
rowSaved = true;
|
||||
lifecycle.afterStored(new QRecord(records.getFirst()));
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(scopedKey != null)
|
||||
{
|
||||
QRecord prior = findIntake(scopedKey);
|
||||
if(!rowSaved && prior != null)
|
||||
{
|
||||
return (replayIntake(prior, row.getValueString("intakeFingerprint")));
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
try
|
||||
{
|
||||
if(!rowSaved)
|
||||
if(!rowSaved && !Boolean.TRUE.equals(row.getValueBoolean("retained")))
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
}
|
||||
@@ -184,23 +297,317 @@ public class AttachmentsService
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** On transactional locking backends, own a transaction only after spooling
|
||||
** the bounded input. An existing outer transaction retains the upload lock.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry storeUpload(QRecord row, Path temporary) throws QException, IOException
|
||||
{
|
||||
var transaction = QContext.getQBackendTransaction();
|
||||
if(transaction == null && AttachmentCleanupQueue.supportsUploadOwnership())
|
||||
{
|
||||
try(var owned = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(new InsertInput(CollaborationTables.ATTACHMENT)))
|
||||
{
|
||||
QContext.setTransaction(owned);
|
||||
try
|
||||
{
|
||||
AttachmentEntry result = storeUpload(row, temporary);
|
||||
owned.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(QException | IOException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
owned.rollback();
|
||||
}
|
||||
catch(QException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
if(transaction != null && !transaction.supportsRollbackCallbacks())
|
||||
{
|
||||
throw (new QException("Attachment uploads require a transaction with confirmed rollback support."));
|
||||
}
|
||||
String bucket = row.getValueString("bucketName");
|
||||
String key = row.getValueString("storageKey");
|
||||
AttachmentCleanupQueue cleanup = new AttachmentCleanupQueue(storage);
|
||||
QRecord pending = cleanup.beginUpload(bucket, key);
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(row.getValueLong("sizeBytes"))
|
||||
.withContentType(AttachmentMediaPolicy.storedType(row.getValueString("contentType")))
|
||||
.withContentDisposition(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))));
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
if(pending == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup.compensateUpload(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
if(transaction != null && pending == null)
|
||||
{
|
||||
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
|
||||
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context,
|
||||
() -> cleanup.compensateUpload(bucket, key)));
|
||||
}
|
||||
boolean rowSaved = false;
|
||||
try
|
||||
{
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).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);
|
||||
rowSaved = true;
|
||||
cleanup.completeUpload(pending);
|
||||
lifecycle.afterStored(new QRecord(records.getFirst()));
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(!rowSaved && pending == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanup.compensateUpload(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained bytes cannot be written before durable intent or under an ambient
|
||||
** transaction whose later rollback could lose their metadata association.
|
||||
*******************************************************************************/
|
||||
private void beforeObjectWrite(QRecord row) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retained attachment storage cannot join an ambient transaction."));
|
||||
}
|
||||
lifecycle.beforeStored(new QRecord(row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained originals use qualified bounded conditional creation and durable
|
||||
** owner recovery; ordinary uploads retain the established streaming behavior.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry journaledUpload(QRecord row, Path temporary) throws QException, IOException
|
||||
{
|
||||
if(row.getValueLong("sizeBytes") > JournaledAttachmentUpload.MAX_BYTES)
|
||||
{
|
||||
throw (new QBadRequestException("Retained uploads are limited to 20 MiB by the qualified conditional storage path."));
|
||||
}
|
||||
var receipt = new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).store(row, Files.readAllBytes(temporary));
|
||||
QRecord stored = receipt.attachment();
|
||||
if(!receipt.alreadyComplete())
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(stored.getValueString("tableName"), stored.getValueString("recordId"),
|
||||
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, stored.getValueString("id"));
|
||||
}
|
||||
return (entry(stored));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native confirmation discovery checks the configured current adoption policy.
|
||||
** No original bytes are read and no audit success is asserted during review.
|
||||
*******************************************************************************/
|
||||
public QRecord requireAdoptable(Integer attachmentId) throws QException
|
||||
{
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
lifecycle.authorizeAdoption(new QRecord(row));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Adopt this exact visible original without uploading, copying or deleting
|
||||
** storage. Registration retries retain the same owner attachment identity.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry adoptRetainedAttachment(Integer attachmentId) throws QException
|
||||
{
|
||||
new ExistingAttachmentAdoption(this, storage, lifecycle).adopt(attachmentId);
|
||||
return (entry(requireRow(attachmentId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recover a visible retained upload without accepting bytes, locators or an
|
||||
** actor from the client. Original-actor and current-parent checks are mandatory.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry recoverRetainedUpload(Integer journalId) throws QException
|
||||
{
|
||||
return (entry(new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).recover(journalId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Conditional single-request puts retain the same original across retries.
|
||||
** Byte arrays deliberately avoid a multipart provider path whose conditional
|
||||
** completion support is not qualified. The caller enforces a 20 MiB limit.
|
||||
*******************************************************************************/
|
||||
private void createIntakeObject(String bucket, String key, byte[] bytes, String type, String fingerprint) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, bytes).withContentLength((long) bytes.length).withContentType(AttachmentMediaPolicy.storedType(type))
|
||||
.withContentDisposition("attachment").withIfNoneMatch(true).withMetadata(Map.of("qqq-intake-fingerprint", fingerprint)));
|
||||
}
|
||||
catch(RuntimeException uncertain)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(storage.head(bucket, key).isEmpty())
|
||||
{
|
||||
throw (uncertain);
|
||||
}
|
||||
try(ObjectStream existing = storage.open(bucket, key))
|
||||
{
|
||||
byte[] original = existing.getInputStream().readNBytes(bytes.length + 1);
|
||||
if(existing.getHeaders() == null || existing.getHeaders().getUserMetadata() == null
|
||||
|| !fingerprint.equals(existing.getHeaders().getUserMetadata().get("qqq-intake-fingerprint")) || !MessageDigest.isEqual(original, bytes))
|
||||
{
|
||||
throw (new QException("An intake request cannot replace an existing original."));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException verificationFailure)
|
||||
{
|
||||
throw (new QException("Could not reconcile attachment intake storage.", verificationFailure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal lookup is keyed by authenticated actor and canonical parent scope.
|
||||
*******************************************************************************/
|
||||
private QRecord findIntake(String scopedKey) throws QException
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("intakeKey", QCriteriaOperator.EQUALS, scopedKey).withLimit(1));
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reauthorize persisted identity and retry idempotent owner registration.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry replayIntake(QRecord row, String fingerprint) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
if(!fingerprint.equals(row.getValueString("intakeFingerprint")))
|
||||
{
|
||||
throw (new QBadRequestException("Intake request conflicts with its original manifest."));
|
||||
}
|
||||
lifecycle.afterStored(new QRecord(row));
|
||||
return (entry(row));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Length-prefix all tuple members, including null, so delimiters cannot collide.
|
||||
*******************************************************************************/
|
||||
private String tupleHash(String... values) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try(DataOutputStream output = new DataOutputStream(buffer))
|
||||
{
|
||||
for(String value : values)
|
||||
{
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeInt(value == null ? -1 : bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new QException("Could not identify attachment intake.", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest first, independently of collaboration.view.
|
||||
*******************************************************************************/
|
||||
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
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))))
|
||||
for(QRecord row : AttachmentRecords.query(listFilter(tableName, recordId)))
|
||||
{
|
||||
entries.add(entry(row));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Files-tab pages use one bounded native query, with one same-parent
|
||||
** lookahead row. Authorization is current on every request; offsets are not
|
||||
** snapshots across concurrent insertions or deletions.
|
||||
*******************************************************************************/
|
||||
public AttachmentPage listPage(String tableName, Serializable recordId, int offset, int limit) throws QException
|
||||
{
|
||||
if(offset < 0 || limit < 1 || limit > 100)
|
||||
{
|
||||
throw (new QBadRequestException("Offset must be nonnegative and limit must be between 1 and 100."));
|
||||
}
|
||||
List<QRecord> rows = AttachmentRecords.query(listFilter(tableName, recordId).withSkip(offset).withLimit(limit + 1));
|
||||
List<AttachmentEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
// Preserve the same metadata-policy evaluation for the lookahead row.
|
||||
AttachmentEntry visible = entry(row);
|
||||
if(entries.size() < limit)
|
||||
{
|
||||
entries.add(visible);
|
||||
}
|
||||
}
|
||||
return (new AttachmentPage(List.copyOf(entries), rows.size() > limit));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only authorized display metadata and continuation, never a raw total.
|
||||
*******************************************************************************/
|
||||
public record AttachmentPage(List<AttachmentEntry> items, boolean hasMore)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared canonical source authority, deletion filter and deterministic order.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter listFilter(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
String id = canonicalId(tableName, recordId);
|
||||
return (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)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
@@ -211,17 +618,106 @@ public class AttachmentsService
|
||||
return (url(requireRow(attachmentId), inline));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard download clients preserve the selected delivery authentication mode.
|
||||
*******************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(Serializable attachmentId) throws QException
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
return (downloadDelivery(attachmentId, false));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Preserve the explicit transport mode for preview and download consumers.
|
||||
***************************************************************************/
|
||||
public AttachmentDownload downloadDelivery(Serializable attachmentId, boolean inline) throws QException
|
||||
{
|
||||
return (delivery(requireRow(attachmentId), inline));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a bounded exact original for trusted integrations such as printing.
|
||||
** Authorize the actual parent, enforce lifecycle policy and verify persisted
|
||||
** length and SHA-256 before releasing any bytes. No signed URL is involved.
|
||||
*******************************************************************************/
|
||||
public AttachmentContent readContent(Serializable attachmentId, Integer maximumBytes) throws QException
|
||||
{
|
||||
QRecord row = requireRow(attachmentId);
|
||||
requireUser();
|
||||
requireRetainedPolicy(row);
|
||||
Long size = row.getValueLong("sizeBytes");
|
||||
String sha256 = row.getValueString("sha256");
|
||||
if(maximumBytes == null || maximumBytes < 1 || maximumBytes > 20 * 1024 * 1024)
|
||||
{
|
||||
throw (new QBadRequestException("Content read limit must be between 1 byte and 20 MiB."));
|
||||
}
|
||||
if(size == null || size < 0 || size > maximumBytes || sha256 == null || !sha256.matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QBadRequestException("Attachment exceeds the content read limit or lacks integrity metadata."));
|
||||
}
|
||||
lifecycle.beforeContentRead(new QRecord(row));
|
||||
byte[] bytes;
|
||||
try(ObjectStream content = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
|
||||
{
|
||||
if(content.getContentLength() != null && !size.equals(content.getContentLength()))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
bytes = content.getInputStream().readNBytes(size.intValue() + 1);
|
||||
String actualHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
if(bytes.length != size.longValue() || !sha256.equals(actualHash))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not read attachment content.", e));
|
||||
}
|
||||
lifecycle.afterContentRead(new QRecord(row), size);
|
||||
return (new AttachmentContent(row.getValueInteger("id"), row.getValueString("fileName"), row.getValueString("contentType"), sha256, bytes));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign an already-authorized row, also used by the preview customizer.
|
||||
*******************************************************************************/
|
||||
URL url(QRecord row, boolean inline)
|
||||
URL url(QRecord row, boolean inline) throws QException
|
||||
{
|
||||
return (delivery(row, inline).url());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only the configured owner policy, after exact row authorization.
|
||||
*******************************************************************************/
|
||||
AttachmentDownload delivery(QRecord row, boolean inline) throws QException
|
||||
{
|
||||
requireRetainedPolicy(row);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeAccess(new QRecord(row), inline);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// Preview generation may ignore storage failures, never policy failures.
|
||||
throw (new QException("Attachment access policy failed.", e));
|
||||
}
|
||||
AttachmentDownload configured = lifecycle.downloadDelivery(new QRecord(row));
|
||||
if(configured != null)
|
||||
{
|
||||
return (configured);
|
||||
}
|
||||
PresignGet request = PresignGet.of(row.getValueString("storageKey")).ttl(Duration.ofMinutes(5));
|
||||
if(!inline)
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Objects stored before AttachmentMediaPolicy keep their original headers (possibly an //
|
||||
// active type served inline). The link therefore forces an attachment disposition for //
|
||||
// every type that is not a known passive format, whatever the caller asked for; passive //
|
||||
// previews stay inline. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!inline || !"inline".equals(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))))
|
||||
{
|
||||
request.downloadAs(row.getValueString("fileName"));
|
||||
}
|
||||
return (storage.presign(row.getValueString("bucketName"), request));
|
||||
return (new AttachmentDownload(storage.presign(row.getValueString("bucketName"), request), false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -233,7 +729,7 @@ public class AttachmentsService
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(Serializable id : attachmentIds.stream().distinct().toList())
|
||||
{
|
||||
rows.add(requireMutable(id));
|
||||
rows.add(requireDeletable(id));
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
@@ -250,6 +746,7 @@ public class AttachmentsService
|
||||
{
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
requireRetainedPolicy(row);
|
||||
QRecord change = new QRecord().withValue("id", row.getValue("id"));
|
||||
if(fileName != null)
|
||||
{
|
||||
@@ -259,10 +756,41 @@ public class AttachmentsService
|
||||
{
|
||||
change.setValue("description", description);
|
||||
}
|
||||
lifecycle.beforeMetadataChange(new QRecord(row), new QRecord(change));
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).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());
|
||||
QBackendTransaction callerTransaction = QContext.getQBackendTransaction();
|
||||
try(QBackendTransaction ownedTransaction = callerTransaction == null ? QBackendTransaction.openFor(input) : null)
|
||||
{
|
||||
QBackendTransaction transaction = ownedTransaction == null ? callerTransaction : ownedTransaction;
|
||||
input.setTransaction(transaction);
|
||||
try
|
||||
{
|
||||
lifecycle.validateMetadataTransaction(new QRecord(row), transaction);
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterMetadataChange(new QRecord(row), new QRecord(change), transaction);
|
||||
if(ownedTransaction != null)
|
||||
{
|
||||
ownedTransaction.commit();
|
||||
}
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
if(ownedTransaction != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ownedTransaction.rollback();
|
||||
}
|
||||
catch(QException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
return (entry(requireRow(attachmentId)));
|
||||
}
|
||||
|
||||
@@ -274,6 +802,46 @@ public class AttachmentsService
|
||||
return (rename(attachmentId, null, description));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transfer a retained attachment's reference to an authorized archive parent.
|
||||
** The caller owns the transaction and must roll it back on any failure. Bytes,
|
||||
** hashes, author, storage coordinates and the retained flag never change.
|
||||
*******************************************************************************/
|
||||
public void transferRetainedReference(Serializable attachmentId, String tableName, Serializable recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
requireUser();
|
||||
if(transaction == null || (QContext.getQBackendTransaction() != null && QContext.getQBackendTransaction() != transaction))
|
||||
{
|
||||
throw (new QException("Archive transfer requires the caller's explicit transaction."));
|
||||
}
|
||||
QRecord row = requireRow(attachmentId);
|
||||
if(!Boolean.TRUE.equals(row.getValueBoolean("retained")) || tableName == null || recordId == null)
|
||||
{
|
||||
throw (new QException("Only a retained attachment can be transferred to an archive."));
|
||||
}
|
||||
var candidate = uploadJournal.findByLocation(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
if(candidate.isPresent() && candidate.get().state() != com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState.COMPLETE)
|
||||
{
|
||||
throw (new QException("Retained upload registration must complete before archival reference transfer."));
|
||||
}
|
||||
QRecord target = com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.visibleParent(tableName, recordId, transaction);
|
||||
if(target == null)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
String canonical = target.getValueString(QContext.getQInstance().getTable(tableName).getPrimaryKeyField());
|
||||
lifecycle.beforeReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
|
||||
QRecord change = new QRecord().withValue("id", row.getValue("id")).withValue("tableName", tableName).withValue("recordId", canonical);
|
||||
com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.copyMirroredLocks(
|
||||
QContext.getQInstance().getTable(tableName), target, QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT), change);
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(transaction)
|
||||
.withRecords(List.of(change));
|
||||
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
input.withPermissionBypass("Installed retention owner authorized this exact archival reference transfer and transaction.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Process Review uses persisted identity rather than trusting client rows.
|
||||
*******************************************************************************/
|
||||
@@ -282,7 +850,7 @@ public class AttachmentsService
|
||||
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
|
||||
QRecord row = requireRow(attachmentId);
|
||||
requireUser();
|
||||
if(!canDelete(row))
|
||||
if(!canModify(row))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment."));
|
||||
}
|
||||
@@ -291,7 +859,17 @@ public class AttachmentsService
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete storage first so a provider failure leaves a retryable row.
|
||||
** Deletion reviews must include retention as well as parent and ownership.
|
||||
*******************************************************************************/
|
||||
public QRecord requireDeletable(Serializable attachmentId) throws QException
|
||||
{
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
requireNotRetained(row);
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Atomically remove metadata and retain a durable storage cleanup request.
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row) throws QException
|
||||
{
|
||||
@@ -303,11 +881,20 @@ public class AttachmentsService
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
|
||||
{
|
||||
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withPrimaryKeys(List.of(row.getValue("id")));
|
||||
input.setTableMetaData(cleanup ? com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
if(isRetained(row))
|
||||
{
|
||||
if(!cleanup)
|
||||
{
|
||||
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
|
||||
}
|
||||
UpdateInput mark = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction())
|
||||
.withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("parentDeleted", true)));
|
||||
mark.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
mark.withPermissionBypass("Trusted parent cleanup retains the original and marks its parent deleted.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(mark).getRecords());
|
||||
return;
|
||||
}
|
||||
new AttachmentCleanupQueue(storage).delete(row, cleanup);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -336,7 +923,7 @@ public class AttachmentsService
|
||||
/*******************************************************************************
|
||||
** Ownership is layered on parent visibility; admins still need that visibility.
|
||||
*******************************************************************************/
|
||||
private boolean canDelete(QRecord row)
|
||||
private boolean canModify(QRecord row)
|
||||
{
|
||||
String user = CollaborationSecurity.userId(QContext.getQSession());
|
||||
return (user != null && (isAdmin() || user.equals(row.getValueString("createdByUserId"))));
|
||||
@@ -366,11 +953,53 @@ public class AttachmentsService
|
||||
/*******************************************************************************
|
||||
** Return display data, without internal storage coordinates.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry entry(QRecord row)
|
||||
private AttachmentEntry entry(QRecord row) throws QException
|
||||
{
|
||||
boolean canEdit = canModify(row) && !(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class));
|
||||
if(canEdit)
|
||||
{
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
canEdit = false;
|
||||
}
|
||||
}
|
||||
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)));
|
||||
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canEdit && !isRetained(row), canEdit));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preflight every selected row before deleting any storage object.
|
||||
*******************************************************************************/
|
||||
private void requireNotRetained(QRecord row) throws QException
|
||||
{
|
||||
if(isRetained(row))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted retention survives removal or reconfiguration of the provider.
|
||||
*******************************************************************************/
|
||||
private boolean isRetained(QRecord row) throws QException
|
||||
{
|
||||
return (Boolean.TRUE.equals(row.getValueBoolean("retained")) || lifecycle.isRetained(new QRecord(row)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing the configured provider must not permit unaudited retained access.
|
||||
*******************************************************************************/
|
||||
private void requireRetainedPolicy(QRecord row) throws QException
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class))
|
||||
{
|
||||
throw (new QException("Retained attachment access requires its lifecycle policy."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+42
-6
@@ -92,7 +92,7 @@ public final class DeleteAttachmentsProcess
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentsService service;
|
||||
private 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");
|
||||
|
||||
@@ -101,7 +101,11 @@ public final class DeleteAttachmentsProcess
|
||||
*******************************************************************************/
|
||||
public Review()
|
||||
{
|
||||
this(new AttachmentsService());
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// no QContext here: QQQ constructs steps during context-free instance //
|
||||
// validation, so the service is created on first use (in a run) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
this(null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -112,6 +116,20 @@ public final class DeleteAttachmentsProcess
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The attachments service, created on first use inside a run (QContext exists).
|
||||
*******************************************************************************/
|
||||
private AttachmentsService service()
|
||||
{
|
||||
if(service == null)
|
||||
{
|
||||
service = new AttachmentsService();
|
||||
}
|
||||
return (service);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Dry run only; no object or row mutations.
|
||||
*******************************************************************************/
|
||||
@@ -122,7 +140,7 @@ public final class DeleteAttachmentsProcess
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord checked = service.requireMutable(row.getValue("id"));
|
||||
QRecord checked = service().requireDeletable(row.getValue("id"));
|
||||
output.getRecords().add(checked);
|
||||
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
|
||||
}
|
||||
@@ -151,7 +169,7 @@ public final class DeleteAttachmentsProcess
|
||||
*******************************************************************************/
|
||||
public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentsService service;
|
||||
private 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)");
|
||||
|
||||
@@ -160,7 +178,11 @@ public final class DeleteAttachmentsProcess
|
||||
*******************************************************************************/
|
||||
public Delete()
|
||||
{
|
||||
this(new AttachmentsService());
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// no QContext here: QQQ constructs steps during context-free instance //
|
||||
// validation, so the service is created on first use (in a run) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
this(null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -171,6 +193,20 @@ public final class DeleteAttachmentsProcess
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The attachments service, created on first use inside a run (QContext exists).
|
||||
*******************************************************************************/
|
||||
private AttachmentsService service()
|
||||
{
|
||||
if(service == null)
|
||||
{
|
||||
service = new AttachmentsService();
|
||||
}
|
||||
return (service);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count success only after both storage and row deletion succeed.
|
||||
*******************************************************************************/
|
||||
@@ -181,7 +217,7 @@ public final class DeleteAttachmentsProcess
|
||||
{
|
||||
try
|
||||
{
|
||||
service.delete(List.of(row.getValue("id")));
|
||||
service().delete(List.of(row.getValue("id")));
|
||||
deleted.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException | RuntimeException expected)
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.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.module.rdbms.actions.RDBMSTransaction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner-only adoption of existing bytes. A durable intent precedes verification;
|
||||
** retention and its audit commit together, then idempotent registration follows.
|
||||
*******************************************************************************/
|
||||
final class ExistingAttachmentAdoption
|
||||
{
|
||||
private static final long MAX_BYTES = 20L * 1024 * 1024;
|
||||
private final AttachmentsService service;
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
|
||||
/***************************************************************************
|
||||
** Dependencies are the actual configured owner boundaries.
|
||||
***************************************************************************/
|
||||
ExistingAttachmentAdoption(AttachmentsService service, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
|
||||
{
|
||||
this.service = service;
|
||||
this.storage = storage;
|
||||
this.lifecycle = lifecycle;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** No external transaction, caller locator, bytes, receipt or force option.
|
||||
***************************************************************************/
|
||||
void adopt(Integer id) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
var session = QContext.getQSession();
|
||||
String actor = CollaborationSecurity.userId(session);
|
||||
Check check = () ->
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null || QContext.getQInstance() != instance || QContext.getQSession() != session
|
||||
|| actor == null || !actor.equals(CollaborationSecurity.userId(session)))
|
||||
{
|
||||
throw (new QException("Adoption requires the unchanged initiating context and its own transaction."));
|
||||
}
|
||||
};
|
||||
check.run();
|
||||
QRecord original = service.requireMutable(id);
|
||||
if(original.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Coordinated intake must recover through its original request."));
|
||||
}
|
||||
requireIntegrity(original);
|
||||
lifecycle.authorizeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
lifecycle.beforeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
same(original, service.requireMutable(id));
|
||||
QRecord retained = new QRecord(original).withValue("retained", true);
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT)
|
||||
.withRecords(List.of(new QRecord().withValue("id", id).withValue("retained", true)));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Attachment owner rechecked current parent, author/admin and audited retention policy.");
|
||||
try(QBackendTransaction transaction = QBackendTransaction.openFor(input))
|
||||
{
|
||||
try
|
||||
{
|
||||
lifecycle.validateMetadataTransaction(retained, transaction);
|
||||
check.run();
|
||||
lock(transaction, id);
|
||||
same(original, read(transaction, id));
|
||||
verify(original);
|
||||
check.run();
|
||||
same(original, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(original));
|
||||
check.run();
|
||||
if(!Boolean.TRUE.equals(original.getValueBoolean("retained")))
|
||||
{
|
||||
input.setTransaction(transaction);
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
lifecycle.afterAdoption(retained, transaction);
|
||||
}
|
||||
check.run();
|
||||
service.requireMutable(id);
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
transaction.commit();
|
||||
}
|
||||
catch(QException | RuntimeException | Error failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
transaction.rollback();
|
||||
}
|
||||
catch(QException | RuntimeException cleanup)
|
||||
{
|
||||
if(cleanup != failure)
|
||||
{
|
||||
failure.addSuppressed(cleanup);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
check.run();
|
||||
same(retained, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
lifecycle.afterStored(new QRecord(retained));
|
||||
check.run();
|
||||
same(retained, service.requireMutable(id));
|
||||
lifecycle.authorizeAdoption(new QRecord(retained));
|
||||
check.run();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Native metadata supplies identifiers; no caller text becomes SQL.
|
||||
***************************************************************************/
|
||||
private void lock(QBackendTransaction transaction, Integer id) throws QException
|
||||
{
|
||||
if(!(transaction instanceof RDBMSTransaction jdbc))
|
||||
{
|
||||
throw (new QException("Existing-original adoption requires a real relational transaction."));
|
||||
}
|
||||
var table = AttachmentRecords.storageTable();
|
||||
String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details ? details.getTableName() : table.getName();
|
||||
String key = table.getField(table.getPrimaryKeyField()).getBackendName();
|
||||
key = key == null ? table.getPrimaryKeyField() : key;
|
||||
if(tableName == null || !tableName.matches("[A-Za-z_][A-Za-z0-9_]*") || !key.matches("[A-Za-z_][A-Za-z0-9_]*"))
|
||||
{
|
||||
throw (new QException("Unsupported attachment storage mapping."));
|
||||
}
|
||||
try
|
||||
{
|
||||
String quote = jdbc.getConnection().getMetaData().getIdentifierQuoteString().trim();
|
||||
if(!List.of("\"", "`").contains(quote))
|
||||
{
|
||||
throw (new QException("Unsupported attachment identifier quoting."));
|
||||
}
|
||||
try(var statement = jdbc.getConnection().prepareStatement("SELECT " + quote + key + quote + " FROM " + quote + tableName + quote
|
||||
+ " WHERE " + quote + key + quote + " = ? FOR UPDATE"))
|
||||
{
|
||||
statement.setQueryTimeout(5);
|
||||
statement.setObject(1, id);
|
||||
try(var result = statement.executeQuery())
|
||||
{
|
||||
if(!result.next())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(java.sql.SQLException failure)
|
||||
{
|
||||
throw (new QException("Could not lock the original attachment.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Read the locked original in the same transaction without URL customizers.
|
||||
***************************************************************************/
|
||||
private QRecord read(QBackendTransaction transaction, Integer id) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withShouldFetchHeavyFields(true)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id).withLimit(1));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Owner adoption already authorized the exact parent and attachment.");
|
||||
var rows = new QueryAction().execute(input).getRecords();
|
||||
if(rows.size() != 1)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Compare persisted metadata, allowing only the native modification timestamp.
|
||||
** The caller supplies the exact expected retention state at each boundary.
|
||||
***************************************************************************/
|
||||
private void same(QRecord expected, QRecord actual) throws QException
|
||||
{
|
||||
for(var field : AttachmentRecords.storageTable().getFields().keySet())
|
||||
{
|
||||
if(!"modifyDate".equals(field) && !Objects.equals(expected.getValue(field), actual.getValue(field)))
|
||||
{
|
||||
throw (new QException("Attachment metadata changed during adoption."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Reject ambiguous or oversized originals before opening storage.
|
||||
***************************************************************************/
|
||||
private void requireIntegrity(QRecord row) throws QException
|
||||
{
|
||||
Long size = row.getValueLong("sizeBytes");
|
||||
if(size == null || size < 0 || size > MAX_BYTES || row.getValueString("sha256") == null
|
||||
|| !row.getValueString("sha256").matches("[0-9a-f]{64}"))
|
||||
{
|
||||
throw (new QException("Original integrity metadata is missing or outside the adoption bound."));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Stream and hash the existing original without releasing or writing bytes.
|
||||
***************************************************************************/
|
||||
private void verify(QRecord row) throws QException
|
||||
{
|
||||
try(ObjectStream object = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
|
||||
{
|
||||
long expected = row.getValueLong("sizeBytes");
|
||||
if(object.getContentLength() != null && object.getContentLength() != expected)
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0;
|
||||
int count;
|
||||
while((count = object.getInputStream().read(buffer, 0, (int) Math.min(buffer.length, expected - total + 1))) != -1)
|
||||
{
|
||||
total += count;
|
||||
if(total > expected)
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
digest.update(buffer, 0, count);
|
||||
}
|
||||
if(total != expected || !row.getValueString("sha256").equals(HexFormat.of().formatHex(digest.digest())))
|
||||
{
|
||||
throw (new AttachmentContentIntegrityException());
|
||||
}
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException | RuntimeException failure)
|
||||
{
|
||||
throw (new QException("Could not verify the existing original.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Checked context guard without installing a different session or authority.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Check
|
||||
{
|
||||
/***********************************************************************
|
||||
** Recheck the initiating context after external callbacks.
|
||||
***********************************************************************/
|
||||
void run() throws QException;
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadCandidate;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadEntry;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadReservation;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadStorageState;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.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.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained-only coordination of one immutable original and its durable owner
|
||||
** manifest. All database ownership stays in Collaboration; lifecycle registration
|
||||
** may commit in its own module after the attachment checkpoint is durable.
|
||||
*******************************************************************************/
|
||||
final class JournaledAttachmentUpload
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The caller can avoid repeating non-mandatory timeline activity on replay.
|
||||
*******************************************************************************/
|
||||
record Receipt(QRecord attachment, boolean alreadyComplete) {}
|
||||
|
||||
static final long MAX_BYTES = 20L * 1024 * 1024;
|
||||
private static final Duration LEASE = Duration.ofMinutes(5);
|
||||
private final AttachmentStorage storage;
|
||||
private final AttachmentLifecyclePolicy lifecycle;
|
||||
private final UploadJournal journal;
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit owner dependencies; the journal never receives file bytes.
|
||||
*******************************************************************************/
|
||||
JournaledAttachmentUpload(AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle, UploadJournal journal)
|
||||
{
|
||||
this.storage = storage;
|
||||
this.lifecycle = lifecycle;
|
||||
this.journal = journal;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist intent before any PUT. Conditional creation never replaces an
|
||||
** original. A late or expired PUT cannot authorize metadata registration.
|
||||
*******************************************************************************/
|
||||
Receipt store(QRecord row, byte[] bytes) throws QException
|
||||
{
|
||||
requireIndependent();
|
||||
UploadCandidate candidate = candidate(row);
|
||||
authorize(candidate);
|
||||
if(bytes.length > MAX_BYTES || bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
|
||||
{
|
||||
throw (new QException("Retained original exceeds the qualified bound or changed before storage."));
|
||||
}
|
||||
UploadEntry entry = journal.prepare(candidate);
|
||||
if(entry.state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
QRecord existing = stored(entry);
|
||||
lifecycle.afterStored(new QRecord(existing));
|
||||
return (new Receipt(existing, true));
|
||||
}
|
||||
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeStored(new QRecord(row));
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
if(entry.storageState() != UploadStorageState.VERIFIED)
|
||||
{
|
||||
journal.recordStorage(reservation, UploadStorageState.UNKNOWN);
|
||||
try
|
||||
{
|
||||
storage.put(candidate.bucketName(), PutRequest.of(candidate.storageKey(), bytes).withContentLength(candidate.sizeBytes())
|
||||
.withContentType(candidate.contentType()).withContentDisposition("attachment").withIfNoneMatch(true)
|
||||
.withMetadata(Map.of("qqq-upload-manifest", candidate.manifestHash())));
|
||||
}
|
||||
catch(RuntimeException uncertain)
|
||||
{
|
||||
try
|
||||
{
|
||||
verify(candidate);
|
||||
}
|
||||
catch(QException verification)
|
||||
{
|
||||
verification.addSuppressed(uncertain);
|
||||
throw (verification);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
verify(candidate);
|
||||
}
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
|
||||
return (new Receipt(register(reservation), false));
|
||||
}
|
||||
finally
|
||||
{
|
||||
journal.release(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard recovery reads an already stored original and never writes bytes.
|
||||
** Coordinated intake must retry through its original coordinator and scope.
|
||||
*******************************************************************************/
|
||||
QRecord recover(Integer journalId) throws QException
|
||||
{
|
||||
requireIndependent();
|
||||
UploadEntry entry = journal.find(journalId).orElseThrow(() -> new QNotFoundException("Not found"));
|
||||
if(!PersonalRecords.currentUserId().equals(entry.candidate().actorId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
if(entry.state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
QRecord row = stored(entry);
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
return (row);
|
||||
}
|
||||
authorize(entry.candidate());
|
||||
if(entry.candidate().intakeKey() != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its registration context."));
|
||||
}
|
||||
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
|
||||
try
|
||||
{
|
||||
lifecycle.beforeRecovery(row(entry.candidate()));
|
||||
verify(entry.candidate());
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
|
||||
return (register(reservation));
|
||||
}
|
||||
finally
|
||||
{
|
||||
journal.release(reservation);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Attachment and journal checkpoint commit together. Replays use the same
|
||||
** immutable attachment id and retry the mandatory idempotent lifecycle hook.
|
||||
*******************************************************************************/
|
||||
private QRecord register(UploadReservation reservation) throws QException
|
||||
{
|
||||
authorize(reservation.candidate());
|
||||
UploadEntry entry = journal.register(reservation, (transaction, candidate, journalId) ->
|
||||
{
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withRecords(List.of(row(candidate)));
|
||||
input.withPermissionBypass("Retained owner journal reauthorized the original actor and current parent attach capability.");
|
||||
List<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
if(records.size() != 1)
|
||||
{
|
||||
throw (new QException("Retained registration did not create exactly one owner row."));
|
||||
}
|
||||
return (records.getFirst().getValueInteger("id"));
|
||||
});
|
||||
QRecord stored = stored(entry);
|
||||
lifecycle.afterStored(new QRecord(stored));
|
||||
reservation = journal.renew(reservation, LEASE);
|
||||
journal.complete(reservation);
|
||||
return (stored);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact original bytes must be readable; missing or ambiguous storage never
|
||||
** becomes a cancellation or permission to delete the source record.
|
||||
*******************************************************************************/
|
||||
private void verify(UploadCandidate candidate) throws QException
|
||||
{
|
||||
if(candidate.sizeBytes() > MAX_BYTES)
|
||||
{
|
||||
throw (new QException("Retained recovery exceeds the qualified content bound."));
|
||||
}
|
||||
try(ObjectStream object = storage.open(candidate.bucketName(), candidate.storageKey()))
|
||||
{
|
||||
byte[] bytes = object.getInputStream().readNBytes(Math.toIntExact(candidate.sizeBytes()) + 1);
|
||||
if(bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
|
||||
{
|
||||
throw (new QException("Stored original does not match its immutable journal manifest."));
|
||||
}
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
throw (new QException("Original storage remains unresolved; no absence or deletion is inferred.", failure));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the checkpoint without exposing storage coordinates to clients.
|
||||
*******************************************************************************/
|
||||
private QRecord stored(UploadEntry entry) throws QException
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, entry.attachmentId()).withLimit(1));
|
||||
if(rows.size() != 1 || !entry.candidate().sha256().equals(rows.getFirst().getValueString("sha256"))
|
||||
|| !entry.candidate().sizeBytes().equals(rows.getFirst().getValueLong("sizeBytes"))
|
||||
|| !entry.candidate().actorId().equals(rows.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
throw (new QException("Retained journal checkpoint does not match its immutable attachment."));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorization is current and actor-specific; the manifest cannot impersonate.
|
||||
*******************************************************************************/
|
||||
private void authorize(UploadCandidate candidate) throws QException
|
||||
{
|
||||
if(!PersonalRecords.currentUserId().equals(candidate.actorId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireFeature(candidate.tableName(), candidate.recordId(), "attach");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retained registration cannot participate in an unrelated caller transaction.
|
||||
*******************************************************************************/
|
||||
private void requireIndependent() throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new QException("Retained upload recovery requires its own committed transactions."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-owned values only; no client-supplied locator or actor is accepted.
|
||||
*******************************************************************************/
|
||||
private UploadCandidate candidate(QRecord row)
|
||||
{
|
||||
return (new UploadCandidate(row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("createdByUserId"),
|
||||
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueString("description"), row.getValueLong("sizeBytes"),
|
||||
row.getValueString("sha256"), row.getValueString("bucketName"), row.getValueString("storageKey"), row.getValueBoolean("retained"),
|
||||
row.getValueString("intakeKey"), row.getValueString("intakeFingerprint")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconstruct only owner metadata from the immutable saved manifest.
|
||||
*******************************************************************************/
|
||||
private QRecord row(UploadCandidate candidate)
|
||||
{
|
||||
return (new QRecord().withValue("tableName", candidate.tableName()).withValue("recordId", candidate.recordId())
|
||||
.withValue("createdByUserId", candidate.actorId()).withValue("fileName", candidate.fileName()).withValue("contentType", candidate.contentType())
|
||||
.withValue("description", candidate.description()).withValue("sizeBytes", candidate.sizeBytes()).withValue("sha256", candidate.sha256())
|
||||
.withValue("bucketName", candidate.bucketName()).withValue("storageKey", candidate.storageKey()).withValue("retained", true)
|
||||
.withValue("parentDeleted", false).withValue("intakeKey", candidate.intakeKey()).withValue("intakeFingerprint", candidate.intakeFingerprint()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of actual bounded bytes, never a storage metadata assertion.
|
||||
*******************************************************************************/
|
||||
private String hash(byte[] bytes) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
|
||||
}
|
||||
catch(NoSuchAlgorithmException failure)
|
||||
{
|
||||
throw (new QException("SHA-256 is unavailable.", failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.processes.QProcessMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A visible native recovery action for retained original registration. It never
|
||||
** accepts replacement bytes, a storage path, an actor, or an intake scope.
|
||||
*******************************************************************************/
|
||||
public final class RecoverRetainedUploadProcess
|
||||
{
|
||||
public static final String NAME = "collaborationRecoverRetainedUpload";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private RecoverRetainedUploadProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ confirmation and result screens, attached to the journal table.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
return (new QProcessMetaData().withName(NAME).withTableName(UploadJournalTable.NAME).withLabel("Recover retained upload")
|
||||
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
||||
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
|
||||
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(UploadJournalTable.NAME))))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Recover the original upload")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("confirmRecovery", QFieldType.BOOLEAN).withLabel("Verify the stored original and retry registration").withDefaultValue(false).withIsRequired(true)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("recover").withCode(new QCodeReference(Recover.class)))
|
||||
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original registration saved")
|
||||
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
|
||||
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
|
||||
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve one visible journal selection before displaying confirmation.
|
||||
*******************************************************************************/
|
||||
public static final class Prepare implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read-only preview under current actor and parent visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(input.getRecords() == null || input.getRecords().size() != 1)
|
||||
{
|
||||
throw (new QException("Select exactly one retained upload."));
|
||||
}
|
||||
var row = new GetAction().execute(new GetInput(UploadJournalTable.NAME).withPrimaryKey(input.getRecords().getFirst().getValue("id"))).getRecord();
|
||||
if(row == null)
|
||||
{
|
||||
throw (new QException("Retained upload is unavailable."));
|
||||
}
|
||||
if(row.getValueString("intakeKey") != null)
|
||||
{
|
||||
throw (new QException("Retry this upload through its original intake request to preserve its context."));
|
||||
}
|
||||
output.addValue("journalId", row.getValue("id"));
|
||||
output.addValue("confirmRecovery", false);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execution reauthorizes independently of the earlier preview.
|
||||
*******************************************************************************/
|
||||
public static final class Recover implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The owner returns only after mandatory lifecycle registration completes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
if(!Boolean.TRUE.equals(input.getValue("confirmRecovery")))
|
||||
{
|
||||
throw (new QException("Confirm retained upload recovery to continue."));
|
||||
}
|
||||
output.addValue("attachmentId", new AttachmentsService().recoverRetainedUpload(input.getValueInteger("journalId")).id());
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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.Map;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.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.code.QCodeReference;
|
||||
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.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit provider settlement through the standard table action and Review flow.
|
||||
*******************************************************************************/
|
||||
public class ResolveAttachmentUploadProcess implements CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationResolveAttachmentUpload";
|
||||
public static final String EVIDENCE = "settlementEvidence";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative table action, never a navigation entry or scheduled process.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"Confirm that the provider has completed or cancelled every selected upload. Object absence or elapsed time alone is not proof. Current bytes will be deleted; the settlement receipt will be retained.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName(NAME).withLabel("Resolve settled uploads").withTableName(CollaborationTables.ATTACHMENT_CLEANUP).withIcon(new QIcon("check"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(ResolveAttachmentUploadProcess.class)))
|
||||
.withStep(0, new QFrontendStepMetaData().withName("evidence").withLabel("Confirm provider settlement")
|
||||
.withFormField(new QFieldMetaData(EVIDENCE, QFieldType.STRING).withLabel("Provider completion or cancellation evidence")
|
||||
.withMaxLength(2000).withIsRequired(true))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard direct process invocation as well as menu visibility.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared standard summary language for Review and Result.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(future);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review never changes provider state or records a completed settlement.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine selected = line(Status.OK, "uploads will be resolved", "uploads selected");
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "resolved records or committed deletions will be skipped", "records skipped");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require evidence before allowing the administrator to review selected rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
if(row.getValue("attachmentId") != null || row.getValue("resolvedAt") != null)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
output.getRecords().add(row);
|
||||
selected.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep streamed totals across pages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply checks current authorization, row ownership and references again.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentCleanupQueue queue;
|
||||
private final ProcessSummaryLine done = line(Status.OK, "uploads will be resolved", "uploads resolved");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "uploads cannot be resolved", "uploads unresolved; inspect and retry");
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construction is also used during context-free metadata validation.
|
||||
*******************************************************************************/
|
||||
public Apply()
|
||||
{
|
||||
this(new AttachmentCleanupQueue());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same service with a fixture storage provider in process tests.
|
||||
*******************************************************************************/
|
||||
public Apply(AttachmentCleanupQueue queue)
|
||||
{
|
||||
this.queue = queue;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each locator has its own transaction and immutable settlement receipt.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AttachmentCleanupQueue.requireSettlementOperator();
|
||||
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
queue.resolveUpload(row.getValue("id"), input.getValueString(EVIDENCE));
|
||||
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report actual application results and retain review exclusions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
done.addSelfToListIfAnyCount(lines);
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled durable attachment cleanup. Runs with its caller's session, which must be a
|
||||
** scheduler system session or collaboration admin; never elevates a user.
|
||||
*******************************************************************************/
|
||||
public class RetryAttachmentCleanupProcess implements BackendStep, CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationRetryAttachmentCleanup";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always register the backend-only process; scheduling is opt-in.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(CollaborationQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Retry attachment cleanup").withIsHidden(true).withIcon(new QIcon("history"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(RetryAttachmentCleanupProcess.class)))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(RetryAttachmentCleanupProcess.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden is a presentation setting, so explicitly guard execution as well.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry committed cleanup intents after provider or process recovery.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
checkPermissionsThrowing(input, null);
|
||||
new AttachmentCleanupQueue().retryPending();
|
||||
}
|
||||
}
|
||||
+799
@@ -0,0 +1,799 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real JDBC implementation for file-backed H2 with WRITE_DELAY=0 and explicitly
|
||||
** qualified MySQL 8.4/InnoDB and PostgreSQL 17 connections, with the
|
||||
** journal and the attachment table on the same backend. This is not a portable
|
||||
** production database claim; any other backend, a memory database, a no-op
|
||||
** transaction or an ambient caller transaction is rejected before any write.
|
||||
**
|
||||
** Every operation opens and owns its own transaction (commit on success, rollback
|
||||
** on any failure; no partial success). Mutations lock the row with a bounded
|
||||
** FOR UPDATE, verify the lease token, fence and expiry, and then also guard the
|
||||
** UPDATE with the same token and fence and require exactly one row. SQL uses only
|
||||
** the fixed owner table name and literal text; all values are bound parameters.
|
||||
** Nothing here ever deletes a row or an object, and it never touches bytes.
|
||||
**
|
||||
** Expiry uses this JVM's clock. Hosts must keep lease durations well above their
|
||||
** clock skew; the fence, not the clock, is what stops a stale holder from
|
||||
** writing once a newer reservation exists.
|
||||
**
|
||||
** Runtime checks cannot qualify a deployment's disks, backups or failover policy.
|
||||
*******************************************************************************/
|
||||
public final class JdbcUploadJournal implements UploadJournal
|
||||
{
|
||||
private static final String TABLE = CollaborationTables.UPLOAD_JOURNAL;
|
||||
private static final int QUERY_TIMEOUT_SECONDS = 10;
|
||||
private static final int MAXIMUM_LIST = 500;
|
||||
private static final Duration MINIMUM_LEASE = Duration.ofSeconds(1);
|
||||
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(30);
|
||||
|
||||
private static final String COLUMNS = "id, table_name, record_id, created_by_user_id, file_name, content_type, description, size_bytes, sha256,"
|
||||
+ " bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash, manifest_hash, state, storage_state, attachment_id,"
|
||||
+ " lease_token, lease_until, fence";
|
||||
private static final String SELECT_ALL = "SELECT " + COLUMNS + " FROM " + TABLE;
|
||||
private static final String BY_ID = SELECT_ALL + " WHERE id = ?";
|
||||
private static final String BY_ID_LOCKED = BY_ID + " FOR UPDATE";
|
||||
private static final String BY_LOCATOR = SELECT_ALL + " WHERE locator_hash = ?";
|
||||
private static final String BY_INTAKE = SELECT_ALL + " WHERE intake_key = ?";
|
||||
private static final String INCOMPLETE = SELECT_ALL + " WHERE state <> 'COMPLETE' ORDER BY id LIMIT ?";
|
||||
|
||||
private static final String INSERT = "INSERT INTO " + TABLE + " (create_date, modify_date, table_name, record_id, created_by_user_id, file_name,"
|
||||
+ " content_type, description, size_bytes, sha256, bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash,"
|
||||
+ " manifest_hash, state, storage_state, fence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PREPARED', 'PENDING', 0)";
|
||||
private static final String GRANT = "UPDATE " + TABLE + " SET lease_token = ?, lease_until = ?, fence = ?, modify_date = ? WHERE id = ? AND fence = ?";
|
||||
private static final String SET_STORAGE = "UPDATE " + TABLE + " SET storage_state = ?, modify_date = ? WHERE id = ? AND lease_token = ? AND fence = ?";
|
||||
private static final String SET_REGISTERED = "UPDATE " + TABLE + " SET state = 'REGISTERED', attachment_id = ?, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'PREPARED'";
|
||||
private static final String SET_COMPLETE = "UPDATE " + TABLE + " SET state = 'COMPLETE', lease_token = NULL, lease_until = NULL, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'REGISTERED'";
|
||||
private static final String CLEAR_LEASE = "UPDATE " + TABLE + " SET lease_token = NULL, lease_until = NULL, modify_date = ?"
|
||||
+ " WHERE id = ? AND lease_token = ? AND fence = ?";
|
||||
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal using the system clock.
|
||||
*******************************************************************************/
|
||||
public JdbcUploadJournal()
|
||||
{
|
||||
this(Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local deterministic clock seam for lease-expiry tests.
|
||||
*******************************************************************************/
|
||||
JdbcUploadJournal(Clock clock)
|
||||
{
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One database transaction's worth of work.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface Work<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Run inside the owned, qualified transaction.
|
||||
*******************************************************************************/
|
||||
T run(RDBMSTransaction transaction, Connection connection) throws SQLException, QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A locked or read row with the journal-private secrets.
|
||||
*******************************************************************************/
|
||||
private record Row(UploadEntry entry, String leaseToken, String manifestHash)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static admission of file H2, native mysql, or the native postgres backend
|
||||
** (vendor and backend type both "postgres"). Actual engine, catalog, table
|
||||
** storage and durability checks additionally run in every transaction.
|
||||
*******************************************************************************/
|
||||
public static boolean isQualifiedBackend(QBackendMetaData backend)
|
||||
{
|
||||
if(backend instanceof RDBMSBackendMetaData postgres && PostgresJournalDatabase.is(postgres))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (backend instanceof RDBMSBackendMetaData metadata && RDBMSBackendModule.NAME.equals(metadata.getBackendType())
|
||||
&& ("mysql".equals(metadata.getVendor()) || ("h2".equals(metadata.getVendor())
|
||||
&& metadata.getJdbcUrl() != null && metadata.getJdbcUrl().startsWith("jdbc:h2:file:"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry prepare(UploadCandidate candidate) throws QException
|
||||
{
|
||||
Objects.requireNonNull(candidate, "candidate");
|
||||
try
|
||||
{
|
||||
return (inTransaction((transaction, connection) -> insertOrReplay(connection, candidate)));
|
||||
}
|
||||
catch(UploadJournalException e)
|
||||
{
|
||||
if(e.getReason() != Reason.DUPLICATE)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
// Lost a unique-key race: the winner committed, so read its row and compare.
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
UploadEntry existing = existing(connection, candidate);
|
||||
if(existing == null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The journal reported a duplicate but no matching row exists."));
|
||||
}
|
||||
return (existing);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadReservation reserve(Integer journalId, Duration lease) throws QException
|
||||
{
|
||||
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = lock(connection, journalId);
|
||||
Instant now = now();
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "This upload is already complete."));
|
||||
}
|
||||
if(row.entry().isLeased(now))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder has a live reservation on this upload."));
|
||||
}
|
||||
String token = UUID.randomUUID().toString();
|
||||
Long fence = row.entry().fence() + 1;
|
||||
Instant until = now.plus(lease);
|
||||
if(update(connection, GRANT, token, Timestamp.from(until), fence, Timestamp.from(now), journalId, row.entry().fence()) != 1)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder took this reservation first."));
|
||||
}
|
||||
return (new UploadReservation(row.entry().leased(fence, until), token, fence, until));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
if(storageState == null || storageState == UploadStorageState.PENDING)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Storage knowledge can only advance to UNKNOWN or VERIFIED."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
UploadStorageState current = row.entry().storageState();
|
||||
if(current == storageState)
|
||||
{
|
||||
return (row.entry());
|
||||
}
|
||||
if(current == UploadStorageState.VERIFIED)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Verified storage cannot move back to UNKNOWN."));
|
||||
}
|
||||
if(update(connection, SET_STORAGE, storageState.name(), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew at synchronous stage boundaries without reviving an expired holder.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadReservation renew(UploadReservation reservation, Duration lease) throws QException
|
||||
{
|
||||
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
Instant until = now().plus(lease);
|
||||
if(update(connection, "UPDATE " + TABLE + " SET lease_until=?, modify_date=? WHERE id=? AND lease_token=? AND fence=?",
|
||||
Timestamp.from(until), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (new UploadReservation(row.entry().leased(reservation.fence(), until), reservation.leaseToken(), reservation.fence(), until));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
Objects.requireNonNull(registrar, "registrar");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = requireHolder(connection, reservation);
|
||||
if(row.entry().state() != UploadJournalState.PREPARED)
|
||||
{
|
||||
return (row.entry());
|
||||
}
|
||||
if(row.entry().storageState() != UploadStorageState.VERIFIED)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.STORAGE_NOT_VERIFIED, "The original object has not been verified, so it cannot be registered."));
|
||||
}
|
||||
Integer attachmentId = registrar.insert(transaction, row.entry().candidate(), row.entry().id());
|
||||
if(attachmentId == null || attachmentId < 1)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar did not return an attachment id."));
|
||||
}
|
||||
if(connection.isClosed() || connection.getAutoCommit())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar altered the journal's owned transaction."));
|
||||
}
|
||||
if(update(connection, SET_REGISTERED, attachmentId, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UploadEntry complete(UploadReservation reservation) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row finished = lock(connection, reservation.journalId());
|
||||
if(finished.entry().state() == UploadJournalState.COMPLETE && finished.entry().fence().equals(reservation.fence()))
|
||||
{
|
||||
return (finished.entry());
|
||||
}
|
||||
Row row = requireHolder(connection, reservation);
|
||||
if(row.entry().state() != UploadJournalState.REGISTERED || row.entry().attachmentId() == null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Only a registered upload can be completed."));
|
||||
}
|
||||
if(update(connection, SET_COMPLETE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (read(connection, reservation.journalId()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean release(UploadReservation reservation) throws QException
|
||||
{
|
||||
Objects.requireNonNull(reservation, "reservation");
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
Row row = lock(connection, reservation.journalId());
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (update(connection, CLEAR_LEASE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) == 1);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<UploadEntry> find(Integer journalId) throws QException
|
||||
{
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<Row> rows = select(connection, BY_ID, journalId);
|
||||
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException
|
||||
{
|
||||
String hash = UploadCandidate.locatorHash(bucketName, storageKey);
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<Row> rows = select(connection, BY_LOCATOR, hash);
|
||||
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** {@inheritDoc}
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<UploadEntry> listIncomplete(int limit) throws QException
|
||||
{
|
||||
if(limit < 1 || limit > MAXIMUM_LIST)
|
||||
{
|
||||
throw (new IllegalArgumentException("The limit must be between 1 and " + MAXIMUM_LIST + "."));
|
||||
}
|
||||
return (inTransaction((transaction, connection) ->
|
||||
{
|
||||
List<UploadEntry> entries = new ArrayList<>();
|
||||
for(Row row : select(connection, INCOMPLETE, limit))
|
||||
{
|
||||
entries.add(row.entry());
|
||||
}
|
||||
return (List.copyOf(entries));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replay an identical manifest or insert the candidate as PREPARED.
|
||||
*******************************************************************************/
|
||||
private UploadEntry insertOrReplay(Connection connection, UploadCandidate candidate) throws SQLException, QException
|
||||
{
|
||||
UploadEntry existing = existing(connection, candidate);
|
||||
if(existing != null)
|
||||
{
|
||||
return (existing);
|
||||
}
|
||||
Timestamp now = Timestamp.from(now());
|
||||
update(connection, INSERT, now, now, candidate.tableName(), candidate.recordId(), candidate.actorId(), candidate.fileName(),
|
||||
candidate.contentType(), candidate.description(), candidate.sizeBytes(), candidate.sha256(), candidate.bucketName(),
|
||||
candidate.storageKey(), candidate.retained(), candidate.intakeKey(), candidate.intakeFingerprint(), candidate.locatorHash(),
|
||||
candidate.manifestHash());
|
||||
List<Row> rows = select(connection, BY_LOCATOR, candidate.locatorHash());
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The prepared candidate could not be read back."));
|
||||
}
|
||||
return (rows.getFirst().entry());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The row already holding this location, if its manifest is identical. Any other
|
||||
** row sharing the location or intake key is an immutable conflict. Null if none.
|
||||
*******************************************************************************/
|
||||
private UploadEntry existing(Connection connection, UploadCandidate candidate) throws SQLException, QException
|
||||
{
|
||||
List<Row> byLocator = select(connection, BY_LOCATOR, candidate.locatorHash());
|
||||
if(!byLocator.isEmpty())
|
||||
{
|
||||
if(!candidate.manifestHash().equals(byLocator.getFirst().manifestHash()))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This object location is already journaled with a different manifest."));
|
||||
}
|
||||
return (byLocator.getFirst().entry());
|
||||
}
|
||||
if(candidate.intakeKey() != null && !select(connection, BY_INTAKE, candidate.intakeKey()).isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This intake key is already journaled for a different object."));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock the row under the bounded database wait, or NOT_FOUND.
|
||||
*******************************************************************************/
|
||||
private Row lock(Connection connection, Integer journalId) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = journalId == null ? List.of() : select(connection, BY_ID_LOCKED, journalId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock the row and require the reservation to still own a live lease on it.
|
||||
*******************************************************************************/
|
||||
private Row requireHolder(Connection connection, UploadReservation reservation) throws SQLException, QException
|
||||
{
|
||||
Row row = lock(connection, reservation.journalId());
|
||||
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
|
||||
{
|
||||
throw (stale());
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Token (compared in constant time), fence and unexpired lease all match.
|
||||
*******************************************************************************/
|
||||
private boolean holds(Row row, UploadReservation reservation)
|
||||
{
|
||||
return (row.leaseToken() != null && reservation.leaseToken() != null
|
||||
&& MessageDigest.isEqual(row.leaseToken().getBytes(StandardCharsets.UTF_8), reservation.leaseToken().getBytes(StandardCharsets.UTF_8))
|
||||
&& row.entry().fence().equals(reservation.fence()) && row.entry().isLeased(now()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The standard refusal for a holder that no longer owns the row.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException stale()
|
||||
{
|
||||
return (new UploadJournalException(Reason.STALE_RESERVATION, "The reservation no longer owns this upload; reserve it again."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read one row that the transaction just changed.
|
||||
*******************************************************************************/
|
||||
private UploadEntry read(Connection connection, Integer journalId) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = select(connection, BY_ID, journalId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
|
||||
}
|
||||
return (rows.getFirst().entry());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded query with one bound parameter, verifying each row's integrity.
|
||||
*******************************************************************************/
|
||||
private List<Row> select(Connection connection, String sql, Object parameter) throws SQLException, QException
|
||||
{
|
||||
List<Row> rows = new ArrayList<>();
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
statement.setObject(1, parameter);
|
||||
try(ResultSet result = statement.executeQuery())
|
||||
{
|
||||
while(result.next())
|
||||
{
|
||||
rows.add(row(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild and verify a row: the preserved manifest must still hash to the
|
||||
** stored manifest and locator, or the row was altered outside this journal.
|
||||
*******************************************************************************/
|
||||
private Row row(ResultSet result) throws SQLException, QException
|
||||
{
|
||||
UploadCandidate candidate;
|
||||
try
|
||||
{
|
||||
candidate = new UploadCandidate(result.getString("table_name"), result.getString("record_id"), result.getString("created_by_user_id"),
|
||||
result.getString("file_name"), result.getString("content_type"), result.getString("description"), result.getLong("size_bytes"),
|
||||
result.getString("sha256"), result.getString("bucket_name"), result.getString("storage_key"), result.getBoolean("retained"),
|
||||
result.getString("intake_key"), result.getString("intake_fingerprint"));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row no longer holds a valid manifest."));
|
||||
}
|
||||
String manifestHash = result.getString("manifest_hash");
|
||||
if(!candidate.manifestHash().equals(manifestHash) || !candidate.locatorHash().equals(result.getString("locator_hash")))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row's manifest no longer matches its recorded hashes."));
|
||||
}
|
||||
int attachment = result.getInt("attachment_id");
|
||||
Integer attachmentId = result.wasNull() ? null : attachment;
|
||||
Timestamp leaseUntil = "MySQL".equals(result.getStatement().getConnection().getMetaData().getDatabaseProductName())
|
||||
? result.getTimestamp("lease_until", Calendar.getInstance(TimeZone.getTimeZone("UTC"))) : result.getTimestamp("lease_until");
|
||||
UploadEntry entry;
|
||||
try
|
||||
{
|
||||
entry = new UploadEntry(result.getInt("id"), candidate, UploadJournalState.valueOf(result.getString("state")),
|
||||
UploadStorageState.valueOf(result.getString("storage_state")), attachmentId, result.getLong("fence"),
|
||||
leaseUntil == null ? null : leaseUntil.toInstant());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "A journal row holds an unknown state."));
|
||||
}
|
||||
return (new Row(entry, result.getString("lease_token"), manifestHash));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute one bound update and return its row count.
|
||||
*******************************************************************************/
|
||||
private int update(Connection connection, String sql, Object... parameters) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
for(int i = 0; i < parameters.length; i++)
|
||||
{
|
||||
if(parameters[i] instanceof Timestamp timestamp && "MySQL".equals(connection.getMetaData().getDatabaseProductName()))
|
||||
{
|
||||
statement.setTimestamp(i + 1, timestamp, Calendar.getInstance(TimeZone.getTimeZone("UTC")));
|
||||
}
|
||||
else
|
||||
{
|
||||
statement.setObject(i + 1, parameters[i]);
|
||||
}
|
||||
}
|
||||
return (statement.executeUpdate());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current time at the millisecond precision the database stores.
|
||||
*******************************************************************************/
|
||||
private Instant now()
|
||||
{
|
||||
return (clock.instant().truncatedTo(ChronoUnit.MILLIS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Own one real transaction: qualify it, run the work, then commit; roll back
|
||||
** every failure so there is never a partial success.
|
||||
*******************************************************************************/
|
||||
private <T> T inTransaction(Work<T> work) throws QException
|
||||
{
|
||||
if(QContext.getQBackendTransaction() != null)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.AMBIENT_TRANSACTION, "The upload journal owns its transactions and cannot join an ambient one."));
|
||||
}
|
||||
RDBMSBackendMetaData backend = requireQualifiedBackends();
|
||||
try(QBackendTransaction opened = new RDBMSBackendModule().openTransaction(new InsertInput(TABLE)))
|
||||
{
|
||||
if(!(opened instanceof RDBMSTransaction transaction) || !transaction.getClass().equals(RDBMSTransaction.class))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires the database's actual transaction."));
|
||||
}
|
||||
try
|
||||
{
|
||||
requireQualifiedConnection(transaction.getConnection(), backend);
|
||||
T result = work.run(transaction, transaction.getConnection());
|
||||
transaction.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
rollback(transaction, e);
|
||||
throw (map(e));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
rollback(transaction, failure);
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Roll back, keeping any rollback failure as suppressed on the real failure.
|
||||
*******************************************************************************/
|
||||
private static void rollback(RDBMSTransaction transaction, Throwable failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
transaction.rollback();
|
||||
}
|
||||
catch(QException | RuntimeException rollbackFailure)
|
||||
{
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify a database failure by SQLSTATE: unique violation, bounded-wait
|
||||
** lock timeout or deadlock, or anything else.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException map(SQLException e)
|
||||
{
|
||||
Throwable cursor = e;
|
||||
for(int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.getCause())
|
||||
{
|
||||
if(cursor instanceof SQLException sql)
|
||||
{
|
||||
if("23505".equals(sql.getSQLState()) || ("23000".equals(sql.getSQLState()) && sql.getErrorCode() == 1062))
|
||||
{
|
||||
return (new UploadJournalException(Reason.DUPLICATE, "The journal row already exists.", e));
|
||||
}
|
||||
if("50200".equals(sql.getSQLState()) || "40001".equals(sql.getSQLState()) || "HYT00".equals(sql.getSQLState())
|
||||
|| "55P03".equals(sql.getSQLState()) || "40P01".equals(sql.getSQLState())
|
||||
|| sql.getErrorCode() == 1205 || sql.getErrorCode() == 1213)
|
||||
{
|
||||
return (new UploadJournalException(Reason.BUSY, "The upload journal row stayed busy past the bounded wait.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new UploadJournalException(Reason.DATABASE, "The upload journal database operation failed.", e));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The journal and the attachment table must share one qualified backend, so the
|
||||
** metadata insert and the checkpoint can be one real transaction.
|
||||
*******************************************************************************/
|
||||
private static RDBMSBackendMetaData requireQualifiedBackends() throws QException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
QBackendMetaData journal = instance == null ? null : instance.getBackendForTable(TABLE);
|
||||
QBackendMetaData attachments = instance == null ? null : instance.getBackendForTable(CollaborationTables.ATTACHMENT);
|
||||
if(!isQualifiedBackend(journal) || attachments == null || !journal.getName().equals(attachments.getName()))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND,
|
||||
"The upload journal requires qualified H2, MySQL 8.4 or PostgreSQL 17 shared by the journal and attachment tables."));
|
||||
}
|
||||
return ((RDBMSBackendMetaData) journal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Qualify the actual connection, not merely its class: open, writable,
|
||||
** transactional, the configured file database, and an actual SETTINGS query
|
||||
** reporting WRITE_DELAY=0 so a commit is on disk before it returns.
|
||||
*******************************************************************************/
|
||||
private static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
if(PostgresJournalDatabase.is(backend))
|
||||
{
|
||||
PostgresJournalDatabase.requireQualifiedConnection(connection, backend);
|
||||
return;
|
||||
}
|
||||
if("mysql".equals(backend.getVendor()))
|
||||
{
|
||||
MySqlJournalDatabase.requireQualifiedConnection(connection, backend);
|
||||
return;
|
||||
}
|
||||
String expected = backend.getJdbcUrl().split(";")[0];
|
||||
if(connection.isClosed() || connection.isReadOnly() || connection.getAutoCommit()
|
||||
|| !expected.equals(String.valueOf(connection.getMetaData().getURL()).split(";")[0]))
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal needs a writable, non-autocommit connection to its configured database."));
|
||||
}
|
||||
boolean writeDelayIsZero = false;
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
|
||||
try(ResultSet settings = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.SETTINGS"))
|
||||
{
|
||||
while(settings.next())
|
||||
{
|
||||
if("WRITE_DELAY".equalsIgnoreCase(settings.getString(1)))
|
||||
{
|
||||
writeDelayIsZero = "0".equals(settings.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!writeDelayIsZero)
|
||||
{
|
||||
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires H2 WRITE_DELAY=0 as reported by the database's SETTINGS."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow qualification of this owner's fixed MySQL journal and attachment pair.
|
||||
** No schema migration or server-global setting is performed at runtime.
|
||||
*******************************************************************************/
|
||||
final class MySqlJournalDatabase
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private MySqlJournalDatabase()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the real connection, current durability settings and physical tables.
|
||||
** A host-configured session lock timeout bounds contention without mutation.
|
||||
*******************************************************************************/
|
||||
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
|
||||
String actual = baseUrl(connection.getMetaData().getURL());
|
||||
String catalog;
|
||||
try
|
||||
{
|
||||
URI location = URI.create(expected.substring("jdbc:".length()));
|
||||
catalog = location.getPath().substring(1);
|
||||
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException | IndexOutOfBoundsException e)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
if(!expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|
||||
|| connection.getAutoCommit() || !"MySQL".equals(connection.getMetaData().getDatabaseProductName())
|
||||
|| !connection.getMetaData().getDatabaseProductVersion().startsWith("8.4."))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
try(ResultSet settings = statement.executeQuery("SELECT @@GLOBAL.innodb_flush_log_at_trx_commit, @@GLOBAL.log_bin, @@GLOBAL.sync_binlog, @@GLOBAL.read_only, @@SESSION.time_zone, @@GLOBAL.system_time_zone, @@SESSION.innodb_lock_wait_timeout, @@GLOBAL.innodb_doublewrite, @@SESSION.transaction_read_only"))
|
||||
{
|
||||
if(!settings.next() || !List.of("ON", "DETECT_AND_RECOVER").contains(settings.getString(8)) || settings.getBoolean(9)
|
||||
|| settings.getInt(7) < 1 || settings.getInt(7) > 5 || settings.getInt(1) != 1 || (settings.getBoolean(2) && settings.getInt(3) != 1) || settings.getBoolean(4)
|
||||
|| !(List.of("+00:00", "UTC").contains(settings.getString(5))
|
||||
|| ("SYSTEM".equals(settings.getString(5)) && "UTC".equals(settings.getString(6)))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String tableName : List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT))
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|
||||
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, tableName);
|
||||
try(ResultSet tableInfo = statement.executeQuery())
|
||||
{
|
||||
if(!tableInfo.next() || !tableName.equals(tableInfo.getString(1)) || !"InnoDB".equals(tableInfo.getString(2))
|
||||
|| (CollaborationTables.UPLOAD_JOURNAL.equals(tableName) && !"utf8mb4_0900_bin".equals(tableInfo.getString(3))) || tableInfo.next())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLLATION_NAME IS NOT NULL"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet columns = statement.executeQuery())
|
||||
{
|
||||
int count = 0;
|
||||
while(columns.next())
|
||||
{
|
||||
count++;
|
||||
if(!"utf8mb4_0900_bin".equals(columns.getString(1)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
if(count < 1)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement("SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND NON_UNIQUE = 0 ORDER BY INDEX_NAME, SEQ_IN_INDEX"))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, catalog);
|
||||
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet indexes = statement.executeQuery())
|
||||
{
|
||||
java.util.Map<String, List<String>> columns = new java.util.HashMap<>();
|
||||
while(indexes.next())
|
||||
{
|
||||
columns.computeIfAbsent(indexes.getString(1), key -> new java.util.ArrayList<>()).add(indexes.getString(2));
|
||||
}
|
||||
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare the configured target without logging credentials or driver options.
|
||||
*******************************************************************************/
|
||||
private static String baseUrl(String url) throws UploadJournalException
|
||||
{
|
||||
if(url == null || !url.startsWith("jdbc:mysql://"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
return (url.split("\\?", 2)[0]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately omit connection URLs and settings from externally visible errors.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException unsupported()
|
||||
{
|
||||
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
|
||||
"Retained upload journaling requires its configured MySQL 8.4 catalog, InnoDB owner tables, binary journal identifiers, a UTC session, a 1-5 second lock timeout and synchronous durable commits."));
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow qualification of this owner's fixed PostgreSQL 17 journal and attachment
|
||||
** pair, on the native QQQ postgres backend (vendor and backend type "postgres").
|
||||
** Observation only: no schema migration and no session or server setting is
|
||||
** changed at runtime.
|
||||
*******************************************************************************/
|
||||
final class PostgresJournalDatabase
|
||||
{
|
||||
static final String VENDOR = "postgres";
|
||||
|
||||
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
|
||||
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
|
||||
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_database()";
|
||||
|
||||
private static final String TABLES = "SELECT c.relname, c.relkind, c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
|
||||
+ " WHERE n.nspname=current_schema() AND c.relname = ANY(?)";
|
||||
|
||||
private static final String COLLATIONS = "SELECT c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace"
|
||||
+ " JOIN pg_collation co ON co.oid=a.attcollation WHERE n.nspname=current_schema() AND c.relname = ANY(?) AND a.attnum>0 AND NOT a.attisdropped AND NOT co.collisdeterministic";
|
||||
|
||||
private static final String UNIQUES = "SELECT i.relname, a.attname FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid"
|
||||
+ " JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace"
|
||||
+ " CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)"
|
||||
+ " JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=k.attnum"
|
||||
+ " WHERE n.nspname=current_schema() AND t.relname = ? AND ix.indisunique AND ix.indisvalid AND ix.indpred IS NULL AND ix.indexprs IS NULL AND k.ord<=ix.indnkeyatts"
|
||||
+ " ORDER BY i.relname, k.ord";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private PostgresJournalDatabase()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The native postgres module identity, not merely a vendor string: a "postgres"
|
||||
** vendor label on the generic rdbms backend type is refused.
|
||||
*******************************************************************************/
|
||||
static boolean is(RDBMSBackendMetaData backend)
|
||||
{
|
||||
return (VENDOR.equals(backend.getVendor()) && VENDOR.equals(backend.getBackendType()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the real connection, current durability settings and physical tables.
|
||||
*******************************************************************************/
|
||||
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
|
||||
{
|
||||
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
|
||||
String actual = baseUrl(connection.getMetaData().getURL());
|
||||
String catalog;
|
||||
try
|
||||
{
|
||||
URI location = URI.create(expected.substring("jdbc:".length()));
|
||||
catalog = location.getPath() == null || !location.getPath().startsWith("/") ? "" : location.getPath().substring(1);
|
||||
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException | IndexOutOfBoundsException e)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
if(!is(backend) || !expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|
||||
|| connection.getAutoCommit() || !"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName())
|
||||
|| connection.getMetaData().getDatabaseMajorVersion() != 17)
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
try(ResultSet settings = statement.executeQuery(SETTINGS))
|
||||
{
|
||||
if(!settings.next() || settings.getInt(1) / 10000 != 17 || !"off".equals(settings.getString(2)) || settings.getBoolean(3)
|
||||
|| !Set.of("on", "remote_apply").contains(settings.getString(4)) || !"on".equals(settings.getString(5)) || !"on".equals(settings.getString(6))
|
||||
|| !Set.of("UTC", "Etc/UTC").contains(settings.getString(7)) || settings.getLong(8) < 1 || settings.getLong(8) > 5000
|
||||
|| !"read committed".equals(settings.getString(9)) || !catalog.equals(settings.getString(10)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<String> names = List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT);
|
||||
for(String tableName : names)
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|
||||
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
requireTables(connection, names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permanent (logged) ordinary tables only, deterministic text comparison, and
|
||||
** complete valid non-partial unique indexes for the journal's two identities.
|
||||
*******************************************************************************/
|
||||
private static void requireTables(Connection connection, List<String> names) throws SQLException, QException
|
||||
{
|
||||
java.sql.Array array = connection.createArrayOf("text", names.toArray());
|
||||
try
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(TABLES))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setArray(1, array);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
Set<String> observed = new java.util.HashSet<>();
|
||||
while(rows.next())
|
||||
{
|
||||
if(!names.contains(rows.getString(1)) || !observed.add(rows.getString(1)) || !"r".equals(rows.getString(2)) || !"p".equals(rows.getString(3)))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
if(observed.size() != names.size())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement(COLLATIONS))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setArray(1, array);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
if(rows.next())
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
array.free();
|
||||
}
|
||||
try(PreparedStatement statement = connection.prepareStatement(UNIQUES))
|
||||
{
|
||||
statement.setQueryTimeout(10);
|
||||
statement.setString(1, CollaborationTables.UPLOAD_JOURNAL);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
Map<String, List<String>> columns = new HashMap<>();
|
||||
while(rows.next())
|
||||
{
|
||||
columns.computeIfAbsent(rows.getString(1), key -> new ArrayList<>()).add(rows.getString(2));
|
||||
}
|
||||
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare the configured target without logging credentials or driver options.
|
||||
*******************************************************************************/
|
||||
private static String baseUrl(String url) throws UploadJournalException
|
||||
{
|
||||
if(url == null || !url.startsWith("jdbc:postgresql://"))
|
||||
{
|
||||
throw (unsupported());
|
||||
}
|
||||
return (url.split("\\?", 2)[0]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliberately omit connection URLs and settings from externally visible errors.
|
||||
*******************************************************************************/
|
||||
private static UploadJournalException unsupported()
|
||||
{
|
||||
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
|
||||
"Retained upload journaling requires its configured PostgreSQL 17 database, permanent owner tables, deterministic comparison, READ COMMITTED isolation, a UTC session, a bounded lock timeout and synchronous durable commits."));
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable, bounded manifest of a retained upload, assembled by the server
|
||||
** (AttachmentsService) from the authorized source, the actual session actor,
|
||||
** the measured size/hash and the chosen storage coordinates. Nothing here is a
|
||||
** client-supplied path or journal field. All members are immutable values.
|
||||
**
|
||||
** Only retained uploads are journaled; a non-retained candidate is rejected.
|
||||
** The bucket and key are needed for recovery but are deliberately absent from
|
||||
** toString and from the QQQ table metadata; the journal exposes only their hash.
|
||||
*******************************************************************************/
|
||||
public record UploadCandidate(String tableName, String recordId, String actorId, String fileName, String contentType, String description,
|
||||
Long sizeBytes, String sha256, String bucketName, String storageKey, Boolean retained,
|
||||
String intakeKey, String intakeFingerprint)
|
||||
{
|
||||
public static final long MAX_SIZE_BYTES = 1L << 40;
|
||||
private static final String HEX_64 = "[0-9a-f]{64}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate every member. Failures are programming errors in the trusted
|
||||
** caller, so they are unchecked and never echo the offending value.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate
|
||||
{
|
||||
tableName = text("tableName", tableName, 128, true, false);
|
||||
recordId = text("recordId", recordId, 64, true, false);
|
||||
actorId = text("actorId", actorId, 255, true, false);
|
||||
fileName = text("fileName", fileName, 255, true, false);
|
||||
contentType = text("contentType", contentType, 255, true, false);
|
||||
description = text("description", description, 4096, false, true);
|
||||
bucketName = text("bucketName", bucketName, 255, true, false);
|
||||
storageKey = text("storageKey", storageKey, 1024, true, false);
|
||||
if(sizeBytes == null || sizeBytes < 0 || sizeBytes > MAX_SIZE_BYTES)
|
||||
{
|
||||
throw (new IllegalArgumentException("sizeBytes is out of range."));
|
||||
}
|
||||
if(sha256 == null || !sha256.matches(HEX_64))
|
||||
{
|
||||
throw (new IllegalArgumentException("sha256 must be a lowercase SHA-256 hex digest."));
|
||||
}
|
||||
if(!Boolean.TRUE.equals(retained))
|
||||
{
|
||||
throw (new IllegalArgumentException("Only retained uploads are journaled."));
|
||||
}
|
||||
if((intakeKey == null) != (intakeFingerprint == null)
|
||||
|| (intakeKey != null && (!intakeKey.matches(HEX_64) || !intakeFingerprint.matches(HEX_64))))
|
||||
{
|
||||
throw (new IllegalArgumentException("intakeKey and intakeFingerprint must be supplied together as SHA-256 identifiers."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of the length-prefixed bucket and key; the journal's unique identity
|
||||
** for one object location.
|
||||
*******************************************************************************/
|
||||
public String locatorHash()
|
||||
{
|
||||
return (locatorHash(bucketName, storageKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 of the length-prefixed bucket and key.
|
||||
*******************************************************************************/
|
||||
public static String locatorHash(String bucketName, String storageKey)
|
||||
{
|
||||
return (tupleHash("upload-journal-locator-v1", bucketName, storageKey));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 over the complete manifest. A row that shares a locator or intake
|
||||
** key but not this hash is an immutable conflict.
|
||||
*******************************************************************************/
|
||||
public String manifestHash()
|
||||
{
|
||||
return (tupleHash("upload-journal-manifest-v1", tableName, recordId, actorId, fileName, contentType, description,
|
||||
String.valueOf(sizeBytes), sha256, bucketName, storageKey, String.valueOf(retained), intakeKey, intakeFingerprint));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the bucket, key, description or actor.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadCandidate[source=" + tableName + "/" + recordId + ", sizeBytes=" + sizeBytes + ", locatorHash=" + locatorHash() + "]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded text without control characters (multi-line allows CR, LF, tab).
|
||||
*******************************************************************************/
|
||||
private static String text(String name, String value, int maximumLength, boolean required, boolean multiLine)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
if(required)
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " is required."));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
if((required && value.isBlank()) || value.length() > maximumLength)
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " is blank or exceeds " + maximumLength + " characters."));
|
||||
}
|
||||
for(int i = 0; i < value.length(); i++)
|
||||
{
|
||||
char c = value.charAt(i);
|
||||
if(Character.isHighSurrogate(c))
|
||||
{
|
||||
if(i + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(++i)))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(Character.isLowSurrogate(c))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
|
||||
}
|
||||
if(Character.isISOControl(c) && !(multiLine && (c == '\n' || c == '\r' || c == '\t')))
|
||||
{
|
||||
throw (new IllegalArgumentException(name + " contains a control character."));
|
||||
}
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Length-prefix every member, including null, so delimiters cannot collide.
|
||||
*******************************************************************************/
|
||||
private static String tupleHash(String... values)
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try(DataOutputStream output = new DataOutputStream(buffer))
|
||||
{
|
||||
for(String value : values)
|
||||
{
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
output.writeInt(value == null ? -1 : bytes.length);
|
||||
output.write(bytes);
|
||||
}
|
||||
}
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not hash the upload manifest.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable snapshot of one journal row at read time. It never carries the
|
||||
** lease token, so it cannot be used to mutate the journal.
|
||||
*******************************************************************************/
|
||||
public final class UploadEntry
|
||||
{
|
||||
private final Integer id;
|
||||
private final UploadCandidate candidate;
|
||||
private final UploadJournalState state;
|
||||
private final UploadStorageState storageState;
|
||||
private final Integer attachmentId;
|
||||
private final Long fence;
|
||||
private final Instant leaseUntil;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local; only the journal creates snapshots.
|
||||
*******************************************************************************/
|
||||
UploadEntry(Integer id, UploadCandidate candidate, UploadJournalState state, UploadStorageState storageState, Integer attachmentId, Long fence, Instant leaseUntil)
|
||||
{
|
||||
this.id = id;
|
||||
this.candidate = candidate;
|
||||
this.state = state;
|
||||
this.storageState = storageState;
|
||||
this.attachmentId = attachmentId;
|
||||
this.fence = fence;
|
||||
this.leaseUntil = leaseUntil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal row id.
|
||||
*******************************************************************************/
|
||||
public Integer id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The preserved manifest, including the original actor and source.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate candidate()
|
||||
{
|
||||
return (candidate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration progress.
|
||||
*******************************************************************************/
|
||||
public UploadJournalState state()
|
||||
{
|
||||
return (state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What is known about the original object.
|
||||
*******************************************************************************/
|
||||
public UploadStorageState storageState()
|
||||
{
|
||||
return (storageState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registered attachment id, or null before the registering commit.
|
||||
*******************************************************************************/
|
||||
public Integer attachmentId()
|
||||
{
|
||||
return (attachmentId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Number of reservations ever granted; the fencing counter.
|
||||
*******************************************************************************/
|
||||
public Long fence()
|
||||
{
|
||||
return (fence);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** When the current lease lapses, or null when no lease is held.
|
||||
*******************************************************************************/
|
||||
public Instant leaseUntil()
|
||||
{
|
||||
return (leaseUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True while a lease is held as of the given instant.
|
||||
*******************************************************************************/
|
||||
public boolean isLeased(Instant now)
|
||||
{
|
||||
return (leaseUntil != null && leaseUntil.isAfter(now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy reflecting a newly granted lease.
|
||||
*******************************************************************************/
|
||||
UploadEntry leased(Long newFence, Instant newLeaseUntil)
|
||||
{
|
||||
return (new UploadEntry(id, candidate, state, storageState, attachmentId, newFence, newLeaseUntil));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the manifest's bucket or key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadEntry[id=" + id + ", state=" + state + ", storage=" + storageState + ", attachmentId=" + attachmentId + ", fence=" + fence + ", " + candidate + "]");
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable journal of retained upload candidates. The intended order, owned by
|
||||
** AttachmentsService (this interface never reads, writes or deletes bytes):
|
||||
**
|
||||
** 1. prepare(candidate) independently committed, before the first PUT
|
||||
** 2. reserve(id, lease) exclusive fenced lease (original or recovery)
|
||||
** 3. recordStorage(UNKNOWN) before the PUT; VERIFIED once bytes are proven
|
||||
** 4. register(reservation, cb) attachment insert + checkpoint, one transaction
|
||||
** 5. (caller) lifecycle.afterStored, after the commit, idempotent and retried
|
||||
** 6. complete(reservation) only after step 5 returned
|
||||
**
|
||||
** A crash, timeout or failure at any step leaves the row visible; recovery
|
||||
** reserves it again once the lease lapses, after the caller reauthorizes the
|
||||
** original actor and the current source. Nothing here deletes an original.
|
||||
**
|
||||
** AttachmentsService owns authorization and lifecycle policy around this port.
|
||||
*******************************************************************************/
|
||||
public interface UploadJournal
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Inserts the registering callback's attachment row. Runs inside the journal's
|
||||
** owned transaction, which the callback must use for every write and must not
|
||||
** commit, roll back or close. Returns the new attachment id. Throwing rolls
|
||||
** back the insert and the checkpoint together.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface AttachmentRegistrar
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Insert the attachment metadata row for the journaled candidate.
|
||||
*******************************************************************************/
|
||||
Integer insert(QBackendTransaction transaction, UploadCandidate candidate, Integer journalId) throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durably record the candidate in its own committed transaction. Idempotent
|
||||
** for an identical manifest; an immutable conflict for any different one.
|
||||
*******************************************************************************/
|
||||
UploadEntry prepare(UploadCandidate candidate) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Take the exclusive lease, failing with LEASE_HELD while another holder's
|
||||
** lease is live. Each grant increments the fence. Lease bounded 1s to 30m.
|
||||
*******************************************************************************/
|
||||
UploadReservation reserve(Integer journalId, Duration lease) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Extend only a still-live exact reservation. Expired holders cannot renew.
|
||||
*******************************************************************************/
|
||||
UploadReservation renew(UploadReservation reservation, Duration lease) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Report original-object knowledge. Forward only: PENDING to UNKNOWN to
|
||||
** VERIFIED. Requires the live reservation.
|
||||
*******************************************************************************/
|
||||
UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Requires VERIFIED storage and the live reservation. Inserts the attachment
|
||||
** through the callback and checkpoints its id and REGISTERED in one same-database
|
||||
** transaction. If already REGISTERED the callback is not called again and the
|
||||
** existing entry is returned, so the attachment is never registered twice.
|
||||
*******************************************************************************/
|
||||
UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mark COMPLETE after the post-commit lifecycle registration returned. Requires
|
||||
** REGISTERED and the live reservation; repeating it for the same fence is a no-op.
|
||||
*******************************************************************************/
|
||||
UploadEntry complete(UploadReservation reservation) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Give up the lease early so recovery need not wait. Changes no other data.
|
||||
** Returns false, harmlessly, if the reservation was no longer the holder.
|
||||
*******************************************************************************/
|
||||
boolean release(UploadReservation reservation) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current snapshot of one row.
|
||||
*******************************************************************************/
|
||||
Optional<UploadEntry> find(Integer journalId) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot found by the object location, hashed internally.
|
||||
*******************************************************************************/
|
||||
Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Oldest-first rows that are not COMPLETE, for recovery sweeps. Limit 1 to 500.
|
||||
*******************************************************************************/
|
||||
List<UploadEntry> listIncomplete(int limit) throws QException;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal failure with a machine-readable reason. Messages never contain the
|
||||
** bucket, storage key or lease token.
|
||||
*******************************************************************************/
|
||||
public class UploadJournalException extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Why the journal refused or failed an operation.
|
||||
*******************************************************************************/
|
||||
public enum Reason
|
||||
{
|
||||
/***************************************************************************
|
||||
** Another transaction held the row past the bounded wait.
|
||||
***************************************************************************/
|
||||
BUSY,
|
||||
/***************************************************************************
|
||||
** A live lease belongs to another holder.
|
||||
***************************************************************************/
|
||||
LEASE_HELD,
|
||||
/***************************************************************************
|
||||
** The reservation's token, fence or lease no longer owns the row.
|
||||
***************************************************************************/
|
||||
STALE_RESERVATION,
|
||||
/***************************************************************************
|
||||
** Same locator or intake key, different immutable manifest (or tampered row).
|
||||
***************************************************************************/
|
||||
IMMUTABLE_CONFLICT,
|
||||
/***************************************************************************
|
||||
** The operation does not apply to the row's current state.
|
||||
***************************************************************************/
|
||||
ILLEGAL_STATE,
|
||||
/***************************************************************************
|
||||
** Registration requires VERIFIED original storage.
|
||||
***************************************************************************/
|
||||
STORAGE_NOT_VERIFIED,
|
||||
/***************************************************************************
|
||||
** No such journal row.
|
||||
***************************************************************************/
|
||||
NOT_FOUND,
|
||||
/***************************************************************************
|
||||
** Backend is not the qualified file-backed H2 configuration.
|
||||
***************************************************************************/
|
||||
UNSUPPORTED_BACKEND,
|
||||
/***************************************************************************
|
||||
** The journal owns its transactions and refuses an ambient one.
|
||||
***************************************************************************/
|
||||
AMBIENT_TRANSACTION,
|
||||
/***************************************************************************
|
||||
** Unique constraint hit; handled internally, not normally surfaced.
|
||||
***************************************************************************/
|
||||
DUPLICATE,
|
||||
/***************************************************************************
|
||||
** Other database failure.
|
||||
***************************************************************************/
|
||||
DATABASE
|
||||
}
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public UploadJournalException(Reason reason, String message)
|
||||
{
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with cause.
|
||||
*******************************************************************************/
|
||||
public UploadJournalException(Reason reason, String message, Throwable cause)
|
||||
{
|
||||
super(message, cause);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The refusal reason.
|
||||
*******************************************************************************/
|
||||
public Reason getReason()
|
||||
{
|
||||
return (reason);
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
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.get.GetInput;
|
||||
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.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Private manifest fields cannot become filter or sorting oracles. This
|
||||
** table-local restriction composes with the host's existing field access policy.
|
||||
*******************************************************************************/
|
||||
public final class UploadJournalReadPolicy
|
||||
{
|
||||
private static final Set<String> PRIVATE = Set.of("bucketName", "storageKey", "manifestHash", "leaseToken");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility namespace.
|
||||
*******************************************************************************/
|
||||
private UploadJournalReadPolicy()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Native read criteria are checked before SQL, including unique-key Get.
|
||||
** Joins are not an journal browsing contract and are rejected conservatively.
|
||||
*******************************************************************************/
|
||||
public static void check(AbstractActionInput input, QTableMetaData table) throws QPermissionDeniedException
|
||||
{
|
||||
if(!UploadJournalTable.NAME.equals(table.getName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(input instanceof QueryInput query)
|
||||
{
|
||||
if(query.getQueryJoins() != null && !query.getQueryJoins().isEmpty())
|
||||
{
|
||||
deny();
|
||||
}
|
||||
filter(query.getFilter(), table, 0, new int[]{0});
|
||||
if(query.getFieldNamesToInclude() != null)
|
||||
{
|
||||
for(String name : query.getFieldNamesToInclude())
|
||||
{
|
||||
field(name, table);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(input instanceof GetInput get)
|
||||
{
|
||||
if(get.getQueryJoins() != null && !get.getQueryJoins().isEmpty())
|
||||
{
|
||||
deny();
|
||||
}
|
||||
if(get.getUniqueKey() != null)
|
||||
{
|
||||
for(String name : get.getUniqueKey().keySet())
|
||||
{
|
||||
field(name, table);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound traversal and accept only literal criteria on public direct fields.
|
||||
*******************************************************************************/
|
||||
private static void filter(QQueryFilter filter, QTableMetaData table, int depth, int[] visited) throws QPermissionDeniedException
|
||||
{
|
||||
if(filter == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(depth > 64 || ++visited[0] > 4096)
|
||||
{
|
||||
deny();
|
||||
}
|
||||
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
|
||||
{
|
||||
field(criterion.getFieldName(), table);
|
||||
if(criterion.getOtherFieldName() != null)
|
||||
{
|
||||
field(criterion.getOtherFieldName(), table);
|
||||
}
|
||||
if(criterion.getFieldFunction() != null)
|
||||
{
|
||||
deny();
|
||||
}
|
||||
for(Object value : criterion.getValues() == null ? List.of() : criterion.getValues())
|
||||
{
|
||||
if(value instanceof com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser
|
||||
&& "createdByUserId".equals(criterion.getFieldName())
|
||||
&& criterion.getOperator() == com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS
|
||||
&& criterion.getValues().size() == 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(value != null && !(value instanceof String) && !(value instanceof Number) && !(value instanceof Boolean)
|
||||
&& !(value instanceof java.time.temporal.TemporalAccessor) && !(value instanceof java.util.Date))
|
||||
{
|
||||
deny();
|
||||
}
|
||||
}
|
||||
}
|
||||
for(var order : filter.getOrderBys() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy>of() : filter.getOrderBys())
|
||||
{
|
||||
field(order.getFieldName(), table);
|
||||
}
|
||||
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
|
||||
{
|
||||
filter(child, table, depth + 1, visited);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A public direct field cannot refer to a private projection through an alias.
|
||||
*******************************************************************************/
|
||||
private static void field(String name, QTableMetaData table) throws QPermissionDeniedException
|
||||
{
|
||||
if(name == null || PRIVATE.contains(name) || table.getFields().get(name) == null || Boolean.TRUE.equals(table.getFields().get(name).getIsHidden()))
|
||||
{
|
||||
deny();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Uniform denial never echoes security values or a caller's probing predicate.
|
||||
*******************************************************************************/
|
||||
private static void deny() throws QPermissionDeniedException
|
||||
{
|
||||
throw (new QPermissionDeniedException("Upload journal queries support public direct fields only."));
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
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.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
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.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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner journal visibility follows its original actor and its exact attachment's
|
||||
** current parent after a transfer. Immutable manifest source values never change.
|
||||
*******************************************************************************/
|
||||
public class UploadJournalSecurity implements CustomPermissionChecker, TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No direct writes or counts bypass per-row dynamic parent authorization.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table)
|
||||
{
|
||||
UploadJournalReadPolicy.check(input, table);
|
||||
}
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || input instanceof CountInput || input instanceof AggregateInput)
|
||||
{
|
||||
throw (new QPermissionDeniedException("The upload journal is maintained by the attachment service; unscoped totals are unavailable."));
|
||||
}
|
||||
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read authoritative owner identity even if the client selected a projection.
|
||||
** Internal manifest columns are not QQQ fields and cannot be returned here.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord row : records)
|
||||
{
|
||||
if(row.getValueInteger("id") == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
List<QRecord> persisted = CollaborationRecords.cleanupQuery(UploadJournalTable.NAME,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, row.getValueInteger("id")).withLimit(1));
|
||||
if(persisted.size() != 1 || !PersonalRecords.currentUserId().equals(persisted.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord source = persisted.getFirst();
|
||||
if(source.getValueInteger("attachmentId") != null)
|
||||
{
|
||||
List<QRecord> attachment = CollaborationRecords.cleanupQuery(CollaborationTables.ATTACHMENT,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, source.getValueInteger("attachmentId")).withLimit(1));
|
||||
if(attachment.size() != 1 || !source.getValueString("sha256").equals(attachment.getFirst().getValueString("sha256"))
|
||||
|| !source.getValueLong("sizeBytes").equals(attachment.getFirst().getValueLong("sizeBytes"))
|
||||
|| !source.getValueString("createdByUserId").equals(attachment.getFirst().getValueString("createdByUserId")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
source = attachment.getFirst();
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(source.getValueString("tableName"), source.getValueString("recordId"));
|
||||
visible.add(row);
|
||||
}
|
||||
catch(QNotFoundException denied)
|
||||
{
|
||||
// Invisible and absent owners have identical native query behavior.
|
||||
}
|
||||
}
|
||||
return (visible);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration progress of a journaled retained upload. Only moves forward.
|
||||
** NOT YET WIRED: no producer, table registration or service uses this yet.
|
||||
*******************************************************************************/
|
||||
public enum UploadJournalState
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Candidate is durable; no attachment row has been committed for it.
|
||||
*******************************************************************************/
|
||||
PREPARED,
|
||||
|
||||
/*******************************************************************************
|
||||
** The attachment row and its checkpoint committed together; the post-commit
|
||||
** lifecycle registration has not yet been confirmed.
|
||||
*******************************************************************************/
|
||||
REGISTERED,
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle registration returned after the registering commit.
|
||||
*******************************************************************************/
|
||||
COMPLETE
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one fixed journal table: native QQQ metadata for visible, read-only
|
||||
** provenance and status, and explicit H2/MySQL DDL. The bucket, storage key,
|
||||
** manifest hash and lease token are physical columns only; they are NOT QQQ
|
||||
** fields, so no UI or API surface can return a raw object locator or lease.
|
||||
** Only the hash of the locator is exposed.
|
||||
**
|
||||
** The producer registers this table; hosts apply the appropriate owner DDL
|
||||
** through migration tooling before enabling retained upload intake.
|
||||
*******************************************************************************/
|
||||
public final class UploadJournalTable
|
||||
{
|
||||
public static final String NAME = CollaborationTables.UPLOAD_JOURNAL;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private UploadJournalTable()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table metadata with no editable field; direct writes are denied by
|
||||
** UploadJournalSecurity. Field names line up with the parent-reference
|
||||
** metadata used by attachments (tableName, recordId, createdByUserId).
|
||||
*******************************************************************************/
|
||||
public static QTableMetaData metaData(CollaborationQBitConfig config)
|
||||
{
|
||||
return (new QTableMetaData().withName(NAME).withLabel("Retained Upload Journal").withIcon(new QIcon("history_edu"))
|
||||
.withBackendName(config.getBackendName()).withPrimaryKeyField("id")
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules().withCustomPermissionChecker(new QCodeReference(UploadJournalSecurity.class)))
|
||||
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(UploadJournalSecurity.class))
|
||||
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_COUNT, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.QUERY_STATS)
|
||||
.withRecordLabelFormatAndFields("%s", "fileName")
|
||||
.withUniqueKey(new UniqueKey("locatorHash"))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withLabel("Created").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withLabel("Modified").withIsEditable(false))
|
||||
.withField(string("tableName", "Table", 128))
|
||||
.withField(string("recordId", "Record ID", 64))
|
||||
.withField(string("createdByUserId", "Original Actor", 255).withPossibleValueSourceName(CollaborationTables.USER))
|
||||
.withField(string("fileName", "File Name", 255))
|
||||
.withField(string("contentType", "Content Type", 255))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsEditable(false))
|
||||
.withField(string("sha256", "SHA-256", 64))
|
||||
.withField(new QFieldMetaData("retained", QFieldType.BOOLEAN).withLabel("Retained").withIsEditable(false))
|
||||
.withField(string("intakeKey", "Intake Key", 64))
|
||||
.withField(string("intakeFingerprint", "Intake Fingerprint", 64))
|
||||
.withField(string("locatorHash", "Object Location Hash", 64))
|
||||
.withField(string("state", "Registration State", 16))
|
||||
.withField(string("storageState", "Original Object", 16))
|
||||
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Attachment").withIsEditable(false)
|
||||
.withPossibleValueSourceName(CollaborationTables.ATTACHMENT))
|
||||
.withField(new QFieldMetaData("leaseUntil", QFieldType.DATE_TIME).withLabel("Lease Until").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("fence", QFieldType.LONG).withLabel("Reservation Count").withIsEditable(false))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "fileName", "state", "storageState", "attachmentId")).withLabel("Status"))
|
||||
.withSection(new QFieldSection("source", new QIcon("link"), Tier.T2, List.of("tableName", "recordId", "createdByUserId", "contentType", "sizeBytes", "sha256", "description")).withLabel("Source"))
|
||||
.withSection(new QFieldSection("recovery", new QIcon("storage"), Tier.T3, List.of("locatorHash", "intakeKey", "intakeFingerprint", "leaseUntil", "fence", "retained")).withLabel("Recovery")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** H2 DDL for the qualified adapter only. Unquoted lowercase identifiers match
|
||||
** QQQ's inferred snake_case backend names. The constraints are database
|
||||
** guards: retained rows only, forward-only states, intake pairing, and no
|
||||
** REGISTERED/COMPLETE row without its attachment id. PostgreSQL has its own
|
||||
** script, postgresScript().
|
||||
*******************************************************************************/
|
||||
public static String h2Script()
|
||||
{
|
||||
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
|
||||
+ " id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n"
|
||||
+ " create_date TIMESTAMP NOT NULL,\n"
|
||||
+ " modify_date TIMESTAMP NOT NULL,\n"
|
||||
+ " table_name VARCHAR(128) NOT NULL,\n"
|
||||
+ " record_id VARCHAR(64) NOT NULL,\n"
|
||||
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
|
||||
+ " file_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " content_type VARCHAR(255) NOT NULL,\n"
|
||||
+ " description VARCHAR(4096),\n"
|
||||
+ " size_bytes BIGINT NOT NULL,\n"
|
||||
+ " sha256 VARCHAR(64) NOT NULL,\n"
|
||||
+ " bucket_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " storage_key VARCHAR(1024) NOT NULL,\n"
|
||||
+ " retained BOOLEAN NOT NULL,\n"
|
||||
+ " intake_key VARCHAR(64),\n"
|
||||
+ " intake_fingerprint VARCHAR(64),\n"
|
||||
+ " locator_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " state VARCHAR(16) NOT NULL,\n"
|
||||
+ " storage_state VARCHAR(16) NOT NULL,\n"
|
||||
+ " attachment_id INTEGER,\n"
|
||||
+ " lease_token VARCHAR(64),\n"
|
||||
+ " lease_until TIMESTAMP,\n"
|
||||
+ " fence BIGINT NOT NULL,\n"
|
||||
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED')\n"
|
||||
+ ");\n"
|
||||
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_locator ON collaboration_upload_journal (locator_hash);\n"
|
||||
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_intake ON collaboration_upload_journal (intake_key);\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_open ON collaboration_upload_journal (state, id);\n"
|
||||
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_parent ON collaboration_upload_journal (table_name, record_id);\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PostgreSQL 17 schema for the native postgres backend. Same columns, CHECK
|
||||
** guards and unique/ordinary indexes as the H2 script; instants are
|
||||
** TIMESTAMPTZ so a lease fence round-trips independent of the JVM zone.
|
||||
** Default (deterministic) collation keeps identifiers exact. Statements are
|
||||
** idempotent, so the script is safe to re-run.
|
||||
*******************************************************************************/
|
||||
public static String postgresScript()
|
||||
{
|
||||
return (h2Script().replace("TIMESTAMP NOT NULL,", "TIMESTAMPTZ NOT NULL,").replace("lease_until TIMESTAMP,", "lease_until TIMESTAMPTZ,"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MySQL 8.4 schema. Binary NO PAD comparison preserves exact identifiers;
|
||||
** millisecond timestamps preserve lease fences across connection round trips.
|
||||
*******************************************************************************/
|
||||
public static String mysqlScript()
|
||||
{
|
||||
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
|
||||
+ " id INTEGER AUTO_INCREMENT PRIMARY KEY,\n"
|
||||
+ " create_date TIMESTAMP(3) NOT NULL,\n"
|
||||
+ " modify_date TIMESTAMP(3) NOT NULL,\n"
|
||||
+ " table_name VARCHAR(128) NOT NULL,\n"
|
||||
+ " record_id VARCHAR(64) NOT NULL,\n"
|
||||
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
|
||||
+ " file_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " content_type VARCHAR(255) NOT NULL,\n"
|
||||
+ " description VARCHAR(4096),\n"
|
||||
+ " size_bytes BIGINT NOT NULL,\n"
|
||||
+ " sha256 VARCHAR(64) NOT NULL,\n"
|
||||
+ " bucket_name VARCHAR(255) NOT NULL,\n"
|
||||
+ " storage_key VARCHAR(1024) NOT NULL,\n"
|
||||
+ " retained BOOLEAN NOT NULL,\n"
|
||||
+ " intake_key VARCHAR(64),\n"
|
||||
+ " intake_fingerprint VARCHAR(64),\n"
|
||||
+ " locator_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
|
||||
+ " state VARCHAR(16) NOT NULL,\n"
|
||||
+ " storage_state VARCHAR(16) NOT NULL,\n"
|
||||
+ " attachment_id INTEGER,\n"
|
||||
+ " lease_token VARCHAR(64),\n"
|
||||
+ " lease_until TIMESTAMP(3),\n"
|
||||
+ " fence BIGINT NOT NULL,\n"
|
||||
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
|
||||
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED'),\n"
|
||||
+ " UNIQUE KEY uq_collaboration_upload_journal_locator (locator_hash),\n"
|
||||
+ " UNIQUE KEY uq_collaboration_upload_journal_intake (intake_key),\n"
|
||||
+ " KEY idx_collaboration_upload_journal_open (state, id),\n"
|
||||
+ " KEY idx_collaboration_upload_journal_parent (table_name, record_id)\n"
|
||||
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin;\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded read-only string field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData string(String name, String label, Integer length)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withMaxLength(length).withIsEditable(false));
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
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.expressions.CurrentUser;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Small metadata contract for native recovery navigation from a source's Files
|
||||
** panel. The client adds canonical source table/id pins to this server-owned view.
|
||||
** Filters aid discovery only; the journal still authorizes every row and action.
|
||||
*******************************************************************************/
|
||||
public final class UploadRecoveryNavigation
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata utility.
|
||||
*******************************************************************************/
|
||||
private UploadRecoveryNavigation()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention owners opt in their managed source tables after producing them.
|
||||
** No client knows or guesses the journal table or recovery process name.
|
||||
*******************************************************************************/
|
||||
public static void configure(QTableMetaData source)
|
||||
{
|
||||
Map<String, Serializable> view = new LinkedHashMap<>();
|
||||
view.put("tableName", UploadJournalTable.NAME);
|
||||
view.put("label", "Review interrupted uploads");
|
||||
view.put("filter", new QQueryFilter().withCriteria("createdByUserId", QCriteriaOperator.EQUALS, new CurrentUser())
|
||||
.withCriteria("state", QCriteriaOperator.IN, "PREPARED", "REGISTERED"));
|
||||
RecordFeaturesMetaData.ofOrWithNew(source).withSetting(RecordFeature.ATTACHMENTS, "recoveryView", (Serializable) view);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable write reservation: an exclusive, fenced lease on one journal row.
|
||||
** Every mutating journal call re-checks the token, fence and expiry under a row
|
||||
** lock, so a holder that lost its lease cannot act. The token is generated by
|
||||
** the journal, is never persisted outside the journal row, never printed, and
|
||||
** is not accepted from clients. The snapshot is as of the grant.
|
||||
*******************************************************************************/
|
||||
public final class UploadReservation
|
||||
{
|
||||
private final UploadEntry entry;
|
||||
private final String leaseToken;
|
||||
private final Long fence;
|
||||
private final Instant leaseUntil;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-local; only the journal grants reservations.
|
||||
*******************************************************************************/
|
||||
UploadReservation(UploadEntry entry, String leaseToken, Long fence, Instant leaseUntil)
|
||||
{
|
||||
this.entry = entry;
|
||||
this.leaseToken = leaseToken;
|
||||
this.fence = fence;
|
||||
this.leaseUntil = leaseUntil;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal row id.
|
||||
*******************************************************************************/
|
||||
public Integer journalId()
|
||||
{
|
||||
return (entry.id());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The preserved manifest.
|
||||
*******************************************************************************/
|
||||
public UploadCandidate candidate()
|
||||
{
|
||||
return (entry.candidate());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal state as of the grant; read the journal for current state.
|
||||
*******************************************************************************/
|
||||
public UploadEntry entryAtGrant()
|
||||
{
|
||||
return (entry);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fencing counter of this grant.
|
||||
*******************************************************************************/
|
||||
public Long fence()
|
||||
{
|
||||
return (fence);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lease expiry of this grant.
|
||||
*******************************************************************************/
|
||||
public Instant leaseUntil()
|
||||
{
|
||||
return (leaseUntil);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secret proof of ownership, visible only to the journal implementation.
|
||||
*******************************************************************************/
|
||||
String leaseToken()
|
||||
{
|
||||
return (leaseToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never prints the token.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("UploadReservation[journalId=" + entry.id() + ", fence=" + fence + ", leaseUntil=" + leaseUntil + "]");
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.uploadjournal;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the journal knows about the original object. The journal never reads or
|
||||
** writes bytes; the storage owner reports these facts. Never moves backwards.
|
||||
*******************************************************************************/
|
||||
public enum UploadStorageState
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No write has been attempted that the journal knows of.
|
||||
*******************************************************************************/
|
||||
PENDING,
|
||||
|
||||
/*******************************************************************************
|
||||
** A write was attempted and its outcome is not known (timeout, crash). The
|
||||
** object may exist; it stays visible and reconcilable and is never deleted.
|
||||
*******************************************************************************/
|
||||
UNKNOWN,
|
||||
|
||||
/*******************************************************************************
|
||||
** The storage owner verified the exact bytes and hash of the original.
|
||||
*******************************************************************************/
|
||||
VERIFIED
|
||||
}
|
||||
@@ -288,7 +288,7 @@ public class CommentsService
|
||||
new FollowService().ensureFollower(user, table, id, "MENTIONED");
|
||||
if(!user.equals(PersonalRecords.currentUserId()))
|
||||
{
|
||||
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment", mention.getValueInteger("id"));
|
||||
notify(row, user, CollaborationNotification.Category.MENTION, "You were mentioned in a comment on ", mention.getValueInteger("id"));
|
||||
notified.add(user);
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,7 @@ public class CommentsService
|
||||
if(!author.equals(follower.userId()) && !mentioned.contains(follower.userId()) && Boolean.TRUE.equals(follower.notifyOnComment())
|
||||
&& (follower.mutedUntil() == null || !follower.mutedUntil().isAfter(Instant.now())))
|
||||
{
|
||||
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on a followed record", null);
|
||||
notify(row, follower.userId(), CollaborationNotification.Category.FOLLOW, "New comment on ", null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,8 @@ public class CommentsService
|
||||
*******************************************************************************/
|
||||
private void notify(QRecord row, String user, CollaborationNotification.Category category, String title, Integer mentionId) throws QException
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(user, title, row.getValueString("body"), category,
|
||||
Notifications.send(new CollaborationNotification(user, title
|
||||
+ new CollaborationRecordAccessChecker().notificationLabel(user, row.getValueString("tableName"), row.getValueString("recordId")), row.getValueString("body"), category,
|
||||
row.getValueString("tableName"), row.getValueString("recordId"), Notifications.link(row.getValueString("tableName"), row.getValueString("recordId")))
|
||||
.withEvent(mentionId == null ? row.getValue("id") : mentionId, PersonalRecords.currentUserId()),
|
||||
mentionId == null ? null : () -> CollaborationRecords.update(CollaborationTables.MENTION,
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class FavoritesService
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder", false)).withLimit(1));
|
||||
Integer order = last.isEmpty() ? 0 : Math.addExact(last.getFirst().getValueInteger("sortOrder"), 1);
|
||||
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
|
||||
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("sortOrder", order);
|
||||
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("sortOrder", order);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.follow;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowersProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
/*******************************************************************************
|
||||
** Core event subscribers; consumers must check each recipient's record access.
|
||||
*******************************************************************************/
|
||||
public class CollaborationFollowersProvider implements RecordFollowersProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read only this parent's subscriptions as the system, independent of the caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<RecordFollower> getFollowers(QInstance instance, RecordReference reference, FollowEvent event) throws QException
|
||||
{
|
||||
List<RecordFollower> followers = new ArrayList<>();
|
||||
QContext.withTemporaryContext(new CapturedContext(instance, new QSystemUserSession(instance)), () ->
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
String preference = event == FollowEvent.COMMENT ? "notifyOnComment" : "notifyOnChange";
|
||||
for(QRecord row : CollaborationRecords.query(CollaborationTables.FOLLOW,
|
||||
CollaborationRecords.parent(reference.tableName(), reference.primaryKey().toString())))
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getValueBoolean(preference))
|
||||
&& (row.getValueInstant("mutedUntil") == null || !row.getValueInstant("mutedUntil").isAfter(now)))
|
||||
{
|
||||
followers.add(new RecordFollower().withUserId(row.getValueString("userId"))
|
||||
.withNotifyOnChange(row.getValueBoolean("notifyOnChange")).withNotifyOnComment(row.getValueBoolean("notifyOnComment"))
|
||||
.withReason(row.getValueString("reason")));
|
||||
}
|
||||
}
|
||||
});
|
||||
return (followers);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ 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.AttachmentAggregateGuard;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentPreviewCustomizer;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentSecurity;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.values.FeatureScopeNamePossibleValueProvider;
|
||||
import com.kingsrook.qbits.collaboration.values.RecordFieldNamePossibleValueProvider;
|
||||
@@ -80,6 +80,8 @@ public final class CollaborationTables
|
||||
public static final String ACTIVITY = "collaboration_activity";
|
||||
public static final String FOLLOW_DIGEST = "collaboration_follow_digest";
|
||||
public static final String ATTACHMENT = "collaboration_attachment";
|
||||
public static final String ATTACHMENT_CLEANUP = "collaboration_attachment_cleanup";
|
||||
public static final String UPLOAD_JOURNAL = "collaboration_upload_journal";
|
||||
public static final String FEATURE_SETTING = "collaboration_feature_setting";
|
||||
public static final String FAVORITE = "collaboration_favorite";
|
||||
public static final String RECENT = "collaboration_recent";
|
||||
@@ -198,10 +200,26 @@ public final class CollaborationTables
|
||||
.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 = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config)));
|
||||
tables.add(com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable.metaData(config));
|
||||
tables.addAll(conversationTables(config));
|
||||
tables.addAll(tagTables(config));
|
||||
tables.addAll(workTables(config));
|
||||
tables.addAll(customFieldTables(config));
|
||||
tables.add(base(config, ATTACHMENT_CLEANUP, "Attachment Cleanup", "delete")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentCleanupQueue.class)))
|
||||
.withRecordLabelFormatAndFields("Cleanup %s", "id")
|
||||
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER))
|
||||
.withField(string("bucketName", "Bucket", 255).withIsRequired(true))
|
||||
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("nextAttemptAt", QFieldType.DATE_TIME).withLabel("Next Attempt"))
|
||||
.withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME).withLabel("Resolved At"))
|
||||
.withField(string("resolvedBy", "Resolved By", 255))
|
||||
.withField(string("settlementEvidence", "Provider Settlement Evidence", 2000))
|
||||
.withSection(new QFieldSection("cleanup", new QIcon("delete"), Tier.T1,
|
||||
List.of("id", "attachmentId", "bucketName", "storageKey", "nextAttemptAt")).withLabel("Cleanup"))
|
||||
.withSection(new QFieldSection("settlement", new QIcon("check"), Tier.T2,
|
||||
List.of("resolvedAt", "resolvedBy", "settlementEvidence")).withLabel("Operator Settlement")));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
|
||||
@@ -273,9 +291,9 @@ public final class CollaborationTables
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData attachment(CollaborationQBitConfig config)
|
||||
{
|
||||
return (base(config, ATTACHMENT, "Attachments", "attach_file")
|
||||
return (AttachmentAggregateGuard.configure(base(config, ATTACHMENT, "Attachments", "attach_file")
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules()
|
||||
.withCustomPermissionChecker(new QCodeReference(AttachmentSecurity.class)))
|
||||
.withCustomPermissionChecker(new QCodeReference(AttachmentAggregateGuard.class)))
|
||||
.withRecordLabelFormatAndFields("%s", "fileName")
|
||||
.withSearchFields(List.of("fileName", "description"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
@@ -285,30 +303,39 @@ public final class CollaborationTables
|
||||
.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("intakeKey", "Intake Key", 64).withIsEditable(false))
|
||||
.withField(string("intakeFingerprint", "Intake Fingerprint", 64).withIsEditable(false))
|
||||
.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(user("createdByUserId", "Created By").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("retained", QFieldType.BOOLEAN).withLabel("Retained").withIsRequired(true).withDefaultValue(false).withIsEditable(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)))
|
||||
.withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48))
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD)
|
||||
.withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "fileName")
|
||||
.withValue("deliveryModeField", "previewDelivery")))
|
||||
.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)))
|
||||
.withValue(AdornmentType.FileDownloadValues.DOWNLOAD_URL_DYNAMIC, true)
|
||||
.withValue("deliveryModeField", "downloadDelivery")))
|
||||
.withVirtualField(new QVirtualFieldMetaData("previewDelivery", QFieldType.STRING).withLabel("Preview Delivery").withIsEditable(false).withIsQuerySelectable(true))
|
||||
.withVirtualField(new QVirtualFieldMetaData("downloadDelivery", QFieldType.STRING).withLabel("Download Delivery").withIsEditable(false).withIsQuerySelectable(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"))
|
||||
List.of("tableName", "recordId", "createdByUserId", "parentDeleted", "retained")).withLabel("Parent"))
|
||||
.withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T3,
|
||||
List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256")).withLabel("Storage")));
|
||||
List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256", "intakeKey", "intakeFingerprint")).withLabel("Storage")), config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,18 +21,33 @@
|
||||
|
||||
package com.kingsrook.qbits.collaboration.presence;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
|
||||
@@ -49,6 +64,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
public static final long TTL_MILLIS = 45000;
|
||||
public static final int MAX_ENTRIES = 50000;
|
||||
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
|
||||
private static final byte[] SESSION_SECRET = sessionSecret();
|
||||
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
|
||||
private final Map<Key, Entry> entries = new HashMap<>();
|
||||
private final RealtimeBusInterface bus;
|
||||
@@ -117,27 +133,52 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each tab has its own lease; user views merge the live leases.
|
||||
** Each tab has its own lease; user views merge the live leases. The lease
|
||||
** keeps an opaque id of the server-side session (never the session uuid,
|
||||
** which is a bearer credential) and the time it started (renewals keep it).
|
||||
*******************************************************************************/
|
||||
private record Entry(String displayName, String mode, String fieldName, long at)
|
||||
private record Entry(String displayName, String mode, String fieldName, long at, String sessionId, long since)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transport-independent snapshot entry, never a persisted directory row.
|
||||
** Merged per user, connections counts the live leases behind it; per
|
||||
** connection (connections = 1), connectionId is an opaque per-lease id,
|
||||
** sessionId an opaque id of the signed-in session (HMAC, never reversible to
|
||||
** the session uuid) and since the lease start (ISO-8601). None of the three
|
||||
** is set on merged viewers.
|
||||
*******************************************************************************/
|
||||
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
|
||||
public record Viewer(String userId, String displayName, String mode, String fieldName, Integer connections, @JsonInclude(JsonInclude.Include.NON_NULL) String connectionId,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String sessionId, @JsonInclude(JsonInclude.Include.NON_NULL) String since) implements Serializable
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A merged viewer behind a number of live leases.
|
||||
*******************************************************************************/
|
||||
public Viewer(String userId, String displayName, String mode, String fieldName, Integer connections)
|
||||
{
|
||||
this(userId, displayName, mode, fieldName, connections, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh locally even if transport delivery fails; replicate every heartbeat.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
return (heartbeat(user, displayName, connection, null, table, pk, mode, fieldName));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew a lease tied to the caller's server-side session. Only an opaque HMAC
|
||||
** of the session uuid is kept and replicated; the uuid itself is dropped here.
|
||||
*******************************************************************************/
|
||||
public boolean heartbeat(String user, String displayName, String connection, String sessionUuid, String table, String pk, String mode, String fieldName)
|
||||
{
|
||||
expire();
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(displayName, mode, fieldName, clock.millis());
|
||||
long now = clock.millis();
|
||||
var entry = new Entry(displayName, mode, fieldName, now, opaqueSessionId(sessionUuid), now);
|
||||
if(!apply(key, entry, false))
|
||||
{
|
||||
return (false);
|
||||
@@ -152,7 +193,8 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
public void leave(String user, String connection, String table, String pk)
|
||||
{
|
||||
var key = new Key(user, connection, table, pk);
|
||||
var entry = new Entry(null, null, null, clock.millis());
|
||||
long now = clock.millis();
|
||||
var entry = new Entry(null, null, null, now, null, now);
|
||||
apply(key, entry, true);
|
||||
publish(key, entry, true);
|
||||
}
|
||||
@@ -171,6 +213,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
data.put("mode", entry.mode());
|
||||
data.put("fieldName", entry.fieldName());
|
||||
data.put("at", entry.at());
|
||||
data.put("sessionId", entry.sessionId());
|
||||
data.put("leave", leave);
|
||||
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
|
||||
}
|
||||
@@ -197,7 +240,7 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
return;
|
||||
}
|
||||
apply(new Key(user, connection, table, (String) data.get("pk")),
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt, (String) data.get("sessionId"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -218,16 +261,25 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
LOG.warn("Presence capacity reached; refusing a new target");
|
||||
return (false);
|
||||
}
|
||||
List<Viewer> before = snapshotInternal(key.table(), key.pk());
|
||||
List<Viewer> before = projection(key.table(), key.pk());
|
||||
if(leave)
|
||||
{
|
||||
entries.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// a renewal keeps the lease start (pods may differ by a little: //
|
||||
// the earliest start wins) and the first known session id //
|
||||
/////////////////////////////////////////////////////////////////
|
||||
if(old != null)
|
||||
{
|
||||
entry = new Entry(entry.displayName(), entry.mode(), entry.fieldName(), entry.at(),
|
||||
old.sessionId() != null ? old.sessionId() : entry.sessionId(), Math.min(old.since(), entry.since()));
|
||||
}
|
||||
entries.put(key, entry);
|
||||
}
|
||||
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
|
||||
changed = !before.equals(projection(key.table(), key.pk()));
|
||||
}
|
||||
if(changed)
|
||||
{
|
||||
@@ -274,16 +326,137 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private List<Viewer> snapshotInternal(String table, String pk)
|
||||
{
|
||||
Map<String, Entry> users = new java.util.TreeMap<>();
|
||||
Map<String, Entry> users = new TreeMap<>();
|
||||
Map<String, Integer> connections = new TreeMap<>();
|
||||
for(var row : live(table, pk))
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
connections.merge(row.getKey().user(), 1, Integer::sum);
|
||||
}
|
||||
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(),
|
||||
pk == null ? null : row.getValue().fieldName(), connections.get(row.getKey()))).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease, ordered by user, lease start and opaque id. A
|
||||
** table target counts each connection once per record it holds a lease on.
|
||||
*******************************************************************************/
|
||||
private List<Viewer> connectionsInternal(String table, String pk)
|
||||
{
|
||||
return (live(table, pk).stream()
|
||||
.map(row -> new Viewer(row.getKey().user(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName(), 1,
|
||||
opaqueConnectionId(row.getKey().user(), row.getKey().connection()), row.getValue().sessionId(), Instant.ofEpochMilli(row.getValue().since()).toString()))
|
||||
.sorted(java.util.Comparator.comparing(Viewer::userId).thenComparing(Viewer::since).thenComparing(Viewer::connectionId))
|
||||
.toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Live leases on a table (any record when pk is null) or on one record.
|
||||
*******************************************************************************/
|
||||
private List<Map.Entry<Key, Entry>> live(String table, String pk)
|
||||
{
|
||||
List<Map.Entry<Key, Entry>> result = new ArrayList<>();
|
||||
for(var row : entries.entrySet())
|
||||
{
|
||||
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
|
||||
{
|
||||
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
|
||||
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
|
||||
result.add(row);
|
||||
}
|
||||
}
|
||||
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** What clients can observe: merged viewers and per-connection viewers. Lease
|
||||
** renewals that change neither generate no event.
|
||||
*******************************************************************************/
|
||||
private List<Viewer> projection(String table, String pk)
|
||||
{
|
||||
List<Viewer> result = new ArrayList<>(snapshotInternal(table, pk));
|
||||
result.addAll(connectionsInternal(table, pk));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque, stable per-lease id: "c" and the first 16 hex digits of
|
||||
** SHA-256(userId + "\n" + connectionId). A client can recognise its own
|
||||
** connection; nobody learns another client's connection id.
|
||||
*******************************************************************************/
|
||||
public static String opaqueConnectionId(String userId, String connectionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest((userId + "\n" + connectionId).getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder("c");
|
||||
for(int i = 0; i < 8; i++)
|
||||
{
|
||||
result.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return (result.toString());
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Opaque session id: "s" and the first 16 hex digits of
|
||||
** HMAC-SHA256(secret, sessionUuid), or null without a session. The secret is
|
||||
** QQQ_PRESENCE_SESSION_SECRET (system property or environment; share it across
|
||||
** pods for ids that agree between pods) or else random per process. Session
|
||||
** uuids are bearer credentials and must never be stored or published.
|
||||
*******************************************************************************/
|
||||
public static String opaqueSessionId(String sessionUuid)
|
||||
{
|
||||
if(sessionUuid == null || sessionUuid.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(SESSION_SECRET, "HmacSHA256"));
|
||||
byte[] digest = mac.doFinal(sessionUuid.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder("s");
|
||||
for(int i = 0; i < 8; i++)
|
||||
{
|
||||
result.append(String.format("%02x", digest[i]));
|
||||
}
|
||||
return (result.toString());
|
||||
}
|
||||
catch(NoSuchAlgorithmException | InvalidKeyException e)
|
||||
{
|
||||
throw (new IllegalStateException("HmacSHA256 is unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured shared secret, or 32 random bytes for this process.
|
||||
*******************************************************************************/
|
||||
private static byte[] sessionSecret()
|
||||
{
|
||||
String configured = System.getProperty("QQQ_PRESENCE_SESSION_SECRET", System.getenv("QQQ_PRESENCE_SESSION_SECRET"));
|
||||
if(configured != null && !configured.isBlank())
|
||||
{
|
||||
return (configured.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
byte[] random = new byte[32];
|
||||
new SecureRandom().nextBytes(random);
|
||||
return (random);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease (opt-in per-connection presence, #54).
|
||||
*******************************************************************************/
|
||||
public List<Viewer> connections(String table, String pk)
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
return (connectionsInternal(table, pk));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -294,12 +467,27 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
Map<String, Integer> result = new java.util.TreeMap<>();
|
||||
Map<String, Integer> result = new TreeMap<>();
|
||||
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Live lease (connection) counts per table, next to the user counts above.
|
||||
*******************************************************************************/
|
||||
public Map<String, Integer> connectionCounts()
|
||||
{
|
||||
expire();
|
||||
synchronized(this)
|
||||
{
|
||||
Map<String, Integer> result = new TreeMap<>();
|
||||
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, live(table, null).size()));
|
||||
result.values().removeIf(count -> count == 0);
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record focus stays on its record channel; table events contain table snapshots.
|
||||
*******************************************************************************/
|
||||
@@ -323,23 +511,97 @@ public final class PresenceRegistry implements AutoCloseable
|
||||
{
|
||||
data.put("pk", pk);
|
||||
}
|
||||
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
|
||||
Viewers viewers = new Viewers();
|
||||
Viewers leases = new Viewers();
|
||||
synchronized(this)
|
||||
{
|
||||
for(Viewer viewer : snapshotInternal(table, pk))
|
||||
{
|
||||
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
|
||||
value.put("userId", viewer.userId());
|
||||
value.put("displayName", viewer.displayName());
|
||||
value.put("mode", viewer.mode());
|
||||
value.put("fieldName", viewer.fieldName());
|
||||
viewers.add(value);
|
||||
}
|
||||
snapshotInternal(table, pk).forEach(viewer -> viewers.add(toMap(viewer)));
|
||||
connectionsInternal(table, pk).forEach(viewer -> leases.add(toMap(viewer)));
|
||||
}
|
||||
data.put("viewers", viewers);
|
||||
data.put("leaseCount", leases.size());
|
||||
data.put("leases", leases);
|
||||
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Event shape of a viewer; per-connection fields only when set.
|
||||
*******************************************************************************/
|
||||
private static LinkedHashMap<String, Serializable> toMap(Viewer viewer)
|
||||
{
|
||||
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
|
||||
value.put("userId", viewer.userId());
|
||||
value.put("displayName", viewer.displayName());
|
||||
value.put("mode", viewer.mode());
|
||||
value.put("fieldName", viewer.fieldName());
|
||||
value.put("connections", viewer.connections());
|
||||
if(viewer.connectionId() != null)
|
||||
{
|
||||
value.put("connectionId", viewer.connectionId());
|
||||
if(viewer.sessionId() != null)
|
||||
{
|
||||
value.put("sessionId", viewer.sessionId());
|
||||
}
|
||||
value.put("since", viewer.since());
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Event viewers list that is never omitted: the SSE writer serializes event data
|
||||
** with NON_EMPTY inclusion, which would drop an empty list, but the schema
|
||||
** declares viewers a required array, so the last leave must send [] (#52).
|
||||
*******************************************************************************/
|
||||
@JsonSerialize(using = ViewersSerializer.class)
|
||||
public static final class Viewers extends ArrayList<LinkedHashMap<String, Serializable>>
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes each viewer with the caller's mapper settings and reports never empty.
|
||||
*******************************************************************************/
|
||||
public static final class ViewersSerializer extends StdSerializer<Viewers>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Jackson instantiates the serializer reflectively.
|
||||
*******************************************************************************/
|
||||
public ViewersSerializer()
|
||||
{
|
||||
super(Viewers.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always an array, including the empty array after the last viewer leaves.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void serialize(Viewers viewers, JsonGenerator generator, SerializerProvider provider) throws IOException
|
||||
{
|
||||
generator.writeStartArray(viewers, viewers.size());
|
||||
for(LinkedHashMap<String, Serializable> viewer : viewers)
|
||||
{
|
||||
provider.defaultSerializeValue(viewer, generator);
|
||||
}
|
||||
generator.writeEndArray();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** NON_EMPTY inclusion consults this; an empty viewer list is still a value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEmpty(SerializerProvider provider, Viewers viewers)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release listeners and timer on host shutdown or in tests.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -62,7 +62,7 @@ public class PresenceService
|
||||
throw (new QBadRequestException("Unknown focused field"));
|
||||
}
|
||||
String name = QContext.getQSession().getUser().getFullName();
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
|
||||
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, QContext.getQSession().getUuid(), table, pk, mode, field))
|
||||
{
|
||||
throw (new QBadRequestException("Presence capacity reached; retry later"));
|
||||
}
|
||||
@@ -89,6 +89,15 @@ public class PresenceService
|
||||
return (registry().snapshot(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One viewer per live lease, behind the same access boundary as snapshot.
|
||||
*******************************************************************************/
|
||||
public List<PresenceRegistry.Viewer> connections(String table, String pk) throws QException
|
||||
{
|
||||
PersonalRecords.currentUserId();
|
||||
return (registry().connections(table, authorize(table, pk)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A refusal always has the same not-found result, including disabled features.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -70,7 +70,7 @@ public final class RecentsService
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
QRecord row = new QRecord().withValue("userId", userId).withValue("targetType", targetType).withValue("targetName", targetName)
|
||||
.withValue("recordId", target.recordId()).withValue("label", target.label()).withValue("lastViewedAt", now).withValue("viewCount", 1);
|
||||
.withValue("recordId", target.recordId()).withValue("label", TargetResolver.cacheableLabel(target)).withValue("lastViewedAt", now).withValue("viewCount", 1);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.RECENT, row);
|
||||
@@ -92,7 +92,7 @@ public final class RecentsService
|
||||
return;
|
||||
}
|
||||
PersonalRecords.update(CollaborationTables.RECENT, List.of(new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", target.label())));
|
||||
.withValue("lastViewedAt", now).withValue("viewCount", Math.addExact(row.getValueInteger("viewCount"), 1)).withValue("label", TargetResolver.cacheableLabel(target))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+6
-15
@@ -27,6 +27,7 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -34,7 +35,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
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.model.session.QSession;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -90,8 +90,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
/*******************************************************************************
|
||||
** The record label as the recipient may see it: empty unless a session for the
|
||||
** recipient exists, the record is readable, and every label field is fully
|
||||
** accessible (FieldAccessHelper.isRecordLabelFullAccess, absent in older QQQ
|
||||
** versions, which means no label).
|
||||
** accessible under the recipient session's field-access policy.
|
||||
*******************************************************************************/
|
||||
public Optional<String> recordLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
@@ -111,7 +110,7 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
try
|
||||
{
|
||||
ParentRecordAccess.requireVisibleParent(table, recordId);
|
||||
if(labelFullyAccessible(table))
|
||||
if(FieldAccessHelper.isRecordLabelFullAccess(QContext.getQInstance().getTable(table)))
|
||||
{
|
||||
GetInput input = new GetInput(table).withPrimaryKey(recordId).withShouldGenerateDisplayValues(true);
|
||||
QRecord record = new GetAction().executeForRecord(input);
|
||||
@@ -127,18 +126,10 @@ public class CollaborationRecordAccessChecker implements com.kingsrook.qqq.backe
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reflective guard: the helper only exists in QQQ versions with secured data.
|
||||
** Notification titles fall back to public table metadata and the record id.
|
||||
*******************************************************************************/
|
||||
private static boolean labelFullyAccessible(String table)
|
||||
public String notificationLabel(String userId, String table, Serializable recordId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
Class<?> helper = Class.forName("com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldAccessHelper");
|
||||
return (Boolean.TRUE.equals(helper.getMethod("isRecordLabelFullAccess", QTableMetaData.class).invoke(null, QContext.getQInstance().getTable(table))));
|
||||
}
|
||||
catch(ReflectiveOperationException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (recordLabel(userId, table, recordId).orElse(QContext.getQInstance().getTable(table).getLabel() + " #" + recordId));
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ public class SharingService
|
||||
.withValue("recipientCouldRead", recipient.canRead()).withValue("sentAt", Instant.now()).withValue("parentDeleted", false));
|
||||
if(Boolean.TRUE.equals(recipient.canRead()))
|
||||
{
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared " + link.label() + " with you", message,
|
||||
Notifications.send(new CollaborationNotification(recipient.userId(), name + " shared "
|
||||
+ new CollaborationRecordAccessChecker().notificationLabel(recipient.userId(), table, id) + " with you", message,
|
||||
CollaborationNotification.Category.SHARE, table, id, link.url()).withEvent(row.getValue("id"), from));
|
||||
new FollowService().ensureFollower(recipient.userId(), table, id, "SHARED");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
bundle: qbit-collaboration
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: widget:collaborationKpis;slot:label
|
||||
summary: "See counts of collaboration activity and work you can access."
|
||||
- key: widget:collaborationTags;slot:label
|
||||
summary: "Browse tags and their usage from the Collaboration home."
|
||||
- key: widget:collaborationCustomFields;slot:label
|
||||
summary: "Review custom fields and their status from the Collaboration home."
|
||||
- key: widget:collaborationWhosHere;slot:label
|
||||
summary: "See who is currently present, when you have collaboration administrator access."
|
||||
- key: widget:collaboration_field_definitionJoinCollaboration_field_value;slot:label
|
||||
summary: "Review values associated with this custom field."
|
||||
- key: widget:collaboration_tagJoinCollaboration_record_tag;slot:label
|
||||
summary: "See records carrying this tag."
|
||||
- key: widget:collaboration_commentJoinCollaboration_comment_revision;slot:label
|
||||
summary: "Review earlier versions of this comment."
|
||||
- key: widget:collaboration_commentJoinCollaboration_mention;slot:label
|
||||
summary: "See people mentioned in this comment."
|
||||
- key: widget:collaboration_commentJoinCollaboration_follow;slot:label
|
||||
summary: "See followers of the record containing this comment."
|
||||
- key: app:collaboration;slot:help
|
||||
summary: "Work together through record comments, attachments, tags, tasks and sharing."
|
||||
content: |
|
||||
Use Collaboration to review discussions, files and work associated with records. Features are available where they have been enabled, and access still depends on the underlying record permissions. Open Feature Settings to review availability, People to find participants, or the content tables to review what has been created.
|
||||
Use the Setup card to see which features are enabled and where their settings come from.
|
||||
- key: table:collaboration_feature_setting;slot:help
|
||||
summary: "Control which record features are available across the site, in an app or on a table."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Use Feature Settings to turn features such as Comments, Tags and Attachments on or off. Choose the Scope Type and Scope Name to identify where your setting applies, then select the Feature. Enabled controls availability, while Permission Mode controls who can use it. Settings holds additional options for that feature.
|
||||
To start with your configured settings, run Create rows from configuration while this table is empty. Review the proposed rows before continuing. This action leaves existing rows untouched. You can then edit the rows to override the configuration.
|
||||
- key: table:collaboration_feature_setting;field:scopeType
|
||||
summary: "Choose whether the setting applies to the whole site, an app or a table."
|
||||
- key: table:collaboration_feature_setting;field:scopeName
|
||||
summary: "Choose the app or table the setting applies to."
|
||||
- key: table:collaboration_feature_setting;field:feature
|
||||
summary: "Choose the record feature you want to configure."
|
||||
- key: table:collaboration_feature_setting;field:enabled
|
||||
summary: "Whether this feature is available in the selected scope."
|
||||
- key: table:collaboration_feature_setting;field:settings
|
||||
summary: "Additional options for the selected feature, stored as JSON."
|
||||
- key: table:collaboration_feature_setting;field:permissionMode
|
||||
summary: "Choose whether editing permission or an explicit feature permission allows use."
|
||||
content: |
|
||||
Permission Mode controls which permission allows someone to use the selected feature. The edit-or-granted option accepts either table editing permission or the specific feature permission. The granted-only option requires the specific feature permission. You still need access to the record, and the feature must be enabled for its scope.
|
||||
- key: table:collaboration_user;slot:help
|
||||
summary: "Find the people used in mentions, sharing and assignments."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
People is the directory used when you mention someone, share a record or assign work. Entries are maintained automatically as signed-in people use collaboration features. You can review their display names, contact details and activity here, but you cannot edit these directory records by hand.
|
||||
- key: table:collaboration_user;field:userId
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:collaboration_user;field:displayName
|
||||
summary: "The name you see when selecting or mentioning this person."
|
||||
- key: table:collaboration_user;field:email
|
||||
summary: "The email address recorded for this person."
|
||||
- key: table:collaboration_user;field:avatarUrl
|
||||
summary: "The address of the image used for this person."
|
||||
- key: table:collaboration_user;field:lastSeenAt
|
||||
summary: "When this person was last seen using collaboration features."
|
||||
- key: table:collaboration_user;field:active
|
||||
summary: "Whether this person is marked active in the directory."
|
||||
- key: table:collaboration_favorite;slot:help
|
||||
summary: "Keep track of the records, tables and apps you have starred."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Star a record, table or app to keep it among your Favorites. Each entry identifies the destination and stores your chosen label, folder and sort order. Favorites belong to individual people, so your list reflects your own choices. An empty list means you have not starred anything available here.
|
||||
- key: table:collaboration_favorite;field:userId
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:collaboration_favorite;field:targetType
|
||||
summary: "The kind of destination, such as a record, table or app."
|
||||
- key: table:collaboration_favorite;field:targetName
|
||||
summary: "The destination this entry opens."
|
||||
- key: table:collaboration_favorite;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_favorite;field:label
|
||||
summary: "The name shown to you for this entry."
|
||||
- key: table:collaboration_favorite;field:sortOrder
|
||||
summary: "The position used to order this entry."
|
||||
- key: table:collaboration_favorite;field:folder
|
||||
summary: "The folder used to group your favorites."
|
||||
- key: table:collaboration_recent;slot:help
|
||||
summary: "Find records you opened recently and see when you last viewed them."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Recently Viewed is filled automatically when you open records with Recents enabled. Each entry keeps the destination, your latest viewing time and the number of views. You do not need to create these entries yourself. Older entries are removed by Trim recents according to the configured retention limits.
|
||||
- key: table:collaboration_recent;field:userId
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:collaboration_recent;field:targetType
|
||||
summary: "The kind of destination, such as a record, table or app."
|
||||
- key: table:collaboration_recent;field:targetName
|
||||
summary: "The destination this entry opens."
|
||||
- key: table:collaboration_recent;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_recent;field:label
|
||||
summary: "The name shown to you for this entry."
|
||||
- key: table:collaboration_recent;field:lastViewedAt
|
||||
summary: "When you most recently opened this destination."
|
||||
- key: table:collaboration_recent;field:viewCount
|
||||
summary: "How many times you have opened this destination."
|
||||
- key: table:collaboration_attachment;slot:help
|
||||
summary: "Browse files attached to records, with their descriptions and storage details."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Upload a file through the Attachments panel on a record to create an attachment. You can review the file name, type, size and description here, together with its parent record. The file is stored separately from this entry. Attachments must be enabled for the parent table before you can add files.
|
||||
- key: table:collaboration_attachment;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_attachment;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_attachment;field:fileName
|
||||
summary: "The name of the attached file."
|
||||
- key: table:collaboration_attachment;field:contentType
|
||||
summary: "The file type reported for the attachment."
|
||||
- key: table:collaboration_attachment;field:sizeBytes
|
||||
summary: "The size of the attached file in bytes."
|
||||
- key: table:collaboration_attachment;field:sha256
|
||||
summary: "The file fingerprint used to identify its contents."
|
||||
- key: table:collaboration_attachment;field:bucketName
|
||||
summary: "The storage location holding the attached file."
|
||||
- key: table:collaboration_attachment;field:storageKey
|
||||
summary: "The location of the file within its storage bucket."
|
||||
- key: table:collaboration_attachment;field:thumbnailKey
|
||||
summary: "The location of the smaller preview image in storage."
|
||||
- key: table:collaboration_attachment;field:description
|
||||
summary: "A description that helps you understand this entry."
|
||||
- key: table:collaboration_attachment;field:createdByUserId
|
||||
summary: "The person who created this entry."
|
||||
- key: table:collaboration_attachment;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_attachment;field:retained
|
||||
summary: "Whether the original file is protected from deletion, including after its related record is deleted."
|
||||
- key: table:collaboration_attachment;field:intakeKey
|
||||
summary: "The server-scoped identity used to recover a repeated file intake request."
|
||||
- key: table:collaboration_attachment;field:intakeFingerprint
|
||||
summary: "The fingerprint that prevents a repeated intake request from changing its original file or metadata."
|
||||
- key: table:collaboration_comment;slot:help
|
||||
summary: "Read record discussions, including replies, authors and resolution details."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Write comments from the Comments panel on a record where Comments is enabled. A discussion can contain replies and can be resolved when it no longer needs attention. You can review the author, latest activity and resolution here. Earlier text from edited comments is retained in Comment History.
|
||||
- key: table:collaboration_comment;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_comment;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_comment;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_comment;field:threadId
|
||||
summary: "The opening comment of the discussion this reply belongs to."
|
||||
- key: table:collaboration_comment;field:parentCommentId
|
||||
summary: "The comment this reply answers."
|
||||
- key: table:collaboration_comment;field:body
|
||||
summary: "The text of the comment."
|
||||
- key: table:collaboration_comment;field:fieldName
|
||||
summary: "The record field this entry refers to."
|
||||
- key: table:collaboration_comment;field:authorUserId
|
||||
summary: "The person who wrote the comment."
|
||||
- key: table:collaboration_comment;field:lastActivityAt
|
||||
summary: "When the comment discussion was last active."
|
||||
- key: table:collaboration_comment;field:editedAt
|
||||
summary: "When the comment was edited."
|
||||
- key: table:collaboration_comment;field:resolvedAt
|
||||
summary: "When the discussion was marked resolved."
|
||||
- key: table:collaboration_comment;field:resolvedByUserId
|
||||
summary: "The person who resolved the discussion."
|
||||
- key: table:collaboration_comment;field:deletedAt
|
||||
summary: "When the comment was deleted."
|
||||
- key: table:collaboration_comment;field:deletedByUserId
|
||||
summary: "The person who deleted the comment."
|
||||
- key: table:collaboration_comment_revision;slot:help
|
||||
summary: "Review earlier text saved when comments were edited."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Comment History is filled automatically when someone edits a comment. Each entry identifies the comment, its earlier text, and who edited it and when. Use these records to understand how a discussion changed. An empty list means there are no recorded edits available for you to review.
|
||||
- key: table:collaboration_comment_revision;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_comment_revision;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_comment_revision;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_comment_revision;field:commentId
|
||||
summary: "The comment this entry refers to."
|
||||
- key: table:collaboration_comment_revision;field:body
|
||||
summary: "The earlier comment text retained for this edit."
|
||||
- key: table:collaboration_comment_revision;field:editedAt
|
||||
summary: "When the comment was edited."
|
||||
- key: table:collaboration_comment_revision;field:editedByUserId
|
||||
summary: "The person who edited the comment."
|
||||
- key: table:collaboration_mention;slot:help
|
||||
summary: "Track people mentioned in comments and whether their notices have been read."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Mention someone in a record comment to create a mention entry. You can review which comment named them, whether the mention is visible, and when they were notified or read it. These entries are maintained by the discussion features; you do not need to create them separately.
|
||||
- key: table:collaboration_mention;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_mention;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_mention;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_mention;field:commentId
|
||||
summary: "The comment this entry refers to."
|
||||
- key: table:collaboration_mention;field:mentionedUserId
|
||||
summary: "The person mentioned in the comment."
|
||||
- key: table:collaboration_mention;field:visible
|
||||
summary: "Whether the mention is visible to the mentioned person."
|
||||
- key: table:collaboration_mention;field:readAt
|
||||
summary: "When the mentioned person read the mention."
|
||||
- key: table:collaboration_mention;field:notifiedAt
|
||||
summary: "When a notification was sent for the mention."
|
||||
- key: table:collaboration_follow;slot:help
|
||||
summary: "See who follows a record and which changes they want to hear about."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Follow a record to receive updates about it where Follow is enabled. Your follower entry stores whether you want notices about changes and comments, along with any temporary mute. You can review why a person follows a record here. Following a record does not replace the permissions needed to read it.
|
||||
- key: table:collaboration_follow;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_follow;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_follow;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_follow;field:userId
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:collaboration_follow;field:reason
|
||||
summary: "Why this person follows the record."
|
||||
- key: table:collaboration_follow;field:notifyOnChange
|
||||
summary: "Whether the follower wants notifications about record changes."
|
||||
- key: table:collaboration_follow;field:notifyOnComment
|
||||
summary: "Whether the follower wants notifications about comments."
|
||||
- key: table:collaboration_follow;field:mutedUntil
|
||||
summary: "When the temporary pause in follower notifications ends."
|
||||
- key: table:collaboration_activity;slot:help
|
||||
summary: "Review the history of comments, files, tags, tasks and sharing on records."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Activity records what happens as you use collaboration features on a record. Each entry gives the time, the person involved, the kind of event and a short description. Turn on Timeline for the parent table to show this history on records. Activity entries are created automatically and cannot be edited here.
|
||||
- key: table:collaboration_activity;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_activity;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_activity;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_activity;field:at
|
||||
summary: "When the activity occurred."
|
||||
- key: table:collaboration_activity;field:actorUserId
|
||||
summary: "The person who performed the activity."
|
||||
- key: table:collaboration_activity;field:type
|
||||
summary: "The kind of activity recorded."
|
||||
- key: table:collaboration_activity;field:summary
|
||||
summary: "A short description of what happened."
|
||||
- key: table:collaboration_activity;field:refTableName
|
||||
summary: "The table holding the item referenced by this activity."
|
||||
- key: table:collaboration_activity;field:refId
|
||||
summary: "The identifier of the item referenced by this activity."
|
||||
- key: table:collaboration_follow_digest;slot:help
|
||||
summary: "Review grouped change notices waiting to be sent to followers."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
As followed records change, entries are created automatically. You can review changes waiting to be sent together to someone following a record. Each entry identifies the recipient, the record and the time the notice is due. Send follow digests processes these pending notices. An empty list means no grouped changes are currently waiting here.
|
||||
- key: table:collaboration_follow_digest;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_follow_digest;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_follow_digest;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_follow_digest;field:userId
|
||||
summary: "The person this entry belongs to."
|
||||
- key: table:collaboration_follow_digest;field:dueAt
|
||||
summary: "When the grouped change notice is due to be sent."
|
||||
- key: table:collaboration_follow_digest;field:changes
|
||||
summary: "The changes collected for the next grouped notice."
|
||||
- key: table:collaboration_tag;slot:help
|
||||
summary: "Manage the labels people apply to records and their approval status."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Create or suggest a tag from a record to add it to the vocabulary. Use Tags to review the vocabulary available for labeling records. A tag can apply across tables or be limited to one table. Depending on Feature Settings, people can create tags freely or suggest tags for approval. Use Approve tags, Merge tags or Delete tags to manage the vocabulary.
|
||||
- key: table:collaboration_tag;field:name
|
||||
summary: "The stable name identifying the tag."
|
||||
- key: table:collaboration_tag;field:label
|
||||
summary: "The name shown to you for this entry."
|
||||
- key: table:collaboration_tag;field:color
|
||||
summary: "The color used to display the tag."
|
||||
- key: table:collaboration_tag;field:tableName
|
||||
summary: "The table this tag is limited to; blank means it is available across tables."
|
||||
- key: table:collaboration_tag;field:approved
|
||||
summary: "Whether the tag has been approved for use."
|
||||
- key: table:collaboration_tag;field:description
|
||||
summary: "A description that helps you understand this entry."
|
||||
- key: table:collaboration_tag;field:createdByUserId
|
||||
summary: "The person who created this entry."
|
||||
- key: table:collaboration_tag;field:usageCount
|
||||
summary: "How many records use the tag."
|
||||
- key: table:collaboration_tag;field:mergedIntoTagId
|
||||
summary: "The tag that replaced this one after a merge."
|
||||
- key: table:collaboration_record_tag;slot:help
|
||||
summary: "See which tags have been applied to each record."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Record Tags links a tag to a record and identifies who applied it. Entries are created when you tag a record or use Add tags on selected records. Tags must be enabled for the parent table. Use the record tagging controls to change these links rather than editing them directly.
|
||||
- key: table:collaboration_record_tag;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_record_tag;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_record_tag;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_record_tag;field:tagId
|
||||
summary: "The tag applied to this record."
|
||||
- key: table:collaboration_record_tag;field:createdByUserId
|
||||
summary: "The person who created this entry."
|
||||
- key: table:collaboration_share;slot:help
|
||||
summary: "Review records shared with people, including messages and opening times."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Share a record with someone from the record header to create a share entry. You can review the sender, recipient, message and the time the recipient opened it. Sharing must be enabled for that table. A share does not itself give the recipient permission to read the record.
|
||||
- key: table:collaboration_share;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_share;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_share;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_share;field:fromUserId
|
||||
summary: "The person who shared the record."
|
||||
- key: table:collaboration_share;field:toUserId
|
||||
summary: "The person the record was shared with."
|
||||
- key: table:collaboration_share;field:message
|
||||
summary: "The message accompanying the share or access request."
|
||||
- key: table:collaboration_share;field:recipientCouldRead
|
||||
summary: "Whether the recipient could read the record when it was shared."
|
||||
- key: table:collaboration_share;field:sentAt
|
||||
summary: "When the record was shared."
|
||||
- key: table:collaboration_share;field:openedAt
|
||||
summary: "When the recipient opened the share."
|
||||
- key: table:collaboration_access_request;slot:help
|
||||
summary: "Review requests to open records and record how those requests were handled."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
When you cannot open a shared record, you can request access. Approvers review the request and record their decision with Mark granted or Decline. Grant the required permissions separately before marking a request granted; this action only records the outcome. The request keeps the handler, time and any response note.
|
||||
- key: table:collaboration_access_request;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_access_request;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_access_request;field:requesterUserId
|
||||
summary: "The person asking for access to the record."
|
||||
- key: table:collaboration_access_request;field:shareId
|
||||
summary: "The share associated with this access request."
|
||||
- key: table:collaboration_access_request;field:message
|
||||
summary: "The message accompanying the share or access request."
|
||||
- key: table:collaboration_access_request;field:status
|
||||
summary: "Whether the request is open, granted, declined or withdrawn."
|
||||
- key: table:collaboration_access_request;field:handledByUserId
|
||||
summary: "The person who answered the access request."
|
||||
- key: table:collaboration_access_request;field:handledAt
|
||||
summary: "When the access request was answered."
|
||||
- key: table:collaboration_access_request;field:note
|
||||
summary: "The response note recorded when the request was handled."
|
||||
- key: table:collaboration_task;slot:help
|
||||
summary: "Track record tasks, assignees, due dates, reminders and completion."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Add a task from the Tasks panel on a record where Tasks is enabled. Give it a title and assign it to someone, with a due date or reminder when needed. Use Mark done when the work is complete and Reopen if it needs more work. Reminder times and completion details are recorded here.
|
||||
- key: table:collaboration_task;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_task;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_task;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_task;field:title
|
||||
summary: "A short description of the work to do."
|
||||
- key: table:collaboration_task;field:notes
|
||||
summary: "Additional details about the task."
|
||||
- key: table:collaboration_task;field:assigneeUserId
|
||||
summary: "The person assigned to work on the record or task."
|
||||
- key: table:collaboration_task;field:dueAt
|
||||
summary: "When the task is due."
|
||||
- key: table:collaboration_task;field:remindAt
|
||||
summary: "When a task reminder should be sent."
|
||||
- key: table:collaboration_task;field:remindedAt
|
||||
summary: "When the task reminder was sent."
|
||||
- key: table:collaboration_task;field:overdueNotifiedAt
|
||||
summary: "When an overdue task notification was sent."
|
||||
- key: table:collaboration_task;field:status
|
||||
summary: "Whether the task is open, done or cancelled."
|
||||
- key: table:collaboration_task;field:completedAt
|
||||
summary: "When the task was marked done."
|
||||
- key: table:collaboration_task;field:completedByUserId
|
||||
summary: "The person who marked the task done."
|
||||
- key: table:collaboration_task;field:createdByUserId
|
||||
summary: "The person who created this entry."
|
||||
- key: table:collaboration_record_assignment;slot:help
|
||||
summary: "See who owns each record and who is assigned to work on it."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Record Owners keeps the owner and assignee associated with a record. You make these assignments through the record controls where Assignment is enabled. Ownership and assignment are separate roles, so you can review both here. The entry links back to the parent record and follows its access restrictions.
|
||||
- key: table:collaboration_record_assignment;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_record_assignment;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_record_assignment;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_record_assignment;field:ownerUserId
|
||||
summary: "The person responsible for the record."
|
||||
- key: table:collaboration_record_assignment;field:assigneeUserId
|
||||
summary: "The person assigned to work on the record or task."
|
||||
- key: table:collaboration_field_definition;slot:help
|
||||
summary: "Define additional fields for a table and manage their publication and validation."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Create a definition in Custom Fields to add information to a table without changing its built-in fields. Choose the table, name, label and type, then set any validation or display options. Use {{label:collaborationPublishField}} to activate a draft. Rebuild index and Archive field manage existing active fields. Custom Fields must be enabled on the table.
|
||||
- key: table:collaboration_field_definition;field:tableName
|
||||
summary: "The table you are adding the custom field to."
|
||||
- key: table:collaboration_field_definition;field:name
|
||||
summary: "The stable name identifying the custom field."
|
||||
- key: table:collaboration_field_definition;field:label
|
||||
summary: "The label you see beside the custom field."
|
||||
- key: table:collaboration_field_definition;field:type
|
||||
summary: "The kind of value the custom field accepts."
|
||||
- key: table:collaboration_field_definition;field:options
|
||||
summary: "The choices available for a selection field, stored as JSON."
|
||||
- key: table:collaboration_field_definition;field:linkTableName
|
||||
summary: "The table you can select records from for a record link."
|
||||
- key: table:collaboration_field_definition;field:required
|
||||
summary: "Whether you must supply a value for the custom field."
|
||||
- key: table:collaboration_field_definition;field:defaultValue
|
||||
summary: "The initial value offered for the custom field."
|
||||
- key: table:collaboration_field_definition;field:minValue
|
||||
summary: "The smallest allowed numeric value."
|
||||
- key: table:collaboration_field_definition;field:maxValue
|
||||
summary: "The largest allowed numeric value."
|
||||
- key: table:collaboration_field_definition;field:maxLength
|
||||
summary: "The maximum number of characters allowed."
|
||||
- key: table:collaboration_field_definition;field:helpText
|
||||
summary: "The guidance shown when you fill in the custom field."
|
||||
- key: table:collaboration_field_definition;field:sectionName
|
||||
summary: "The section where the custom field is displayed."
|
||||
- key: table:collaboration_field_definition;field:sortOrder
|
||||
summary: "The position used to order this entry."
|
||||
- key: table:collaboration_field_definition;field:filterable
|
||||
summary: "Whether the custom field is available for filtering."
|
||||
- key: table:collaboration_field_definition;field:searchable
|
||||
summary: "Whether the custom field is available for searching."
|
||||
- key: table:collaboration_field_definition;field:classification
|
||||
summary: "The sensitivity assigned to information stored in the custom field."
|
||||
- key: table:collaboration_field_definition;field:status
|
||||
summary: "Whether the custom field is a draft, active or archived."
|
||||
content: |
|
||||
New custom fields start as drafts. Review the definition and use Publish field to make a draft active. Use Archive field when an active definition should no longer be active. You change this status through those actions rather than editing it directly; each action checks the current state before applying the change.
|
||||
- key: table:collaboration_field_definition;field:indexState
|
||||
summary: "Whether preparation for filtering is absent, building, ready or failed."
|
||||
- key: table:collaboration_field_definition;field:storage
|
||||
summary: "The storage method used for this custom field."
|
||||
- key: table:collaboration_field_definition;field:statusMessage
|
||||
summary: "The latest explanation or warning about the custom field."
|
||||
- key: table:collaboration_field_definition;field:indexDdl
|
||||
summary: "The database instructions used to prepare the field for filtering."
|
||||
- key: table:collaboration_field_definition;field:createdByUserId
|
||||
summary: "The person who created this entry."
|
||||
- key: table:collaboration_field_value;slot:help
|
||||
summary: "Review values entered into custom fields on records."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Custom Field Values stores entries made through custom fields on records when separate value storage is used. Each entry identifies its parent record and field, with the value stored according to its type. You enter or change values on the parent record; these supporting records are read-only here.
|
||||
- key: table:collaboration_field_value;field:tableName
|
||||
summary: "The table containing the related record."
|
||||
- key: table:collaboration_field_value;field:recordId
|
||||
summary: "The identifier of the related record."
|
||||
- key: table:collaboration_field_value;field:parentDeleted
|
||||
summary: "Whether the related record has been deleted."
|
||||
- key: table:collaboration_field_value;field:fieldName
|
||||
summary: "The record field this entry refers to."
|
||||
- key: table:collaboration_field_value;field:valueText
|
||||
summary: "The text stored for the custom field."
|
||||
- key: table:collaboration_field_value;field:valueNumber
|
||||
summary: "The numeric value stored for the custom field."
|
||||
- key: table:collaboration_field_value;field:valueDate
|
||||
summary: "The date stored for the custom field."
|
||||
- key: table:collaboration_field_value;field:valueDateTime
|
||||
summary: "The date and time stored for the custom field."
|
||||
- key: table:collaboration_field_value;field:valueBoolean
|
||||
summary: "The yes or no value stored for the custom field."
|
||||
- key: process:collaborationTrimRecents;slot:help
|
||||
summary: "Remove older Recently Viewed entries according to the configured limits."
|
||||
content: |
|
||||
Trim recents maintains Recently Viewed by removing older entries according to the configured per-person count limit. It works on viewing history, so it does not delete the records you opened. Use Recently Viewed to review the retained history after cleanup has run. Automatic cleanup requires your host to configure a scheduler.
|
||||
- key: process:collaborationBuildFieldIndexes;slot:help
|
||||
summary: "Prepare active custom fields for filtering."
|
||||
content: |
|
||||
Build field indexes performs the background preparation needed for filtering custom fields. You can review Index State and Status Message in Custom Fields to see whether preparation is complete or needs attention. These supporting details help you distinguish a field definition from a field that is ready for filtering.
|
||||
- key: process:collaborationPublishField;slot:help
|
||||
summary: "Make selected draft custom fields active."
|
||||
content: |
|
||||
Select draft definitions in Custom Fields and use Publish field when their labels, types and validation are ready. Custom Fields must be enabled for the target table. Review the selected definitions before continuing, then read the results for each field; a failed field does not prevent the others from being processed.
|
||||
- key: process:collaborationArchiveField;slot:help
|
||||
summary: "Archive selected active custom fields."
|
||||
content: |
|
||||
Select active definitions in Custom Fields and use Archive field when they should no longer be active. Review the affected values before continuing. The action checks each selected definition and reports its outcome separately. Read the result for each field so you can distinguish archived definitions from those that could not be changed.
|
||||
- key: process:collaborationRebuildField;slot:help
|
||||
summary: "Rebuild filtering support for selected active custom fields."
|
||||
content: |
|
||||
Use Rebuild index on active custom fields when their filtering support needs to be prepared again. Review the selected definitions before continuing. After the action, check Index State and Status Message for progress or problems. The result reports each selected field separately, so review any failures before retrying.
|
||||
- key: process:collaborationSendTaskReminders;slot:help
|
||||
summary: "Send due task reminders and overdue notifications."
|
||||
content: |
|
||||
Send task reminders checks tasks for reminders that are due and work that has become overdue. You choose Due At and Remind At on the task; Reminded At and Overdue Notified At record notices already sent. Review those times when checking why a task has or has not produced a notification.
|
||||
- key: process:collaborationGrant_collaboration_access_request;slot:help
|
||||
summary: "Record that selected access requests have been granted."
|
||||
content: |
|
||||
Grant the person the required access in your permission system first, then use Mark granted to record the outcome of the request. You can add a Note for the requester. Review the selected requests before continuing. This action records the decision, handler and time; it does not change record permissions.
|
||||
- key: process:collaborationDecline_collaboration_access_request;slot:help
|
||||
summary: "Decline selected access requests and record your response."
|
||||
content: |
|
||||
Use Decline when an access request should not be approved. You can add a Note explaining the decision before reviewing the selected requests. Only requests you are permitted to handle can be processed. Read the results to confirm which decisions were recorded and which requests were skipped.
|
||||
- key: process:collaborationDone_collaboration_task;slot:help
|
||||
summary: "Mark selected tasks done and record their completion."
|
||||
content: |
|
||||
Use Mark done after the work described by a task is finished. Review the selected tasks before continuing, then check the results for any tasks you could not change. Completion records who finished the task and when. If more work is needed later, use Reopen on the task.
|
||||
- key: process:collaborationReopen_collaboration_task;slot:help
|
||||
summary: "Return selected tasks to open status."
|
||||
content: |
|
||||
Use Reopen when a completed task needs more work. Select the tasks and review them before continuing. The action checks whether you can manage each task and reports skipped records separately. Afterward, review the task status and its existing assignment and timing details before continuing the work.
|
||||
- key: process:collaborationSendFollowDigests;slot:help
|
||||
summary: "Send grouped change notices that are due for followers."
|
||||
content: |
|
||||
Send follow digests processes pending groups of record changes for followers. Each Follow Digests entry identifies the record, recipient and due time. Use that list to review notices waiting to be sent. An empty list means no grouped notices are waiting there; it does not mean the record has no followers.
|
||||
- key: process:collaborationApproveTags;slot:help
|
||||
summary: "Approve selected tags for use on records."
|
||||
content: |
|
||||
Use Approve tags to make suggested tags available in an approval-based vocabulary. Select the tags you want to approve and review the selection before continuing. You need permission to manage tags. Check the result for skipped or unchanged tags, then review Approved in the Tags list.
|
||||
- key: process:collaborationMergeTags;slot:help
|
||||
summary: "Combine selected tags into a chosen target tag."
|
||||
content: |
|
||||
Use Merge tags when several tags should be represented by one approved tag. Choose the Target tag and review the selected source tags before continuing. The action moves their record assignments to the target. Review the results and the remaining vocabulary before applying the target tag to more records.
|
||||
- key: process:collaborationDeleteTags;slot:help
|
||||
summary: "Delete selected tags and remove their record assignments."
|
||||
content: |
|
||||
Use Delete tags when tags should be removed from the vocabulary. Review the selected tags and the records that will lose tag assignments before continuing. You need permission to manage tags. This removes tag assignments from the affected records; it does not delete the records themselves.
|
||||
- key: process:collaborationDeleteAttachments;slot:help
|
||||
summary: "Delete selected attachments from their records."
|
||||
content: |
|
||||
Select the attachments you want to remove and use Delete attachments. Review the selection before continuing, especially the parent records and file names. The action checks whether you can remove each attachment. Read the results afterward to see which attachments were deleted and which could not be processed.
|
||||
- key: table:collaboration_feature_setting;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:collaboration_feature_setting;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_feature_setting;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_user;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:collaboration_user;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_user;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_favorite;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:collaboration_favorite;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_favorite;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_recent;section:identity
|
||||
summary: "The identifying details for this entry."
|
||||
- key: table:collaboration_recent;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_recent;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_attachment;section:file
|
||||
summary: "The attached file and its description."
|
||||
- key: table:collaboration_attachment;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_attachment;section:storage
|
||||
summary: "The storage details and preparation status for this entry."
|
||||
- key: table:collaboration_attachment;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_comment;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_comment;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_comment;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_comment_revision;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_comment_revision;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_comment_revision;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_mention;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_mention;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_mention;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_follow;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_follow;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_follow;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_activity;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_activity;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_activity;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_follow_digest;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_follow_digest;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_follow_digest;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_tag;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_tag;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_record_tag;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_record_tag;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_record_tag;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_share;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_share;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_share;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_access_request;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_access_request;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_task;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_task;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_task;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_record_assignment;section:details
|
||||
summary: "The settings and details associated with this entry."
|
||||
- key: table:collaboration_record_assignment;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_record_assignment;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_field_definition;section:definition
|
||||
summary: "The custom field identity, type and publication status."
|
||||
- key: table:collaboration_field_definition;section:settings
|
||||
summary: "The custom field choices, validation and display settings."
|
||||
- key: table:collaboration_field_definition;section:storage
|
||||
summary: "The storage details and preparation status for this entry."
|
||||
- key: table:collaboration_field_definition;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_field_value;section:value
|
||||
summary: "The value entered for the custom field."
|
||||
- key: table:collaboration_field_value;section:parent
|
||||
summary: "The record this entry belongs to and whether that record was deleted."
|
||||
- key: table:collaboration_field_value;section:dates
|
||||
summary: "When this entry was created and last changed."
|
||||
- key: table:collaboration_field_definition;section:values
|
||||
summary: "Review stored values associated with this custom field."
|
||||
- key: table:collaboration_tag;section:recordTags
|
||||
summary: "See which records carry this tag."
|
||||
- key: table:collaboration_comment;section:collaboration_comment_revision
|
||||
summary: "Review earlier versions of this comment."
|
||||
- key: table:collaboration_comment;section:collaboration_mention
|
||||
summary: "See people mentioned in this comment."
|
||||
- key: table:collaboration_comment;section:collaboration_follow
|
||||
summary: "See who follows the record containing this comment."
|
||||
- key: widget:collaborationSetup;slot:label
|
||||
summary: "See which record features are enabled and where their settings come from."
|
||||
- key: process:collaborationCreateFeatureSettings;slot:help
|
||||
summary: "Create editable feature settings from your current configuration."
|
||||
content: |
|
||||
Run Create rows from configuration in {{label:collaboration_feature_setting}} when the table is empty. Review the proposed settings before continuing. You need collaboration administrator access.
|
||||
|
||||
The action creates the settings already declared by your configuration. It does not overwrite existing rows. After creating rows, edit them to change feature availability; saved settings override the code configuration.
|
||||
- key: possibleValueSource:collaboration_feature_setting;slot:help
|
||||
summary: "Control which record features are available across the site, in an app or on a table."
|
||||
- key: possibleValueSource:collaboration_user;slot:help
|
||||
summary: "Find the people used in mentions, sharing and assignments."
|
||||
- key: possibleValueSource:collaboration_favorite;slot:help
|
||||
summary: "Keep track of the records, tables and apps you have starred."
|
||||
- key: possibleValueSource:collaboration_recent;slot:help
|
||||
summary: "Find records you opened recently and see when you last viewed them."
|
||||
- key: possibleValueSource:collaboration_attachment;slot:help
|
||||
summary: "Browse files attached to records, with their descriptions and storage details."
|
||||
- key: possibleValueSource:collaboration_comment;slot:help
|
||||
summary: "Read record discussions, including replies, authors and resolution details."
|
||||
- key: possibleValueSource:collaboration_comment_revision;slot:help
|
||||
summary: "Review earlier text saved when comments were edited."
|
||||
- key: possibleValueSource:collaboration_mention;slot:help
|
||||
summary: "Track people mentioned in comments and whether their notices have been read."
|
||||
- key: possibleValueSource:collaboration_follow;slot:help
|
||||
summary: "See who follows a record and which changes they want to hear about."
|
||||
- key: possibleValueSource:collaboration_activity;slot:help
|
||||
summary: "Review the history of comments, files, tags, tasks and sharing on records."
|
||||
- key: possibleValueSource:collaboration_follow_digest;slot:help
|
||||
summary: "Review grouped change notices waiting to be sent to followers."
|
||||
- key: possibleValueSource:collaboration_tag;slot:help
|
||||
summary: "Manage the labels people apply to records and their approval status."
|
||||
- key: possibleValueSource:collaboration_record_tag;slot:help
|
||||
summary: "See which tags have been applied to each record."
|
||||
- key: possibleValueSource:collaboration_share;slot:help
|
||||
summary: "Review records shared with people, including messages and opening times."
|
||||
- key: possibleValueSource:collaboration_access_request;slot:help
|
||||
summary: "Review requests to open records and record how those requests were handled."
|
||||
- key: possibleValueSource:collaboration_task;slot:help
|
||||
summary: "Track record tasks, assignees, due dates, reminders and completion."
|
||||
- key: possibleValueSource:collaboration_record_assignment;slot:help
|
||||
summary: "See who owns each record and who is assigned to work on it."
|
||||
- key: possibleValueSource:collaboration_field_definition;slot:help
|
||||
summary: "Define additional fields for a table and manage their publication and validation."
|
||||
- key: possibleValueSource:collaboration_field_value;slot:help
|
||||
summary: "Review values entered into custom fields on records."
|
||||
- key: possibleValueSource:collaboration_field_type;slot:help
|
||||
summary: "Choose the kind of value your custom field accepts."
|
||||
- key: possibleValueSource:collaboration_field_status;slot:help
|
||||
summary: "Choose whether a custom field is a draft, active or archived."
|
||||
- key: possibleValueSource:collaboration_field_index_state;slot:help
|
||||
summary: "Review whether filtering support is absent, building, ready or failed."
|
||||
- key: possibleValueSource:collaboration_field_classification;slot:help
|
||||
summary: "Choose the sensitivity of information stored in the custom field."
|
||||
- key: possibleValueSource:collaboration_custom_field_tables;slot:help
|
||||
summary: "Choose a table configured to support custom fields."
|
||||
- key: possibleValueSource:collaboration_scope_name;slot:help
|
||||
summary: "Choose an app or table for the selected scope, or the whole instance."
|
||||
- key: possibleValueSource:collaboration_record_field_name;slot:help
|
||||
summary: "Choose a visible field from the selected table."
|
||||
- key: possibleValueSource:collaboration_access_request_status;slot:help
|
||||
summary: "Review whether an access request is open, granted, declined or withdrawn."
|
||||
- key: possibleValueSource:collaboration_task_status;slot:help
|
||||
summary: "Review whether a task is open, done or cancelled."
|
||||
- key: possibleValueSource:collaboration_target_type;slot:help
|
||||
summary: "Choose the kind of destination you want to save."
|
||||
- key: possibleValueSource:collaboration_scope_type;slot:help
|
||||
summary: "Choose whether a setting applies to the whole instance, an app or a table."
|
||||
- key: possibleValueSource:collaboration_feature;slot:help
|
||||
summary: "Choose the record feature you want to configure."
|
||||
- key: possibleValueSource:collaboration_permission_mode;slot:help
|
||||
summary: "Choose whether a feature accepts editing permission or requires an explicit feature permission."
|
||||
- key: table:collaboration_attachment_cleanup;slot:help
|
||||
summary: "Review storage cleanup waiting to finish after an attachment was deleted."
|
||||
content: |
|
||||
**How rows get created:** Authorized attachment operations create these records automatically. Deleted files are removed after the database commits. Uncertain uploads remain pending until the provider has finished writing. Ordinary successful cleanup removes its entry; administrator-resolved uploads retain a receipt. Administrators can inspect the records but cannot edit them directly.
|
||||
- key: table:collaboration_attachment_cleanup;field:attachmentId
|
||||
summary: "The deleted attachment, when known; failed uploads may have no committed attachment ID."
|
||||
- key: table:collaboration_attachment_cleanup;field:bucketName
|
||||
summary: "The storage bucket containing the file awaiting cleanup."
|
||||
- key: table:collaboration_attachment_cleanup;field:storageKey
|
||||
summary: "The immutable file location retained until cleanup succeeds."
|
||||
- key: table:collaboration_attachment_cleanup;field:nextAttemptAt
|
||||
summary: "Failed cleanup waits at least one minute before another scheduled attempt."
|
||||
- key: table:collaboration_attachment_cleanup;field:resolvedAt
|
||||
summary: "When an administrator resolved this upload. Resolved receipts are excluded from retries."
|
||||
- key: table:collaboration_attachment_cleanup;field:resolvedBy
|
||||
summary: "The signed-in administrator who resolved this upload. The original identity is retained on repeat requests."
|
||||
- key: table:collaboration_attachment_cleanup;field:settlementEvidence
|
||||
summary: "Evidence that the provider completed or cancelled the upload. Never include credentials or private file contents."
|
||||
- key: table:collaboration_attachment_cleanup;section:settlement
|
||||
summary: "Retained evidence and identity for an administrator-resolved upload."
|
||||
- key: process:collaborationResolveAttachmentUpload;slot:help
|
||||
summary: "Resolve uncertain uploads only after confirming that the provider has finished writing."
|
||||
content: |
|
||||
Select upload intents, record evidence of provider completion or cancellation for every selected location, and confirm the Review screen. Object absence or elapsed time alone is not proof. If completion cannot be established, leave the intent pending. The action requires an identified collaboration administrator, refuses active or referenced uploads, deletes current bytes, and retains a settlement receipt. Read Result to see which uploads remain unresolved. Existing attachment deletions use normal cleanup retry instead.
|
||||
- key: process:collaborationRetryAttachmentCleanup;slot:help
|
||||
summary: "Retry pending attachment file cleanup after storage or database recovery."
|
||||
content: |
|
||||
Processes up to 100 committed cleanup requests. Completed entries are removed; failures remain available for retry. Runs every minute when the host configures a collaboration scheduler. Manual execution requires collaboration administrator access.
|
||||
- key: table:collaboration_upload_journal;slot:help
|
||||
summary: "Review retained upload progress and recover interrupted registration."
|
||||
content: |
|
||||
**How rows get created**
|
||||
|
||||
Retained uploads automatically create an immutable journal entry before original storage. PREPARED and REGISTERED entries may need recovery after an interruption; COMPLETE means mandatory registration finished, not that quarantined content was released.
|
||||
Open a pending row and choose Recover retained upload while signed in as its original uploader with current access to attach files to the source. The action verifies the existing original and retries registration; it never replaces or deletes bytes. If an intake key is present, retry through the original upload workflow instead.
|
||||
Missing or mismatched storage remains unresolved. Contact your operator if storage is unavailable or the original uploader is no longer active. Waiting for a reservation to expire is not permission to discard evidence.
|
||||
- key: table:collaboration_upload_journal;field:tableName
|
||||
summary: "Original source table. A completed archive keeps this provenance unchanged."
|
||||
- key: table:collaboration_upload_journal;field:recordId
|
||||
summary: "Original source record identifier. Access follows the exact retained attachment after an archive transfer."
|
||||
- key: table:collaboration_upload_journal;field:createdByUserId
|
||||
summary: "Person who initiated the upload. Recovery currently requires this same signed-in person and current source access."
|
||||
- key: table:collaboration_upload_journal;field:fileName
|
||||
summary: "Original uploaded file name captured before storing the bytes."
|
||||
- key: table:collaboration_upload_journal;field:contentType
|
||||
summary: "Media type captured for the original upload."
|
||||
- key: table:collaboration_upload_journal;field:description
|
||||
summary: "Description supplied with the original upload."
|
||||
- key: table:collaboration_upload_journal;field:sizeBytes
|
||||
summary: "Measured number of original bytes. Recovery requires the exact same size."
|
||||
- key: table:collaboration_upload_journal;field:sha256
|
||||
summary: "SHA-256 calculated from the actual original bytes and checked again during recovery."
|
||||
- key: table:collaboration_upload_journal;field:retained
|
||||
summary: "This journal contains retained originals only; recovery never deletes their bytes."
|
||||
- key: table:collaboration_upload_journal;field:intakeKey
|
||||
summary: "A value identifies a coordinated intake. Retry through the original intake workflow rather than this recovery action."
|
||||
- key: table:collaboration_upload_journal;field:intakeFingerprint
|
||||
summary: "Immutable coordinated request fingerprint. Conflicting retries are rejected."
|
||||
- key: table:collaboration_upload_journal;field:locatorHash
|
||||
summary: "Hash identifying the stored object without revealing its storage address."
|
||||
- key: table:collaboration_upload_journal;field:state
|
||||
summary: "PREPARED awaits an attachment; REGISTERED awaits lifecycle completion; COMPLETE has saved registration."
|
||||
- key: table:collaboration_upload_journal;field:storageState
|
||||
summary: "UNKNOWN may have stored bytes; VERIFIED confirms the exact original. Missing data never proves safe cancellation."
|
||||
- key: table:collaboration_upload_journal;field:attachmentId
|
||||
summary: "Exact attachment checkpoint. Recovery reuses this identity rather than creating a replacement."
|
||||
- key: table:collaboration_upload_journal;field:leaseUntil
|
||||
summary: "Time until the current recovery reservation expires. Expiry does not delete or cancel the original."
|
||||
- key: table:collaboration_upload_journal;field:fence
|
||||
summary: "Monotonically increasing reservation number that prevents an expired attempt from registering metadata."
|
||||
- key: process:collaborationAdoptRetainedAttachment;slot:help
|
||||
summary: "Verify an existing attachment's original and register it as retained without copying bytes."
|
||||
content: |
|
||||
Select exactly one attachment, confirm the Review screen and read the Result. You must currently be allowed to modify the attachment and see its source record. The action verifies the existing original and completes lifecycle registration under the configured retention policy. It never accepts replacement bytes, a storage path or another person's identity, and it never copies or deletes the original.
|
||||
Uploads that came from a coordinated intake request cannot be adopted here; retry them through their original workflow. If the original is missing or does not match, the attachment remains unchanged.
|
||||
- key: process:collaborationRecoverRetainedUpload;slot:help
|
||||
summary: "Verify an existing retained original and finish interrupted registration."
|
||||
content: |
|
||||
Select one journal row and confirm recovery. You must be its original uploader and currently be allowed to attach files to its source. Recovery preserves the original bytes and any committed attachment identity, and may leave the file quarantined for review.
|
||||
Coordinated intake entries must be retried through their original workflow. Missing or mismatched bytes remain unresolved; the action never creates replacement content or clears a possible original by timeout.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -56,7 +57,7 @@ class CollaborationApiContractTest
|
||||
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
|
||||
"mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty();
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
|
||||
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
|
||||
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
|
||||
assertThat(extension.getSpecs()).hasSize(45);
|
||||
|
||||
@@ -37,6 +37,7 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
@@ -222,6 +223,21 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
|
||||
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
|
||||
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
|
||||
ok("POST", "/presence", body.replace("tab-one", "tab-two").replace("EDITING", "VIEWING"));
|
||||
JsonNode merged = ok("GET", "/presence?table=document&pk=1", null);
|
||||
assertThat(merged.get("viewers")).hasSize(1);
|
||||
assertThat(merged.at("/viewers/0/connections").asInt()).isEqualTo(2);
|
||||
assertThat(merged.get("leaseCount").asInt()).isEqualTo(2);
|
||||
assertThat(merged.has("leases")).isFalse();
|
||||
JsonNode perConnection = ok("GET", "/presence?table=document&pk=1&perConnection=true", null);
|
||||
assertThat(perConnection.get("viewers")).hasSize(1);
|
||||
assertThat(perConnection.get("leases")).hasSize(2);
|
||||
assertThat(perConnection.at("/leases/0/connectionId").asText()).startsWith("c").doesNotContain("tab-");
|
||||
assertThat(perConnection.at("/leases/0/since").asText()).isNotBlank();
|
||||
assertThat(perConnection.at("/leases/0/sessionId").asText()).startsWith("s");
|
||||
assertThat(perConnection.toString()).doesNotContain("alice" + sessionSuffix).doesNotContain(sessionSuffix);
|
||||
assertError(400, "GET", "/presence?table=document&perConnection=maybe", null, "alice");
|
||||
ok("DELETE", "/presence", "{\"connectionId\":\"tab-two\",\"table\":\"document\",\"pk\":\"1\"}");
|
||||
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
|
||||
assertError(404, "POST", "/presence", body, "outsider");
|
||||
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
|
||||
@@ -431,8 +447,16 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
HttpResponse<String> hidden = request("GET", "/records/document/3/" + feature, null, "alice");
|
||||
HttpResponse<String> absent = request("GET", "/records/document/999/" + feature, null, "alice");
|
||||
assertThat(hidden.statusCode()).isEqualTo(404);
|
||||
assertThat(hidden.body()).isEqualTo(absent.body());
|
||||
assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found");
|
||||
ObjectNode hiddenError = (ObjectNode) json(hidden, 404);
|
||||
ObjectNode absentError = (ObjectNode) json(absent, 404);
|
||||
assertThat(hiddenError.path("traceId").asText()).isNotBlank();
|
||||
assertThat(absentError.path("traceId").asText()).isNotBlank();
|
||||
hiddenError.remove("traceId");
|
||||
absentError.remove("traceId");
|
||||
assertThat(hiddenError).isEqualTo(absentError);
|
||||
assertThat(hiddenError.path("error").asText()).isEqualTo("Not found");
|
||||
assertThat(hiddenError.path("code").asText()).isEqualTo("NOT_FOUND");
|
||||
assertThat(hiddenError.path("message").asText()).isEqualTo("Not found");
|
||||
}
|
||||
assertError(404, "POST", "/records/document/3/comments", "not json", "alice");
|
||||
assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice");
|
||||
@@ -500,15 +524,19 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
|
||||
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()), 200);
|
||||
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.0.0");
|
||||
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.2.0");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Framework errors always contain a human-readable error property.
|
||||
** Framework errors include the standard message, code and request trace.
|
||||
*******************************************************************************/
|
||||
private void assertError(int status, String method, String path, String body, String caller) throws Exception
|
||||
{
|
||||
assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank();
|
||||
var error = json(request(method, path, body, caller), status);
|
||||
assertThat(error.path("error").asText()).isNotBlank();
|
||||
assertThat(error.path("code").asText()).isNotBlank();
|
||||
assertThat(error.path("message").asText()).isNotBlank();
|
||||
assertThat(error.path("traceId").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Coverage of metadata produced by this qbit, before core adds its own bulk actions.
|
||||
*******************************************************************************/
|
||||
class CollaborationHelpBundleTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every owned object resolves and has help, without host-specific providers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bundleCoversProducedMetadata() throws Exception
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
new CollaborationQBitProducer().withQBitConfig(new CollaborationQBitConfig().withBackendName("memory")).produce(instance);
|
||||
|
||||
HelpBundleAssertions.forInstance(instance)
|
||||
.bundles("help/qbit-collaboration.yaml")
|
||||
.owning(target -> target.name().startsWith("collaboration"))
|
||||
.assertComplete();
|
||||
|
||||
var entries = HelpBundles.of(instance).getBundles().getFirst().getEntries();
|
||||
assertThat(entries).extracting(entry -> entry.getKey()).doesNotHaveDuplicates();
|
||||
// Core's gate requires tables, fields, apps and processes; also cover our widgets and selectors.
|
||||
instance.getWidgets().keySet().forEach(name -> assertThat(entries)
|
||||
.anyMatch(entry -> entry.getKey().equals("widget:" + name + ";slot:label")));
|
||||
instance.getPossibleValueSources().keySet().stream().filter(name -> name.startsWith("collaboration")).forEach(name -> assertThat(entries)
|
||||
.anyMatch(entry -> entry.getKey().equals("possibleValueSource:" + name + ";slot:help")));
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundleEntry;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every collaboration_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or writes
|
||||
** it, so no table exists only on paper;
|
||||
** - it is either seeded (feature settings: FeatureSettingsDefaults, from the
|
||||
** configuration at run time) or listed here with why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get created.
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class CollaborationTablesGuardrailTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "collaboration");
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeded from the code configuration by "Create rows from configuration".
|
||||
*******************************************************************************/
|
||||
static final List<String> SEEDED = List.of("collaboration_feature_setting");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("collaboration_user", "runtime: filled as signed-in people use collaboration services"),
|
||||
Map.entry("collaboration_favorite", "user content: a person stars something"),
|
||||
Map.entry("collaboration_recent", "runtime: a person opens a record"),
|
||||
Map.entry("collaboration_attachment", "user content: a person uploads a file"),
|
||||
Map.entry("collaboration_attachment_cleanup", "runtime: authorized deletion queues durable storage cleanup"),
|
||||
Map.entry("collaboration_upload_journal", "runtime: retained upload intent is committed before original storage and retained for recovery"),
|
||||
Map.entry("collaboration_comment", "user content: a person comments"),
|
||||
Map.entry("collaboration_comment_revision", "runtime: a comment is edited"),
|
||||
Map.entry("collaboration_mention", "runtime: a comment mentions someone"),
|
||||
Map.entry("collaboration_follow", "user content: a person follows a record"),
|
||||
Map.entry("collaboration_follow_digest", "runtime: a followed record changes"),
|
||||
Map.entry("collaboration_activity", "runtime: collaboration features are used"),
|
||||
Map.entry("collaboration_tag", "user content: a person or administrator adds a tag"),
|
||||
Map.entry("collaboration_record_tag", "user content: a person tags a record"),
|
||||
Map.entry("collaboration_field_definition", "administrator content: a field manager defines a custom field"),
|
||||
Map.entry("collaboration_field_value", "user content: a person fills in a custom field"),
|
||||
Map.entry("collaboration_share", "user content: a person shares a record"),
|
||||
Map.entry("collaboration_access_request", "user content: a person asks for access"),
|
||||
Map.entry("collaboration_task", "user content: a person adds a task"),
|
||||
Map.entry("collaboration_record_assignment", "user content: a person assigns a record"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> constantByTable = constantByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))
|
||||
&& !p.getFileName().toString().equals("CoreCollaboration.java") && !p.getFileName().toString().equals("CollaborationSchema.java"))
|
||||
.map(CollaborationTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
String constant = constantByTable.get(table);
|
||||
assertThat(constant).as("constant for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("CollaborationTables\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + constant + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
boolean seeded = SEEDED.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> instance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// the seeded table has a real action that writes its rows, and it is named in help //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo("collaboration_feature_setting");
|
||||
assertThat(FeatureSettingsDefaults.configuredRows(instance, config)).isNotEmpty();
|
||||
assertThat(help("collaboration_feature_setting").getContent()).contains(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getLabel());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
for(String table : collaborationTables())
|
||||
{
|
||||
assertThat(help(table).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-collaboration");
|
||||
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
|
||||
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:collaboration;slot:help"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration works without a help table; core owns the guarded startup sync.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registrationWithoutHelpTable()
|
||||
{
|
||||
assertThat(instance.getTable("helpContent")).isNull();
|
||||
HelpBundles.syncOnStartup(instance);
|
||||
assertThat(HelpBundles.of(instance).getBundles()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private HelpBundleEntry help(String table)
|
||||
{
|
||||
return (HelpBundles.of(instance).getBundles().getFirst().getEntries().stream()
|
||||
.filter(entry -> entry.getKey().equals("table:" + table + ";slot:help")).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> collaborationTables()
|
||||
{
|
||||
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("collaboration_")).sorted().toList();
|
||||
assertThat(tables).hasSize(21);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** CollaborationTables declares X = "collaboration_x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> constantByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Matcher m = Pattern.compile("String\\s+([A-Z_]+)\\s*=\\s*\"(collaboration_[a-z_]+)\"").matcher(read(MAIN.resolve("model").resolve("CollaborationTables.java")));
|
||||
while(m.find())
|
||||
{
|
||||
rs.put(m.group(2), m.group(1));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ATTACHMENT_CLEANUP, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
|
||||
for(String widget : CollaborationHome.HOME_WIDGETS)
|
||||
{
|
||||
@@ -146,7 +146,7 @@ class CollaborationTest
|
||||
assertThat(producer.isEnabled()).isTrue();
|
||||
assertThat(producer.getQBitConfig()).isSameAs(config);
|
||||
assertThat(producer.getNamespace()).isEqualTo("collaboration");
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
|
||||
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-collaboration");
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
|
||||
assertThat(config.getAdminApp()).isTrue();
|
||||
@@ -221,13 +221,28 @@ 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(19);
|
||||
assertThat(headless.getTables()).hasSize(21);
|
||||
assertThat(headless.getApps()).isEmpty();
|
||||
assertThat(headless.getWidgets()).hasSize(5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Home tables leave column widths to the frontend, so narrow cards wrap
|
||||
** instead of scrolling sideways (issue #21).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeTablesHaveNoFixedColumnWidths() throws Exception
|
||||
{
|
||||
for(TableData table : java.util.List.of(CollaborationHome.tags(), CollaborationHome.fields(), CollaborationHome.presence()))
|
||||
{
|
||||
assertThat(table.getColumns()).isNotEmpty().allSatisfy(column -> assertThat(column.getWidth()).isNull());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty states explain what has not been populated.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -179,8 +179,9 @@ public class CustomFieldsTest extends PersonalServiceFixture
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FIELD_DEFINITION).withPrimaryKeys(List.of(row.getValue("id"))))).hasMessageContaining("archive");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("name", "renamed"), row)).hasMessageContaining("cannot change");
|
||||
assertThatThrownBy(() -> FieldDefinitions.options(new QRecord().withValue("options", "{}"))).hasMessageContaining("options");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("classification", "BAD"), row)).hasMessageContaining("classification");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(row).withValue("minValue", 5).withValue("maxValue", 1), row)).hasMessageContaining("Minimum");
|
||||
QRecord published = FieldDefinitions.get(row.getValue("id"));
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("classification", "BAD"), published)).hasMessageContaining("classification");
|
||||
assertThatThrownBy(() -> FieldDefinitions.validate(new QRecord(published).withValue("minValue", 5).withValue("maxValue", 1), published)).hasMessageContaining("Minimum");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationSetupStatus;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFeaturesResolver;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
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.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
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.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.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FeaturePermissionMode;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Create rows from configuration" defaults for collaboration_feature_setting.
|
||||
** Main claim: writing the rows changes nothing. For several configurations
|
||||
** (default, table features, app and table declarations, permission modes,
|
||||
** settings, two apps containing one table), every table resolves to exactly the
|
||||
** same enabled flag, permission mode and settings with and without the rows.
|
||||
*******************************************************************************/
|
||||
class FeatureSettingsDefaultsTest
|
||||
{
|
||||
private QInstance instance;
|
||||
private CollaborationQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh instance: a backend, three host tables, two apps, then the qbit.
|
||||
** `code` runs after the qbit is produced and declares features on apps/tables.
|
||||
*******************************************************************************/
|
||||
private void build(Consumer<CollaborationQBitConfig> configure, Consumer<QInstance> code) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
for(String name : List.of("document", "invoice", "receipt"))
|
||||
{
|
||||
instance.addTable(new QTableMetaData().withName(name).withLabel(name.substring(0, 1).toUpperCase() + name.substring(1) + "s").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("title", QFieldType.STRING)).withRecordLabelFormatAndFields("%s", "title"));
|
||||
}
|
||||
instance.addApp(new QAppMetaData().withName("sales").withLabel("Sales").withIcon(new QIcon("sell")).withSection(new QAppSection().withName("s").withLabel("S").withTables(List.of("invoice", "receipt"))));
|
||||
instance.addApp(new QAppMetaData().withName("ops").withLabel("Ops").withIcon(new QIcon("build")).withSection(new QAppSection().withName("o").withLabel("O").withTables(List.of("receipt", "document"))));
|
||||
config = new CollaborationQBitConfig().withBackendName("memory");
|
||||
configure.accept(config);
|
||||
new CollaborationQBitProducer().withQBitConfig(config).produce(instance);
|
||||
code.accept(instance);
|
||||
new com.kingsrook.qqq.backend.core.instances.QInstanceEnricher(instance).enrich();
|
||||
admin();
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void admin()
|
||||
{
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
com.kingsrook.qbits.collaboration.presence.PresenceRegistry.close(instance);
|
||||
com.kingsrook.qqq.backend.core.realtime.RealtimeBus.close(instance);
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What every host table resolves to for every feature: enabled, permission mode,
|
||||
** settings (with the TAGS mode as TagsService reads it).
|
||||
*******************************************************************************/
|
||||
private Map<String, String> effective()
|
||||
{
|
||||
Map<String, String> rs = new TreeMap<>();
|
||||
for(String table : List.of("document", "invoice", "receipt"))
|
||||
{
|
||||
var resolved = RecordFeaturesResolver.resolve(instance, instance.getTable(table));
|
||||
for(RecordFeature feature : RecordFeature.values())
|
||||
{
|
||||
var one = resolved.getFeatures().get(feature);
|
||||
Map<String, java.io.Serializable> settings = new TreeMap<>(one.getSettings());
|
||||
if(feature == RecordFeature.TAGS)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// TagsService reads a missing mode as the configured default tag mode, //
|
||||
// so a row that spells the default out is the same effective setting. //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
settings.putIfAbsent("mode", config.getDefaultTagMode());
|
||||
}
|
||||
rs.put(table + "." + feature, one.getEnabled() + " " + one.getPermissionMode() + " " + settings);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the real "Create rows from configuration" process end to end.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<ProcessSummaryLine> create() throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(FeatureSettingsDefaultsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput output = new RunProcessAction().execute(input);
|
||||
return ((List<ProcessSummaryLine>) output.getValues().get(com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows() throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING).withFilter(new QQueryFilter())).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configurations: defaults; table features with GRANTED_ONLY and a table
|
||||
** TAGS mode; an app that turns attachments on and sets the sharing approver;
|
||||
** a different default tag mode and default features; two apps with one table.
|
||||
*******************************************************************************/
|
||||
private void scenario(int number) throws Exception
|
||||
{
|
||||
switch(number)
|
||||
{
|
||||
case 0 -> build(c -> { }, i -> { });
|
||||
case 1 -> build(c -> c.withDefaultFeatures(RecordFeature.COMMENTS, RecordFeature.TAGS).withTableFeatures("document", RecordFeature.TAGS, RecordFeature.TASKS), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getTable("invoice")).withPermissionMode(RecordFeature.SHARING, FeaturePermissionMode.GRANTED_ONLY)
|
||||
.withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
|
||||
});
|
||||
case 2 -> build(c -> c.withDefaultTagMode("APPROVED_ONLY"), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.ATTACHMENTS, true).withSetting(RecordFeature.SHARING, "accessApproverPermission", "sales.admin");
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getTable("receipt")).withEnabled(RecordFeature.COMMENTS, true).withEnabled(RecordFeature.FAVORITES, false);
|
||||
});
|
||||
case 3 -> build(c -> c.withDefaultFeatures(), i ->
|
||||
{
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("sales")).withEnabled(RecordFeature.TASKS, true).withPermissionMode(RecordFeature.TASKS, FeaturePermissionMode.GRANTED_ONLY);
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withEnabled(RecordFeature.TASKS, false);
|
||||
RecordFeaturesMetaData.ofOrWithNew(i.getApp("ops")).withSetting(RecordFeature.TAGS, "mode", "APPROVED_ONLY");
|
||||
});
|
||||
default -> throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The proof: for every scenario the effective feature set is identical with
|
||||
** and without the seeded rows, and rows really were created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsChangeNothing() throws Exception
|
||||
{
|
||||
for(int number = 0; number <= 3; number++)
|
||||
{
|
||||
scenario(number);
|
||||
Map<String, String> without = effective();
|
||||
assertThat(rows()).as("scenario " + number + " starts empty").isEmpty();
|
||||
|
||||
List<ProcessSummaryLine> summary = create();
|
||||
assertThat(summary).as("scenario " + number).isNotEmpty();
|
||||
List<QRecord> rows = rows();
|
||||
assertThat(rows).as("scenario " + number + " rows").hasSizeGreaterThanOrEqualTo(RecordFeature.values().length);
|
||||
assertThat(rows.stream().filter(row -> "INSTANCE".equals(row.getValueString("scopeType")))).hasSize(RecordFeature.values().length);
|
||||
|
||||
assertThat(effective()).as("scenario " + number + ": effective settings with the seeded rows").containsExactlyInAnyOrderEntriesOf(without);
|
||||
tearDown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default scenario in plain terms: favorites, recents and timeline are
|
||||
** the only features on, TAGS carries the mode, everything else is off.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultRowsMirrorTheConfiguredDefaults() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
create();
|
||||
for(QRecord row : rows())
|
||||
{
|
||||
assertThat(row.getValueString("scopeType")).isEqualTo("INSTANCE");
|
||||
assertThat(row.getValueString("scopeName")).isEqualTo("instance");
|
||||
RecordFeature feature = RecordFeature.valueOf(row.getValueString("feature"));
|
||||
boolean on = List.of(RecordFeature.FAVORITES, RecordFeature.RECENTS, RecordFeature.TIMELINE).contains(feature);
|
||||
assertThat(row.getValueBoolean("enabled")).as(feature.name()).isEqualTo(on);
|
||||
assertThat(row.getValueString("permissionMode")).isEqualTo("EDIT_OR_GRANTED");
|
||||
assertThat(row.getValueString("settings")).isEqualTo(feature == RecordFeature.TAGS ? "{\"mode\":\"FREE\"}" : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Running it again creates nothing; an edit survives; and the rows are live:
|
||||
** turning COMMENTS on in its row turns comments on for every host table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertOnlyAndTheRowsAreLive() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
create();
|
||||
int count = rows().size();
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).isFalse();
|
||||
|
||||
QRecord comments = rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow();
|
||||
new UpdateAction().execute(new UpdateInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(new QRecord().withValue("id", comments.getValue("id")).withValue("enabled", true))));
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("document")).isEnabled(RecordFeature.COMMENTS)).as("the edited row drives the runtime").isTrue();
|
||||
assertThat(RecordFeaturesResolver.resolve(instance, instance.getTable("invoice")).isEnabled(RecordFeature.COMMENTS)).isTrue();
|
||||
|
||||
List<ProcessSummaryLine> second = create();
|
||||
assertThat(second).singleElement().satisfies(line -> assertThat(line.getMessage()).contains("already has rows"));
|
||||
assertThat(rows()).hasSize(count);
|
||||
assertThat(rows().stream().filter(row -> "COMMENTS".equals(row.getValueString("feature"))).findFirst().orElseThrow().getValueBoolean("enabled")).isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table that already has an administrator's rows gets nothing added.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void neverAddsToExistingRows() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
CollaborationRecords.insert(CollaborationTables.FEATURE_SETTING, new QRecord().withValue("scopeType", "INSTANCE").withValue("scopeName", "instance")
|
||||
.withValue("feature", "TASKS").withValue("enabled", true));
|
||||
assertThat(FeatureSettingsDefaults.missingRows(instance, config)).isEmpty();
|
||||
create();
|
||||
assertThat(rows()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only collaboration administrators may run it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminOnly() throws Exception
|
||||
{
|
||||
scenario(0);
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW));
|
||||
assertThatThrownBy(() -> new FeatureSettingsDefaultsProcess.AdminOnly().checkPermissionsThrowing(null, null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getMinInputRecords()).isEqualTo(0);
|
||||
assertThat(instance.getProcess(FeatureSettingsDefaultsProcess.NAME).getTableName()).isEqualTo(CollaborationTables.FEATURE_SETTING);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card reports the configuration, then the rows, and what is on where.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCardUsesTheRuntimeResolution() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
List<CollaborationSetupStatus.Item> before = CollaborationSetupStatus.load(instance);
|
||||
assertThat(before).hasSize(RecordFeature.values().length + 1);
|
||||
assertThat(before.getFirst().state()).isEqualTo("Using code defaults");
|
||||
assertThat(before.getFirst().nextStep()).contains("Create rows from configuration");
|
||||
assertThat(item(before, "Comments").state()).isEqualTo("On for 2 of 3 tables");
|
||||
assertThat(item(before, "Comments").where()).isEqualTo("Invoices, Receipts");
|
||||
assertThat(item(before, "Favorites").state()).isEqualTo("Off");
|
||||
assertThat(item(before, "Tasks").state()).isEqualTo("On for 1 of 3 tables");
|
||||
assertThat(item(before, "Tasks").where()).isEqualTo("Documents");
|
||||
assertThat(item(before, "Presence").state()).isEqualTo("Off");
|
||||
assertThat(item(before, "Presence").nextStep()).contains("Feature Settings");
|
||||
assertThat(item(before, "Tasks").source()).isEqualTo("Configuration");
|
||||
|
||||
create();
|
||||
List<CollaborationSetupStatus.Item> after = CollaborationSetupStatus.load(instance);
|
||||
assertThat(after.getFirst().state()).isEqualTo("Set up");
|
||||
assertThat(item(after, "Tasks").source()).isEqualTo("Feature Settings");
|
||||
List<String> beforeStates = new ArrayList<>(before.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList());
|
||||
assertThat(after.stream().skip(1).map(CollaborationSetupStatus.Item::state).toList()).as("the card reports the same thing before and after").isEqualTo(beforeStates);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The load is one transaction: while it is open (and after it fails and is
|
||||
** rolled back) the override overlay never sees any of its rows, so a failure
|
||||
** on a later page cannot leave Instance rows without the rows that restate
|
||||
** the code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void laterPageFailureNeverReachesTheOverlay() throws Exception
|
||||
{
|
||||
scenario(1);
|
||||
long generation = RuntimeMetaDataOverlay.getGeneration(instance);
|
||||
FeatureSettingsDefaultsProcess.Load load = new FeatureSettingsDefaultsProcess.Load();
|
||||
assertThat(load.openTransaction(new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput())).isPresent();
|
||||
com.kingsrook.qqq.backend.core.actions.QBackendTransaction tx = new com.kingsrook.qqq.backend.core.actions.QBackendTransaction();
|
||||
load.setTransaction(java.util.Optional.of(tx));
|
||||
|
||||
List<QRecord> all = FeatureSettingsDefaults.missingRows(instance, config);
|
||||
var page1 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
|
||||
page1.setRecords(new ArrayList<>(all.subList(0, 3)));
|
||||
load.runOnePage(page1, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput());
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("no refresh before commit").isEqualTo(generation);
|
||||
|
||||
var page2 = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput();
|
||||
page2.setRecords(new ArrayList<>(List.of(new QRecord().withValue("scopeType", "INSTANCE"))));
|
||||
assertThatThrownBy(() -> load.runOnePage(page2, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
|
||||
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QException.class);
|
||||
tx.rollback();
|
||||
assertThat(RuntimeMetaDataOverlay.getGeneration(instance)).as("rolled back: the overlay never changed").isEqualTo(generation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static CollaborationSetupStatus.Item item(List<CollaborationSetupStatus.Item> items, String feature)
|
||||
{
|
||||
return (items.stream().filter(item -> feature.equals(item.feature())).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RecordFollowers;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.RuntimeMetaDataOverlay;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.FollowEvent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFollower;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReference;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the installed provider through core's public event-subscriber facade.
|
||||
*******************************************************************************/
|
||||
class FollowersProviderTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preferences, mutes and parent scope apply even for callers without access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void resolvesEventSubscribersAsSystemWithoutFilteringRecipientAccess() throws Exception
|
||||
{
|
||||
seed("change", "document", "1", true, false, null, false);
|
||||
seed("comment", "document", "1", false, true, null, false);
|
||||
seed("both", "document", "1", true, true, Instant.now().minusSeconds(60), false);
|
||||
seed("muted", "document", "1", true, true, Instant.now().plusSeconds(3600), false);
|
||||
seed("neither", "document", "1", false, false, null, false);
|
||||
seed("other-record", "document", "2", true, true, null, false);
|
||||
seed("other-table", "other", "1", true, true, null, false);
|
||||
seed("deleted", "document", "1", true, true, null, true);
|
||||
RecordReference reference = new RecordReference("document", 1);
|
||||
for(QSession session : List.of(new QSession(), new QSystemUserSession(instance)))
|
||||
{
|
||||
QContext.init(instance, session);
|
||||
for(FollowEvent event : List.of(FollowEvent.ALERT, FollowEvent.CHANGE))
|
||||
{
|
||||
assertThat(RecordFollowers.get(instance, reference, event)).extracting(RecordFollower::getUserId)
|
||||
.containsExactlyInAnyOrder("change", "both");
|
||||
}
|
||||
assertThat(RecordFollowers.get(instance, reference, FollowEvent.COMMENT)).extracting(RecordFollower::getUserId)
|
||||
.containsExactlyInAnyOrder("comment", "both");
|
||||
assertThat(RecordFollowers.get(instance, reference, FollowEvent.CHANGE)).filteredOn(f -> f.getUserId().equals("change"))
|
||||
.singleElement().satisfies(f ->
|
||||
{
|
||||
assertThat(f.getNotifyOnChange()).isTrue();
|
||||
assertThat(f.getNotifyOnComment()).isFalse();
|
||||
assertThat(f.getReason()).isEqualTo("MANUAL");
|
||||
});
|
||||
assertThat(QContext.getQSession()).isSameAs(session);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing subscriptions do not override the host's FOLLOW feature switch.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void disabledFollowReturnsEmpty() throws Exception
|
||||
{
|
||||
seed("follower", "document", "1", true, true, null, false);
|
||||
RecordFeaturesMetaData.ofOrWithNew(instance.getTable("document")).withEnabled(RecordFeature.FOLLOW, false);
|
||||
RuntimeMetaDataOverlay.refresh(instance);
|
||||
for(FollowEvent event : FollowEvent.values())
|
||||
{
|
||||
assertThat(RecordFollowers.get(instance, new RecordReference("document", 1), event)).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seed persisted preferences directly, including subscriptions lacking access.
|
||||
*******************************************************************************/
|
||||
private void seed(String user, String table, String id, Boolean change, Boolean comment, Instant muted, Boolean deleted) throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().insert(new InsertInput(CollaborationTables.FOLLOW).withRecords(List.of(new QRecord()
|
||||
.withValue("userId", user).withValue("tableName", table).withValue("recordId", id).withValue("reason", "MANUAL")
|
||||
.withValue("notifyOnChange", change).withValue("notifyOnComment", comment).withValue("mutedUntil", muted)
|
||||
.withValue("parentDeleted", deleted))), true);
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ package com.kingsrook.qbits.collaboration;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.collaboration.api.CollaborationPresenceSchemas;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
|
||||
import com.kingsrook.qbits.collaboration.presence.PresenceService;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
@@ -36,6 +38,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.realtime.InMemoryRealtimeBus;
|
||||
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -61,7 +64,7 @@ class PresenceTest extends PersonalServiceFixture
|
||||
assertThat(second.snapshot("document", "1")).hasSize(1);
|
||||
clock.advance(1000);
|
||||
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
|
||||
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
|
||||
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title", 2));
|
||||
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
|
||||
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
|
||||
clock.advance(20000);
|
||||
@@ -87,6 +90,177 @@ class PresenceTest extends PersonalServiceFixture
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The last leave still sends viewers: the SSE writer and the HTTP snapshot must
|
||||
** both carry an empty array, never omit the field (#52).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lastLeaveSendsEmptyViewers() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("bob", "Bob", "one", "person", null, "VIEWING", null);
|
||||
registry.heartbeat("bob", "Bob", "one", "person", "7", "EDITING", "name");
|
||||
registry.leave("bob", "one", "person", null);
|
||||
registry.leave("bob", "one", "person", "7");
|
||||
assertThat(registry.snapshot("person", null)).isEmpty();
|
||||
|
||||
for(String channel : List.of("table:person", "record:person:7"))
|
||||
{
|
||||
RealtimeMessage last = events.stream().filter(event -> event.getChannel().equals(channel) && event.getEvent().equals("presence.changed")).reduce((first, second) -> second).orElseThrow();
|
||||
assertThat(last.getData()).containsKey("viewers");
|
||||
assertThat((List<?>) last.getData().get("viewers")).isEmpty();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the realtime hub copies the data map and writes it with toJson, //
|
||||
// whose NON_EMPTY inclusion used to drop the empty viewers list //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
String json = JsonUtils.toJson(new LinkedHashMap<>(last.getData()));
|
||||
assertThat(JsonUtils.toJSONObject(json).getJSONArray("viewers").length()).isZero();
|
||||
}
|
||||
|
||||
String occupied = JsonUtils.toJson(events.stream().filter(event -> event.getChannel().equals("record:person:7")).findFirst().orElseThrow().getData());
|
||||
assertThat(JsonUtils.toJSONObject(occupied).getJSONArray("viewers").getJSONObject(0).getString("fieldName")).isEqualTo("name");
|
||||
|
||||
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, registry.snapshot("person", null));
|
||||
assertThat(JsonUtils.toJSONObject(JsonUtils.toJson(snapshot)).getJSONArray("viewers").length()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Two leases of one user merge into one viewer with connections = 2; per
|
||||
** connection they are two viewers with opaque ids, modes, fields and starts.
|
||||
** Counts keep counting users; connectionCounts counts leases (#54).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void perConnectionViewersAndCounts() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
|
||||
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
|
||||
clock.advance(1000);
|
||||
second.heartbeat("admin", "Admin", "terminal-2", "session-b", "person", "7", "EDITING", "name");
|
||||
first.heartbeat("bob", "Bob", "tab", "session-c", "person", "7", "VIEWING", null);
|
||||
|
||||
assertThat(first.snapshot("person", "7")).containsExactly(
|
||||
new PresenceRegistry.Viewer("admin", "Admin", "EDITING", "name", 2),
|
||||
new PresenceRegistry.Viewer("bob", "Bob", "VIEWING", null, 1));
|
||||
|
||||
List<PresenceRegistry.Viewer> connections = second.connections("person", "7");
|
||||
assertThat(connections).hasSize(3);
|
||||
assertThat(connections.subList(0, 2)).extracting(PresenceRegistry.Viewer::userId, PresenceRegistry.Viewer::mode, PresenceRegistry.Viewer::fieldName, PresenceRegistry.Viewer::connections)
|
||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("admin", "VIEWING", null, 1), org.assertj.core.groups.Tuple.tuple("admin", "EDITING", "name", 1));
|
||||
assertThat(connections.getFirst().connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("admin", "terminal-1")).startsWith("c").hasSize(17);
|
||||
assertThat(connections.getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(connections.get(1).since()).isEqualTo("2026-10-13T12:00:01Z");
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// renewals keep the lease start; the session uuid replicates //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
clock.advance(20000);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
|
||||
assertThat(second.connections("person", "7").getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(second.connections("person", "7")).extracting(PresenceRegistry.Viewer::sessionId)
|
||||
.containsExactly(PresenceRegistry.opaqueSessionId("session-a"), PresenceRegistry.opaqueSessionId("session-b"), PresenceRegistry.opaqueSessionId("session-c"));
|
||||
assertThat(PresenceRegistry.opaqueSessionId("session-a")).startsWith("s").hasSize(17).isNotEqualTo(PresenceRegistry.opaqueSessionId("session-b"));
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// table snapshots and counts: users vs leases //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
assertThat(first.snapshot("person", null)).extracting(PresenceRegistry.Viewer::connections).containsExactly(2, 1);
|
||||
assertThat(first.connections("person", null)).extracting(PresenceRegistry.Viewer::fieldName).containsOnlyNulls();
|
||||
assertThat(first.counts()).containsExactly(java.util.Map.entry("person", 2));
|
||||
assertThat(first.connectionCounts()).containsExactly(java.util.Map.entry("person", 3));
|
||||
|
||||
second.leave("admin", "terminal-2", "person", "7");
|
||||
assertThat(first.snapshot("person", "7").getFirst()).isEqualTo(new PresenceRegistry.Viewer("admin", "Admin", "VIEWING", null, 1));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Session uuids are bearer credentials: no bus message (internal replication
|
||||
** or client event) and no snapshot carries one, only the opaque session id.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rawSessionUuidIsNeverPublished() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
String sessionUuid = "7d1c2a9e-secret-session-uuid";
|
||||
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
|
||||
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "VIEWING", null);
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "EDITING", "name");
|
||||
first.leave("admin", "terminal-1", "person", "7");
|
||||
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", null, "VIEWING", null);
|
||||
assertThat(events).isNotEmpty().anySatisfy(event -> assertThat(event.getChannel()).isEqualTo(PresenceRegistry.INTERNAL_CHANNEL));
|
||||
for(RealtimeMessage event : events)
|
||||
{
|
||||
assertThat(JsonUtils.toJson(new LinkedHashMap<>(event.getData()))).doesNotContain(sessionUuid);
|
||||
}
|
||||
assertThat(events).filteredOn(event -> event.getChannel().equals(PresenceRegistry.INTERNAL_CHANNEL) && !Boolean.TRUE.equals(event.getData().get("leave")))
|
||||
.allSatisfy(event -> assertThat(event.getData().get("sessionId")).isEqualTo(PresenceRegistry.opaqueSessionId(sessionUuid)));
|
||||
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, second.snapshot("person", null), 1, second.connections("person", null));
|
||||
String json = JsonUtils.toJson(snapshot);
|
||||
assertThat(json).doesNotContain(sessionUuid).contains(PresenceRegistry.opaqueSessionId(sessionUuid));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** presence.changed carries connections per merged viewer, a lease count and
|
||||
** one lease entry per connection; a second terminal of one user is a change.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void changedEventCarriesConnections() throws Exception
|
||||
{
|
||||
var clock = new MutableClock();
|
||||
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
|
||||
{
|
||||
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
|
||||
bus.subscribe(events::add);
|
||||
registry.heartbeat("admin", "Admin", "terminal-1", "person", "7", "VIEWING", null);
|
||||
int before = recordEvents(events).size();
|
||||
registry.heartbeat("admin", "Admin", "terminal-2", "person", "7", "VIEWING", null);
|
||||
List<RealtimeMessage> after = recordEvents(events);
|
||||
assertThat(after).hasSize(before + 1);
|
||||
|
||||
var json = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(after.getLast().getData())));
|
||||
assertThat(json.getJSONArray("viewers").length()).isEqualTo(1);
|
||||
assertThat(json.getJSONArray("viewers").getJSONObject(0).getInt("connections")).isEqualTo(2);
|
||||
assertThat(json.getJSONArray("viewers").getJSONObject(0).has("connectionId")).isFalse();
|
||||
assertThat(json.getInt("leaseCount")).isEqualTo(2);
|
||||
assertThat(json.getJSONArray("leases").length()).isEqualTo(2);
|
||||
var lease = json.getJSONArray("leases").getJSONObject(1);
|
||||
assertThat(lease.getString("userId")).isEqualTo("admin");
|
||||
assertThat(lease.getString("mode")).isEqualTo("VIEWING");
|
||||
assertThat(lease.getInt("connections")).isEqualTo(1);
|
||||
assertThat(lease.getString("connectionId")).startsWith("c");
|
||||
assertThat(lease.getString("since")).isEqualTo("2026-10-13T12:00:00Z");
|
||||
assertThat(json.toString()).doesNotContain("terminal-");
|
||||
|
||||
registry.leave("admin", "terminal-1", "person", "7");
|
||||
registry.leave("admin", "terminal-2", "person", "7");
|
||||
var last = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(recordEvents(events).getLast().getData())));
|
||||
assertThat(last.getInt("leaseCount")).isZero();
|
||||
assertThat(last.getJSONArray("leases").length()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record-channel presence.changed events so far.
|
||||
*******************************************************************************/
|
||||
private static List<RealtimeMessage> recordEvents(List<RealtimeMessage> events)
|
||||
{
|
||||
return (events.stream().filter(event -> event.getChannel().equals("record:person:7") && event.getEvent().equals("presence.changed")).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** At capacity, existing leases renew while new targets are refused on both pods.
|
||||
*******************************************************************************/
|
||||
@@ -117,7 +291,15 @@ class PresenceTest extends PersonalServiceFixture
|
||||
void accessFeatureAndSnapshot() throws Exception
|
||||
{
|
||||
var service = new PresenceService();
|
||||
QContext.getQSession().setUuid("session-uuid-alice");
|
||||
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
|
||||
assertThat(service.connections("document", "1")).singleElement().satisfies(viewer ->
|
||||
{
|
||||
assertThat(viewer.connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("alice", "one"));
|
||||
assertThat(viewer.sessionId()).isEqualTo(PresenceRegistry.opaqueSessionId("session-uuid-alice"));
|
||||
assertThat(JsonUtils.toJson(new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("document", "1", service.snapshot("document", "1"), 1, service.connections("document", "1"))))
|
||||
.doesNotContain("session-uuid-alice");
|
||||
});
|
||||
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
|
||||
assertThat(service.snapshot("document", null)).hasSize(1);
|
||||
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
|
||||
@@ -137,6 +319,7 @@ class PresenceTest extends PersonalServiceFixture
|
||||
admin();
|
||||
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
|
||||
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
|
||||
assertThat(CollaborationHome.presence().getRows().getFirst().get("connections")).isEqualTo(1);
|
||||
login("alice");
|
||||
assertThat(CollaborationHome.presence().getRows()).isEmpty();
|
||||
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.comments.CommentsService;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotification;
|
||||
import com.kingsrook.qbits.collaboration.notify.CollaborationNotifier;
|
||||
import com.kingsrook.qbits.collaboration.notify.RecordingNotifier;
|
||||
import com.kingsrook.qbits.collaboration.recents.RecentsService;
|
||||
import com.kingsrook.qbits.collaboration.sharing.SharingService;
|
||||
import com.kingsrook.qqq.backend.core.actions.collaboration.UserSessionProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached and delivered labels respect the session that will receive the data.
|
||||
*******************************************************************************/
|
||||
class SecuredLabelsTest extends PersonalServiceFixture
|
||||
{
|
||||
private static RecordingNotifier recorder;
|
||||
|
||||
/*******************************************************************************
|
||||
** Non-FULL labels are never cached; lists always re-read as the current viewer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cachesOnlyFullAccessLabelsAndRefreshesForViewer() throws Exception
|
||||
{
|
||||
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY, FieldSecurityLock.Behavior.REVEAL))
|
||||
{
|
||||
restrictLabel(behavior);
|
||||
FavoritesService.star("RECORD", "document", 1);
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
|
||||
{
|
||||
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("label")).isNull());
|
||||
}
|
||||
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
FavoritesService.unstar("RECORD", "document", 1);
|
||||
PersonalRecords.delete(CollaborationTables.RECENT, PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()).stream().map(row -> row.getValue("id")).toList());
|
||||
}
|
||||
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
|
||||
FavoritesService.star("RECORD", "document", 1);
|
||||
RecentsService.recordView("RECORD", "document", 1);
|
||||
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
|
||||
{
|
||||
assertThat(PersonalRecords.query(table, PersonalRecords.ownFilter())).singleElement()
|
||||
.satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("First document"));
|
||||
}
|
||||
login("alice");
|
||||
assertThat(FavoritesService.list()).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
assertThat(RecentsService.list(50)).singleElement().satisfies(entry -> assertThat(entry.label()).doesNotContain("First document"));
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
assertThat(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter("TABLE", "document", null)))
|
||||
.singleElement().satisfies(row -> assertThat(row.getValueString("label")).isEqualTo("Documents"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The sender's FULL label cannot leak to recipients with MASKED or HIDDEN access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notificationTitlesResolveLabelsPerRecipient() throws Exception
|
||||
{
|
||||
for(FieldSecurityLock.Behavior behavior : List.of(FieldSecurityLock.Behavior.MASK, FieldSecurityLock.Behavior.DENY))
|
||||
{
|
||||
restrictLabel(behavior);
|
||||
QContext.getQSession().withSecurityKeyValue("sensitive", "full");
|
||||
recorder = new RecordingNotifier();
|
||||
config.withNotifier(new QCodeReference(TestNotifier.class)).withUserSessionProvider(new QCodeReference(TestSessions.class));
|
||||
new SharingService().share("document", 1, List.of("restricted", "full"), "Shared user content");
|
||||
new CommentsService().post("document", 1, "@[Restricted](user:restricted) @[Full](user:full)", null, null);
|
||||
assertThat(recorder.notifications()).hasSize(4);
|
||||
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("restricted")).hasSize(2)
|
||||
.allSatisfy(n -> assertThat(n.title()).contains("Documents #1").doesNotContain("First document"));
|
||||
assertThat(recorder.notifications()).filteredOn(n -> n.recipientUserId().equals("full")).hasSize(2)
|
||||
.allSatisfy(n -> assertThat(n.title()).contains("First document"));
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo("alice");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field-lock override grants FULL to selected sessions only.
|
||||
*******************************************************************************/
|
||||
private void restrictLabel(FieldSecurityLock.Behavior behavior)
|
||||
{
|
||||
if(!instance.getSecurityKeyTypes().containsKey("sensitive"))
|
||||
{
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
}
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(behavior).withOverrideValues(List.of("full")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Recipients share record access but have different label access.
|
||||
*******************************************************************************/
|
||||
public static class TestSessions implements UserSessionProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build independent recipient sessions for real core field checks.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> sessionForUser(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId)).withPermissions("document.read")
|
||||
.withSecurityKeyValue("tenant", "a");
|
||||
if(userId.equals("full"))
|
||||
{
|
||||
session.withSecurityKeyValue("sensitive", "full");
|
||||
}
|
||||
return (Optional.of(session));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture notifications before any external transport.
|
||||
*******************************************************************************/
|
||||
public static class TestNotifier implements CollaborationNotifier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve the payload for assertions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void notify(CollaborationNotification notification)
|
||||
{
|
||||
recorder.notify(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import com.kingsrook.qbits.collaboration.timeline.TimelinePage;
|
||||
import com.kingsrook.qbits.collaboration.timeline.TimelineService;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.personalization.TableMetaDataPersonalizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldMaskStyle;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.FieldValueMasker;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.FieldSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.personalization.TableMetaDataPersonalizerInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
@@ -182,6 +186,40 @@ public class TimelineServiceTest extends PersonalServiceFixture
|
||||
assertThat(timeline.timeline("document", 1, null, 50, Set.of("AUDIT")).items()).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.7 QueryAction masks audit values and removes HIDDEN detail rows as the viewer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fieldTiersProtectTimelineValuesAndMessages() throws Exception
|
||||
{
|
||||
String canary = "TIMELINE-SECRET-CANARY";
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("sensitive"));
|
||||
instance.getTable("document").getField("title").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.MASK).withMaskStyle(FieldMaskStyle.FULL));
|
||||
instance.getTable("document").getField("tenant").withFieldSecurityLock(new FieldSecurityLock()
|
||||
.withSecurityKeyType("sensitive").withDefaultBehavior(FieldSecurityLock.Behavior.DENY));
|
||||
seedAudit(4, canary, 100);
|
||||
for(String field : List.of("title", "tenant"))
|
||||
{
|
||||
seed("auditDetail", new QRecord().withValue("id", field.equals("title") ? 10L : 11L).withValue("auditId", 4L)
|
||||
.withValue("fieldName", field).withValue("oldValue", canary).withValue("newValue", canary).withValue("message", canary));
|
||||
}
|
||||
TimelinePage page = timeline.timeline("document", 1, null, 50, Set.of("AUDIT"));
|
||||
assertThat(page.items()).filteredOn(item -> item.id().equals(4L)).singleElement().satisfies(item ->
|
||||
{
|
||||
assertThat(item.summary()).isNull();
|
||||
assertThat(item.details()).singleElement().satisfies(detail ->
|
||||
{
|
||||
assertThat(detail.fieldName()).isEqualTo("title");
|
||||
assertThat(detail.oldValue()).isEqualTo(FieldValueMasker.MASK);
|
||||
assertThat(detail.newValue()).isEqualTo(FieldValueMasker.MASK);
|
||||
assertThat(detail.message()).isNull();
|
||||
});
|
||||
});
|
||||
assertThat(page.items().stream().flatMap(item -> item.details().stream())).noneMatch(detail -> "tenant".equals(detail.fieldName()));
|
||||
assertThat(page.toString()).doesNotContain(canary, "Old", "New", "sensitive old", "sensitive new");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real personalizer boundary, like core's audit screen.
|
||||
*******************************************************************************/
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
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.QueryJoin;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Actual native Count permission enforcement and metadata, plus aggregate input
|
||||
** guard semantics. Record Files separately tests archived native parent rows.
|
||||
*******************************************************************************/
|
||||
public class AttachmentAggregateGuardTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Ordinary Collaboration keeps its established count and metadata contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultPolicyPreservesOrdinaryCount() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isZero();
|
||||
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getDisabledCapabilities()).doesNotContain(Capability.TABLE_COUNT);
|
||||
assertThat(new AttachmentLifecyclePolicy().requiresPinnedAggregateParent()).isFalse();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A readable literal pin is accepted; broad or invisible parents are rejected.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void nativeCountRequiresCurrentlyVisiblePinnedParent() throws Exception
|
||||
{
|
||||
enable();
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("3")))).isInstanceOf(QPermissionDeniedException.class);
|
||||
var guard = new AttachmentAggregateGuard();
|
||||
assertThatThrownBy(() -> guard.checkPermissionsThrowing(new AggregateInput(CollaborationTables.ATTACHMENT), instance.getTable(CollaborationTables.ATTACHMENT)))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
guard.checkPermissionsThrowing(new AggregateInput(CollaborationTables.ATTACHMENT).withFilter(pin("1")), instance.getTable(CollaborationTables.ATTACHMENT));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** OR, set branches, conflicting pins and joins cannot escape the exact parent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsFilterShapesWhichDoNotGuaranteeOneParent() throws Exception
|
||||
{
|
||||
enable();
|
||||
for(QQueryFilter filter : java.util.List.of(pin("1").withBooleanOperator(QQueryFilter.BooleanOperator.OR),
|
||||
pin("1").withSubFilterSetOperator(QQueryFilter.SubFilterSetOperator.UNION),
|
||||
pin("1").withSubFilter(new QQueryFilter().withSubFilterSetOperator(QQueryFilter.SubFilterSetOperator.UNION_ALL)),
|
||||
pin("1").withCriteria("recordId", QCriteriaOperator.EQUALS, "2"),
|
||||
new QQueryFilter().withSubFilter(pin("1")), new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, "document")))
|
||||
{
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(filter))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1")).withQueryJoin(new QueryJoin())))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Generic list clients see no unscoped total capability in instance metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataHidesGenericTotalsWithoutDisablingPinnedNativeCount() throws Exception
|
||||
{
|
||||
enable();
|
||||
var table = new MetaDataAction().execute(new MetaDataInput()).getTables().get(CollaborationTables.ATTACHMENT);
|
||||
assertThat(table.getCapabilities()).doesNotContain("TABLE_COUNT", "QUERY_STATS").contains("TABLE_QUERY");
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture policy is installed before count actions and reflected in metadata.
|
||||
*******************************************************************************/
|
||||
private void enable()
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
config.withAttachmentLifecycle(new QCodeReference(PinnedPolicy.class));
|
||||
AttachmentAggregateGuard.configure(instance.getTable(CollaborationTables.ATTACHMENT), config);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing the runtime lifecycle cannot broaden a previously installed table.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void installedRestrictionSurvivesPolicyRemovalAndMetadataClone() throws Exception
|
||||
{
|
||||
enable();
|
||||
var clone = instance.getTable(CollaborationTables.ATTACHMENT).clone();
|
||||
config.withAttachmentLifecycle(new QCodeReference(AttachmentLifecyclePolicy.class));
|
||||
instance.getTables().put(CollaborationTables.ATTACHMENT, AttachmentAggregateGuard.configure(clone, config));
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
|
||||
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getDisabledCapabilities()).contains(Capability.TABLE_COUNT, Capability.QUERY_STATS);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh default metadata still detects retained rows of invisible parents.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void persistedRetentionRequiresPinsWithoutAnInstalledPolicyMarker() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(CollaborationTables.ATTACHMENT)
|
||||
.withRecords(java.util.List.of(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("tableName", "document")
|
||||
.withValue("recordId", "3").withValue("retained", true).withValue("fileName", "retained.txt")
|
||||
.withValue("contentType", "text/plain").withValue("sizeBytes", 1L).withValue("sha256", "a".repeat(64))
|
||||
.withValue("bucketName", "fixture").withValue("storageKey", "fixture-retained").withValue("createdByUserId", "alice")));
|
||||
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
|
||||
input.withPermissionBypass("Test fixture seeds retained owner metadata without an installed lifecycle.");
|
||||
assertThat(new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(input).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact equality on both canonical reference fields is required.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter pin(String id)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, "document").withCriteria("recordId", QCriteriaOperator.EQUALS, id));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An ordinary generic owner can opt in without depending on Record Files.
|
||||
*******************************************************************************/
|
||||
public static class PinnedPolicy extends AttachmentLifecyclePolicy
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Constant metadata capability policy, no request context needed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean requiresPinnedAggregateParent()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/*******************************************************************************
|
||||
** Declared media types are client-controlled; only passive formats may be
|
||||
** served inline, everything else is an opaque attachment.
|
||||
*******************************************************************************/
|
||||
class AttachmentMediaPolicyTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Active, malformed and missing declarations are opaque attachments.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void activeMalformedAndMissingTypesAreOpaqueAttachments()
|
||||
{
|
||||
for(String declared : new String[] { "text/html", "TEXT/HTML; charset=utf-8", "image/svg+xml", "application/xhtml+xml", "text/xml", "application/xml",
|
||||
"text/javascript", "application/javascript", "weird", "", " ", "a/b/c", "text/html\r\nX-Injected: 1", null })
|
||||
{
|
||||
assertThat(AttachmentMediaPolicy.storedType(declared)).as(String.valueOf(declared)).isEqualTo("application/octet-stream");
|
||||
assertThat(AttachmentMediaPolicy.disposition(declared)).as(String.valueOf(declared)).isEqualTo("attachment");
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Passive raster, PDF, text and media types keep their normalized type inline.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void passiveTypesAreNormalizedAndInline()
|
||||
{
|
||||
assertThat(AttachmentMediaPolicy.storedType("IMAGE/PNG; q=1")).isEqualTo("image/png");
|
||||
assertThat(AttachmentMediaPolicy.disposition("IMAGE/PNG; q=1")).isEqualTo("inline");
|
||||
for(String declared : new String[] { "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain", "audio/mpeg", "video/mp4" })
|
||||
{
|
||||
assertThat(AttachmentMediaPolicy.storedType(declared)).isEqualTo(declared);
|
||||
assertThat(AttachmentMediaPolicy.disposition(declared)).isEqualTo("inline");
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.sql.DriverManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.MemoryUploadJournal;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSQueryAction;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 native H2 attachment queries; host permissions remain the fixture's
|
||||
** actual memory-backed parent policy. No content is stored or signed here.
|
||||
*******************************************************************************/
|
||||
class AttachmentPaginationJdbcTest extends PersonalServiceFixture
|
||||
{
|
||||
private AttachmentsService service;
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain the complete owner table shape, including required fields. Observe
|
||||
** the bound at the native backend and execute the real RDBMS query unchanged.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void database() throws Exception
|
||||
{
|
||||
ConnectionManager.closeConnectionProviders();
|
||||
var backend = new RDBMSBackendMetaData().withName("page_database").withVendor("h2").withUsername("sa")
|
||||
.withJdbcUrl("jdbc:h2:mem:attachment_pages_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE");
|
||||
QBackendModuleDispatcher.registerBackendModule(new RecordingBackend());
|
||||
backend.setBackendType(RecordingBackend.class);
|
||||
instance.addBackend(backend);
|
||||
var table = instance.getTable(CollaborationTables.ATTACHMENT);
|
||||
table.setBackendName(backend.getName());
|
||||
try(var connection = DriverManager.getConnection(backend.getJdbcUrl(), "sa", ""); var statement = connection.createStatement())
|
||||
{
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
String type = switch(field.getType())
|
||||
{
|
||||
case INTEGER -> "INTEGER";
|
||||
case LONG -> "BIGINT";
|
||||
case BOOLEAN -> "BOOLEAN";
|
||||
case DATE_TIME -> "TIMESTAMP";
|
||||
case STRING, TEXT -> "VARCHAR(4096)";
|
||||
default -> throw new IllegalStateException("Unhandled fixture field " + field.getName());
|
||||
};
|
||||
columns.add(field.getBackendName() + " " + type + (field.getName().equals("id") ? " PRIMARY KEY" : "")
|
||||
+ (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
}
|
||||
statement.execute("CREATE TABLE collaboration_attachment (" + String.join(",", columns) + ")");
|
||||
for(int id = 1; id <= 8; id++)
|
||||
{
|
||||
statement.execute("INSERT INTO collaboration_attachment (id,table_name,record_id,file_name,content_type,size_bytes,sha256,bucket_name,storage_key,created_by_user_id,parent_deleted,retained,create_date) VALUES ("
|
||||
+ id + ",'document','" + (id == 8 ? "2" : "1") + "','file-" + id + "','text/plain',1,'" + "a".repeat(64)
|
||||
+ "','test','key-" + id + "','alice'," + (id == 7) + ",TRUE,TIMESTAMP '2026-10-31 00:00:00')");
|
||||
}
|
||||
}
|
||||
service = new AttachmentsService(config, new AttachmentStorage(), new AttachmentLifecyclePolicy(), new MemoryUploadJournal());
|
||||
RecordingBackend.bounds.clear();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Close only fixture native providers after the query assertions.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void closeDatabase()
|
||||
{
|
||||
ConnectionManager.closeConnectionProviders();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL paging preserves deterministic ties, exact source scope and retained
|
||||
** metadata while returning only the requested page plus its lookahead.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundedSqlPagesKeepTiesSourceIdentityAndRetainedMetadata() throws Exception
|
||||
{
|
||||
var first = service.listPage("document", "01", 0, 2);
|
||||
assertThat(first.items()).extracting(AttachmentEntry::id).containsExactly(6, 5);
|
||||
assertThat(first.hasMore()).isTrue();
|
||||
assertThat(first.items()).allSatisfy(item -> assertThat(item.canDelete()).isFalse());
|
||||
var second = service.listPage("document", 1, 2, 2);
|
||||
assertThat(second.items()).extracting(AttachmentEntry::id).containsExactly(4, 3);
|
||||
var last = service.listPage("document", 1, 4, 2);
|
||||
assertThat(last.items()).extracting(AttachmentEntry::id).containsExactly(2, 1);
|
||||
assertThat(last.hasMore()).isFalse();
|
||||
assertThat(service.listPage("document", 1, 6, 2).items()).isEmpty();
|
||||
assertThat(RecordingBackend.bounds).containsExactly("0:3:3", "2:3:3", "4:3:2", "6:3:0");
|
||||
assertThat(service.listPage("document", 2, 0, 100).items()).extracting(AttachmentEntry::id).containsExactly(8);
|
||||
assertThat(RecordingBackend.bounds.getLast()).isEqualTo("0:101:1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Current parent revocation and invalid bounds fail before attachment SQL.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void revocationAndInvalidBoundsNeverReachAttachmentQuery() throws Exception
|
||||
{
|
||||
service.listPage("document", 1, 0, 1);
|
||||
RecordingBackend.bounds.clear();
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
|
||||
assertThatThrownBy(() -> service.listPage("document", 1, 1, 1)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> service.listPage("document", 1, -1, 1)).hasMessageContaining("Offset");
|
||||
assertThatThrownBy(() -> service.listPage("document", 1, 0, 101)).hasMessageContaining("limit");
|
||||
assertThat(RecordingBackend.bounds).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Instrument only attachment query inputs/results; delegate actual SQL to QQQ.
|
||||
*******************************************************************************/
|
||||
public static class RecordingBackend extends RDBMSBackendModule
|
||||
{
|
||||
static final List<String> bounds = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Isolated dispatcher name; never replace the production RDBMS module.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getBackendType()
|
||||
{
|
||||
return ("attachment_pagination_test");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real RDBMS execution must return no more than the requested lookahead.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QueryInterface getQueryInterface()
|
||||
{
|
||||
return (input ->
|
||||
{
|
||||
var output = new RDBMSQueryAction().execute(input);
|
||||
bounds.add(input.getFilter().getSkip() + ":" + input.getFilter().getLimit() + ":" + output.getRecords().size());
|
||||
return (output);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+1267
File diff suppressed because it is too large.
Load diff
+863
-7
@@ -32,16 +32,23 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.HomeWidgetData;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.MemoryUploadJournal;
|
||||
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.ObjectStream;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectHeaders;
|
||||
import com.kingsrook.qbits.objectstorage.api.StoredObject;
|
||||
import com.kingsrook.qbits.objectstorage.api.StorageException;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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;
|
||||
@@ -87,7 +94,9 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
private static AttachmentsService processService;
|
||||
private FakeStorage storage;
|
||||
private MemoryUploadJournal journal;
|
||||
private AttachmentsService service;
|
||||
private TestLifecycle lifecycle;
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker, network, object-storage grants or collaboration.view needed.
|
||||
@@ -95,13 +104,585 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
@BeforeEach
|
||||
void attachments()
|
||||
{
|
||||
lifecycle = null;
|
||||
storage = new FakeStorage();
|
||||
service = new AttachmentsService(config, storage);
|
||||
journal = new MemoryUploadJournal();
|
||||
service = memoryService();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard downloads retain lifecycle checks and the authenticated descriptor.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void authenticatedDeliveryPreservesPolicyAndExactUrl() throws Exception
|
||||
{
|
||||
AttachmentEntry file = upload("download.txt", "original");
|
||||
java.util.concurrent.atomic.AtomicBoolean deny = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
URL destination = URI.create("https://app.example/qqq/v1/qbit/files/exact").toURL();
|
||||
AttachmentLifecyclePolicy policy = new AttachmentLifecyclePolicy()
|
||||
{
|
||||
/***********************************************************************
|
||||
** Policy refusal must happen before a delivery is issued.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void beforeAccess(QRecord row, boolean inline) throws QException
|
||||
{
|
||||
if(deny.get())
|
||||
{
|
||||
throw new QException("Denied by lifecycle");
|
||||
}
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
** No storage signing or alternate byte owner is needed.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public AttachmentDownload downloadDelivery(QRecord row)
|
||||
{
|
||||
assertThat(row.getValueInteger("id")).isEqualTo(file.id());
|
||||
return (new AttachmentDownload(destination, true));
|
||||
}
|
||||
};
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
assertThat(service.downloadDelivery(file.id())).isEqualTo(new AttachmentDownload(destination, true));
|
||||
assertThat(service.downloadUrl(file.id(), false)).isEqualTo(destination);
|
||||
assertThat(service.downloadDelivery(file.id(), true)).isEqualTo(new AttachmentDownload(destination, true));
|
||||
assertThat(service.downloadUrl(file.id(), true)).isEqualTo(destination);
|
||||
AttachmentDownload signed = new AttachmentDownload(URI.create("https://storage.example/file?signature=secret").toURL(), false);
|
||||
assertThat(signed.toString()).doesNotContain("secret", "signature", "storage.example");
|
||||
for(String invalid : java.util.List.of("https://user:password@storage.example/file", "https://storage.example/file#fragment", "http:/missing-host"))
|
||||
{
|
||||
assertThatThrownBy(() -> new AttachmentDownload(URI.create(invalid).toURL(), false)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
deny.set(true);
|
||||
assertThatThrownBy(() -> service.downloadDelivery(file.id())).hasMessageContaining("Denied by lifecycle");
|
||||
assertThatThrownBy(() -> new AttachmentDownload(URI.create("file:///tmp/bytes").toURL(), true)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention protects mixed bulk deletion without preventing metadata edits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedFilesAreNotDeletable() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
AttachmentEntry ordinary = upload("ordinary.txt", "first");
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry retained = upload("retained.txt", "second");
|
||||
lifecycle.retainedId = retained.id();
|
||||
assertThat(retained.canDelete()).isFalse();
|
||||
assertThat(retained.canEdit()).isTrue();
|
||||
assertThatThrownBy(() -> service.delete(List.of(ordinary.id(), retained.id()))).hasMessageContaining("retained");
|
||||
assertThat(storage.objects).hasSize(2);
|
||||
assertThatThrownBy(() -> service.requireDeletable(retained.id())).hasMessageContaining("retained");
|
||||
assertThatThrownBy(() -> service.deleteStoredRow(raw(retained.id()))).hasMessageContaining("retained");
|
||||
assertThat(service.rename(retained.id(), "renamed.txt", null).fileName()).isEqualTo("renamed.txt");
|
||||
assertThat(lifecycle.metadataCalls).isEqualTo(1);
|
||||
lifecycle.metadataFails = true;
|
||||
assertThatThrownBy(() -> service.rename(retained.id(), "denied.txt", null)).hasMessageContaining("Metadata audit unavailable");
|
||||
assertThat(raw(retained.id()).getValueString("fileName")).isEqualTo("renamed.txt");
|
||||
lifecycle.retentionFails = true;
|
||||
assertThatThrownBy(() -> service.delete(List.of(ordinary.id()))).hasMessageContaining("Retention unavailable");
|
||||
assertThat(storage.objects).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated cleanup advances past retained rows and never removes their bytes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedParentCleanupCrossesPageBoundary() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
for(int i = 0; i < 205; i++)
|
||||
{
|
||||
upload("photo-" + i + ".jpg", "original");
|
||||
}
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
AttachmentCleanup cleanup = new AttachmentCleanup(service);
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter());
|
||||
assertThat(rows).hasSize(205).allSatisfy(row -> assertThat(row.getValueBoolean("parentDeleted")).isTrue());
|
||||
assertThat(storage.objects).hasSize(205);
|
||||
assertThatThrownBy(() -> service.downloadUrl(rows.getFirst().getValue("id"), false)).hasMessage("Not found");
|
||||
assertThat(lifecycle.accesses).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both standard preview fields and explicit downloads pass the same policy.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void accessPolicyIsStrictAndRunsAfterAuthorization() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
AttachmentEntry uploaded = upload("audit.txt", "original");
|
||||
assertThat(lifecycle.storedCalls).isEqualTo(1);
|
||||
service.downloadUrl(uploaded.id(), false);
|
||||
new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service);
|
||||
assertThat(lifecycle.accesses).containsExactly(false, true, false);
|
||||
assertThat(storage.signatures).hasSize(3);
|
||||
lifecycle.accessFails = true;
|
||||
assertThatThrownBy(() -> service.downloadUrl(uploaded.id(), false)).hasMessageContaining("Access audit unavailable");
|
||||
assertThatThrownBy(() -> new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service))
|
||||
.hasMessageContaining("Access audit unavailable");
|
||||
assertThat(storage.signatures).hasSize(3);
|
||||
lifecycle.accessRuntimeFails = true;
|
||||
assertThatThrownBy(() -> new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service))
|
||||
.hasMessageContaining("Attachment access policy failed");
|
||||
assertThat(storage.signatures).hasSize(3);
|
||||
lifecycle.accesses.clear();
|
||||
QContext.getQSession().getSecurityKeyValues().remove("tenant");
|
||||
assertThatThrownBy(() -> service.downloadUrl(uploaded.id(), false)).hasMessage("Not found");
|
||||
assertThat(new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service)).isEmpty();
|
||||
assertThat(lifecycle.accesses).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A policy registration failure preserves the original for reconciliation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storedPolicyFailureDoesNotDeleteOriginal() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.storedFails = true;
|
||||
assertThatThrownBy(() -> upload("preserved.txt", "original")).hasMessageContaining("Registration unavailable");
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration removal cannot turn a retained original into a deletable file.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void persistedRetentionSurvivesProviderRemoval() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("retained.txt", "original");
|
||||
assertThat(raw(file.id()).getValueBoolean("retained")).isTrue();
|
||||
config.withAttachmentLifecycle(new QCodeReference(AttachmentLifecyclePolicy.class));
|
||||
service = memoryService();
|
||||
assertThat(service.list("document", 1).getFirst().canDelete()).isFalse();
|
||||
assertThatThrownBy(() -> service.delete(List.of(file.id()))).hasMessageContaining("retained");
|
||||
assertThatThrownBy(() -> service.downloadUrl(file.id(), false)).hasMessageContaining("requires its lifecycle policy");
|
||||
assertThat(storage.signatures).isEmpty();
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
new AttachmentCleanup(service).onParentsDeleted("document", List.of(1));
|
||||
assertThat(raw(file.id()).getValueBoolean("parentDeleted")).isTrue();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Failed intake classification never writes object bytes or attachment rows.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unavailableRetentionPolicyDeniesIntake() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retentionFails = true;
|
||||
assertThatThrownBy(() -> upload("blocked.txt", "bytes")).hasMessageContaining("Retention unavailable");
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core validation reads authorize rows without generating presentation URLs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void internalReadsDoNotIssueUrlsButDisplayReadsDo() throws Exception
|
||||
{
|
||||
CountingLifecycle.accessCalls = 0;
|
||||
PreviewStorage.signatureCalls = 0;
|
||||
config.withAttachmentLifecycle(new QCodeReference(CountingLifecycle.class))
|
||||
.withAttachmentStorage(new QCodeReference(PreviewStorage.class));
|
||||
service = memoryService();
|
||||
AttachmentEntry file = upload("presentation.txt", "original");
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
QRecord internal = new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())).getRecord();
|
||||
assertThat(internal.getValue("previewUrl")).isNull();
|
||||
assertThat(internal.getValue("downloadUrl")).isNull();
|
||||
assertThat(internal.getValue("previewDelivery")).isNull();
|
||||
assertThat(internal.getValue("downloadDelivery")).isNull();
|
||||
QRecord stale = new QRecord(internal).withValue("previewDelivery", "AUTHENTICATED_FETCH").withValue("downloadDelivery", "SIGNED_URL");
|
||||
QRecord cleared = new AttachmentPreviewCustomizer().filter(List.of(stale), true, false, service).getFirst();
|
||||
assertThat(cleared.getValue("previewDelivery")).isNull();
|
||||
assertThat(cleared.getValue("downloadDelivery")).isNull();
|
||||
service.rename(file.id(), "renamed.txt", null);
|
||||
assertThat(CountingLifecycle.accessCalls).isZero();
|
||||
assertThat(PreviewStorage.signatureCalls).isZero();
|
||||
QRecord displayed = new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())
|
||||
.withShouldGenerateDisplayValues(true)).getRecord();
|
||||
assertThat(displayed.getValueString("previewUrl")).startsWith("https://signed.example/");
|
||||
assertThat(displayed.getValueString("downloadUrl")).startsWith("https://signed.example/");
|
||||
assertThat(displayed.getValueString("previewDelivery")).isEqualTo("SIGNED_URL");
|
||||
assertThat(displayed.getValueString("downloadDelivery")).isEqualTo("SIGNED_URL");
|
||||
new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT).withShouldGenerateDisplayValues(true));
|
||||
assertThat(CountingLifecycle.accessCalls).isEqualTo(4);
|
||||
assertThat(PreviewStorage.signatureCalls).isEqualTo(4);
|
||||
QContext.getQSession().getSecurityKeyValues().remove("tenant");
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())).getRecord()).isNull();
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT)).getRecords()).isEmpty();
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT).withShouldGenerateDisplayValues(true)).getRecords()).isEmpty();
|
||||
assertThat(CountingLifecycle.accessCalls).isEqualTo(4);
|
||||
assertThat(PreviewStorage.signatureCalls).isEqualTo(4);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count policy calls across independently constructed request services.
|
||||
*******************************************************************************/
|
||||
public static class CountingLifecycle extends AttachmentLifecyclePolicy
|
||||
{
|
||||
private static int accessCalls;
|
||||
|
||||
/*******************************************************************************
|
||||
** Observe URL intent without depending on a service instance lifetime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void beforeAccess(QRecord attachment, boolean inline)
|
||||
{
|
||||
accessCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured preview-only storage adapter; no network or object-store access.
|
||||
*******************************************************************************/
|
||||
public static class PreviewStorage extends AttachmentStorage
|
||||
{
|
||||
private static int signatureCalls;
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a synthetic capability and count actual signing calls.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public URL presign(String name, PresignGet request)
|
||||
{
|
||||
signatureCalls++;
|
||||
try
|
||||
{
|
||||
return (URI.create("https://signed.example/" + request.getKey()).toURL());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject a per-test policy while configuring internal metadata reads too.
|
||||
*******************************************************************************/
|
||||
private AttachmentsService memoryService()
|
||||
{
|
||||
AttachmentLifecyclePolicy selected = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle());
|
||||
return (new AttachmentsService(config, storage, selected, journal));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject a per-test policy while configuring internal metadata reads too.
|
||||
*******************************************************************************/
|
||||
private void withLifecycle()
|
||||
{
|
||||
config.withAttachmentLifecycle(new QCodeReference(TestLifecycle.class));
|
||||
lifecycle = new TestLifecycle();
|
||||
service = new AttachmentsService(config, storage, lifecycle, journal);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reference transfers need explicit retention authority and caller ownership.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void archiveTransferRequiresPolicyAndTransaction() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("retained.txt", "original");
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, null))
|
||||
.hasMessageContaining("explicit transaction");
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
|
||||
.hasMessageContaining("archive policy");
|
||||
QContext.setTransaction(new QBackendTransaction());
|
||||
try
|
||||
{
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
|
||||
.hasMessageContaining("explicit transaction");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(raw(file.id()).getValueString("recordId")).isEqualTo("1");
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A trusted policy cannot replace native target visibility with an approval.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void archiveTransferChecksBothParentsBeforePolicy() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("retained.txt", "original");
|
||||
TransferPolicy policy = new TransferPolicy();
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
QBackendTransaction transaction = new QBackendTransaction();
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 3, transaction)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 999, transaction)).hasMessage("Not found");
|
||||
QContext.getQSession().getSecurityKeyValues().clear();
|
||||
QContext.getQSession().withSecurityKeyValue("tenant", "b");
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 3, transaction)).hasMessage("Not found");
|
||||
assertThat(policy.beforeCalls).isZero();
|
||||
assertThat(policy.afterCalls).isZero();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The owner changes only the canonical reference, never the original content.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void archiveTransferPreservesOriginalAndPassesExactTransaction() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("retained.txt", "original");
|
||||
QRecord prior = raw(file.id());
|
||||
Map<String, byte[]> originalObjects = new HashMap<>(storage.objects);
|
||||
TransferPolicy policy = new TransferPolicy();
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
QBackendTransaction transaction = new QBackendTransaction();
|
||||
login("bob");
|
||||
service.transferRetainedReference(file.id(), "document", 2, transaction);
|
||||
QRecord updated = raw(file.id());
|
||||
assertThat(updated.getValueString("recordId")).isEqualTo("2");
|
||||
assertThat(updated.getValueString("tableName")).isEqualTo("document");
|
||||
for(String field : prior.getValues().keySet())
|
||||
{
|
||||
if(!List.of("tableName", "recordId", "modifyDate").contains(field))
|
||||
{
|
||||
assertThat(updated.getValue(field)).as(field).isEqualTo(prior.getValue(field));
|
||||
}
|
||||
}
|
||||
assertThat(storage.objects).containsExactlyInAnyOrderEntriesOf(originalObjects);
|
||||
assertThat(storage.readCount).isZero();
|
||||
assertThat(policy.beforeCalls).isEqualTo(1);
|
||||
assertThat(policy.afterCalls).isEqualTo(1);
|
||||
assertThat(policy.transaction).isSameAs(transaction);
|
||||
assertThat(policy.targetId).isEqualTo("2");
|
||||
assertThat(policy.prior.getValueString("recordId")).isEqualTo("1");
|
||||
assertThat(service.list("document", 2)).hasSize(1);
|
||||
assertThat(service.list("document", 1)).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome failure reaches the transaction owner; memory does not prove rollback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void archiveTransferPropagatesOutcomeFailure() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("retained.txt", "original");
|
||||
TransferPolicy policy = new TransferPolicy();
|
||||
policy.failAfter = true;
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
|
||||
.hasMessage("Archive audit unavailable");
|
||||
assertThat(policy.beforeCalls).isEqualTo(1);
|
||||
assertThat(policy.afterCalls).isEqualTo(1);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An ordinary attachment cannot acquire the archive-only transfer capability.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void archiveTransferRejectsOrdinaryAttachments() throws Exception
|
||||
{
|
||||
AttachmentEntry file = upload("ordinary.txt", "original");
|
||||
TransferPolicy policy = new TransferPolicy();
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
|
||||
.hasMessageContaining("Only a retained attachment");
|
||||
assertThat(policy.beforeCalls).isZero();
|
||||
assertThat(raw(file.id()).getValueString("recordId")).isEqualTo("1");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Test-only authority observes calls; real JDBC/archive policy lives in its QBit.
|
||||
*******************************************************************************/
|
||||
private static class TransferPolicy extends AttachmentLifecyclePolicy
|
||||
{
|
||||
private int beforeCalls;
|
||||
private int afterCalls;
|
||||
private boolean failAfter;
|
||||
private QBackendTransaction transaction;
|
||||
private String targetId;
|
||||
private QRecord prior;
|
||||
|
||||
/***************************************************************************
|
||||
** Observe the canonical target and the caller's exact transaction.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void beforeReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction)
|
||||
{
|
||||
beforeCalls++;
|
||||
this.transaction = transaction;
|
||||
targetId = recordId;
|
||||
prior = attachment;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A failed outcome must escape so the caller can roll back its transaction.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void afterReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
afterCalls++;
|
||||
if(failAfter)
|
||||
{
|
||||
throw (new QException("Archive audit unavailable"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-test policy instance, exposed only to the enclosing deterministic tests.
|
||||
*******************************************************************************/
|
||||
public static class TestLifecycle extends AttachmentLifecyclePolicy
|
||||
{
|
||||
private boolean retained;
|
||||
private Integer retainedId;
|
||||
private boolean retentionFails;
|
||||
private boolean accessFails;
|
||||
private boolean accessRuntimeFails;
|
||||
private boolean storedFails;
|
||||
private boolean beforeStoredFails;
|
||||
private int beforeStoredCalls;
|
||||
private boolean metadataFails;
|
||||
private int storedCalls;
|
||||
private int metadataCalls;
|
||||
private final List<Boolean> accesses = new ArrayList<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Public no-argument constructor for host-configured policy loading.
|
||||
*******************************************************************************/
|
||||
public TestLifecycle()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record successful registration callbacks or simulate a failed registry.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void beforeStored(QRecord attachment) throws QException
|
||||
{
|
||||
beforeStoredCalls++;
|
||||
if(beforeStoredFails)
|
||||
{
|
||||
throw (new QException("Storage intent unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit fixture-only recovery policy; real durability is tested with JDBC.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void beforeRecovery(QRecord candidate)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Simulate registration after storage; this memory fixture is not durability proof.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void afterStored(QRecord attachment) throws QException
|
||||
{
|
||||
storedCalls++;
|
||||
if(storedFails)
|
||||
{
|
||||
throw (new QException("Registration unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Simulate a durable access event writer failure before signing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void beforeAccess(QRecord attachment, boolean inline) throws QException
|
||||
{
|
||||
accesses.add(inline);
|
||||
if(accessRuntimeFails)
|
||||
{
|
||||
throw (new IllegalStateException("Unexpected policy failure"));
|
||||
}
|
||||
if(accessFails)
|
||||
{
|
||||
throw (new QException("Access audit unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Protect all rows or one specific retained row, independently of parent.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isRetained(QRecord attachment) throws QException
|
||||
{
|
||||
if(retentionFails)
|
||||
{
|
||||
throw (new QException("Retention unavailable"));
|
||||
}
|
||||
return (retained && (retainedId == null || retainedId.equals(attachment.getValueInteger("id"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Track proposed edits separately from successful committed writes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void beforeMetadataChange(QRecord attachment, QRecord changes) throws QException
|
||||
{
|
||||
metadataCalls++;
|
||||
if(metadataFails)
|
||||
{
|
||||
throw (new QException("Metadata audit unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** This memory fixture tests callback ordering, not durable transactions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Production providers must append their event using the owner transaction.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void afterMetadataChange(QRecord attachment, QRecord changes, QBackendTransaction transaction)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upload, metadata, newest-first list, signed download and deletion round trip.
|
||||
*******************************************************************************/
|
||||
/*******************************************************************************
|
||||
** The action steps can be constructed without a QContext, as QQQ does during
|
||||
** instance validation (issue #20); the service is created on first use.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stepsConstructWithoutContext()
|
||||
{
|
||||
QContext.clear();
|
||||
assertThat(new DeleteAttachmentsProcess.Review()).isNotNull();
|
||||
assertThat(new DeleteAttachmentsProcess.Delete()).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void roundTrip() throws Exception
|
||||
{
|
||||
@@ -128,6 +709,184 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
assertThat(service.list("document", 1)).extracting(AttachmentEntry::id).containsExactly(second.id());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Content is authorized and verified before release, and buffers are immutable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void verifiedContentRead() throws Exception
|
||||
{
|
||||
AttachmentEntry file = upload("original.txt", "hello");
|
||||
AttachmentContent content = service.readContent(file.id(), 5);
|
||||
assertThat(content.attachmentId()).isEqualTo(file.id());
|
||||
assertThat(content.sha256()).isEqualTo(file.sha256());
|
||||
assertThat(content.contentType()).isEqualTo("text/plain");
|
||||
assertThat(content.bytes()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8));
|
||||
byte[] copy = content.bytes();
|
||||
copy[0] = 0;
|
||||
assertThat(content.bytes()[0]).isEqualTo((byte) 'h');
|
||||
assertThat(storage.lastReadClosed).isTrue();
|
||||
assertThat(storage.signatures).isEmpty();
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 4)).hasMessageContaining("limit");
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 21 * 1024 * 1024)).hasMessageContaining("20 MiB");
|
||||
assertThat(storage.readCount).isEqualTo(1);
|
||||
QContext.getQSession().getSecurityKeyValues().remove("tenant");
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).isInstanceOf(QNotFoundException.class);
|
||||
assertThat(storage.readCount).isEqualTo(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Changed, truncated and oversized originals never escape the integrity gate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void corruptContentRead() throws Exception
|
||||
{
|
||||
AttachmentEntry file = upload("original.txt", "hello");
|
||||
String key = storage.objects.keySet().iterator().next();
|
||||
storage.objects.put(key, "wrong".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
|
||||
assertThat(storage.lastReadClosed).isTrue();
|
||||
storage.objects.put(key, "four".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).isInstanceOf(AttachmentContentIntegrityException.class);
|
||||
storage.reportLength = false;
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
|
||||
storage.objects.put(key, "toolong".getBytes(StandardCharsets.UTF_8));
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
|
||||
assertThat(storage.lastReadClosed).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Same intent reuses one original while conflicting bytes or metadata fail.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retryableIntakePreservesOneOriginal() throws Exception
|
||||
{
|
||||
String request = "a".repeat(64);
|
||||
String manifest = "b".repeat(64);
|
||||
AttachmentEntry first = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
|
||||
AttachmentEntry replay = service.upload("document", "01", "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
|
||||
assertThat(replay.id()).isEqualTo(first.id());
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThatThrownBy(() -> service.upload("document", 1, "evidence.txt", "text/plain", stream("other"), "caption", request, manifest))
|
||||
.hasMessageContaining("conflicts");
|
||||
assertThatThrownBy(() -> service.upload("document", 1, "renamed.txt", "text/plain", stream("hello"), "caption", request, manifest))
|
||||
.hasMessageContaining("conflicts");
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
service.upload("document", 2, "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
|
||||
assertThat(storage.objects).hasSize(2);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Completed retained retries do not create another file-added timeline event.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void completedRetainedReplayDoesNotRepeatTimelineActivity() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
String request = "c".repeat(64);
|
||||
String manifest = "d".repeat(64);
|
||||
var first = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
|
||||
var replay = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
|
||||
assertThat(replay.id()).isEqualTo(first.id());
|
||||
assertThat(com.kingsrook.qbits.collaboration.CollaborationRecords.query(CollaborationTables.ACTIVITY,
|
||||
new QQueryFilter().withCriteria("type", QCriteriaOperator.EQUALS, "FILE_ADDED"))).hasSize(1);
|
||||
assertThat(lifecycle.storedCalls).as("mandatory idempotent registration runs for each coordinator replay").isEqualTo(2);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A metadata insert failure leaves a deterministic original recoverable.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retryableIntakeRecoversStoredObject() throws Exception
|
||||
{
|
||||
TestLifecycle lifecycle = new TestLifecycle();
|
||||
service = new AttachmentsService(config, storage, lifecycle, journal);
|
||||
QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT);
|
||||
Map<String, QCodeReference> original = new HashMap<>(table.getCustomizers());
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class));
|
||||
String request = "c".repeat(64);
|
||||
String manifest = "d".repeat(64);
|
||||
assertThatThrownBy(() -> service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest))
|
||||
.hasMessageContaining("save attachment");
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
table.setCustomizers(original);
|
||||
AttachmentEntry recovered = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
|
||||
assertThat(recovered.sha256()).hasSize(64);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(service.list("document", 1)).hasSize(1);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both audited content-read phases must succeed before releasing any bytes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedContentReadRequiresExplicitAuditPolicy() throws Exception
|
||||
{
|
||||
AttachmentLifecyclePolicy policy = new AttachmentLifecyclePolicy()
|
||||
{
|
||||
/***********************************************************************
|
||||
** Mark the fixture retained while retaining the default read denial.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public boolean isRetained(QRecord attachment)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
** This fixture exercises read denial after explicitly allowed storage.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void beforeStored(QRecord attachment)
|
||||
{
|
||||
}
|
||||
};
|
||||
service = new AttachmentsService(config, storage, policy, journal);
|
||||
AttachmentEntry file = upload("retained.txt", "hello");
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("content-read policy");
|
||||
assertThat(storage.readCount).isZero();
|
||||
AttachmentLifecyclePolicy partialPolicy = new AttachmentLifecyclePolicy()
|
||||
{
|
||||
/***********************************************************************
|
||||
** A before hook alone must not bypass mandatory read-result recording.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void beforeContentRead(QRecord attachment)
|
||||
{
|
||||
}
|
||||
};
|
||||
service = new AttachmentsService(config, storage, partialPolicy, journal);
|
||||
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("read-result policy");
|
||||
assertThat(storage.readCount).isEqualTo(1);
|
||||
assertThat(storage.lastReadClosed).isTrue();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** An older retention provider cannot silently omit atomic metadata auditing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedMetadataRequiresTransactionPolicyBeforeMutation() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
AttachmentEntry file = upload("original.txt", "hello");
|
||||
AttachmentLifecyclePolicy legacyProvider = new AttachmentLifecyclePolicy()
|
||||
{
|
||||
/***********************************************************************
|
||||
** An older provider handles intent but has no atomic result contract.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void beforeMetadataChange(QRecord attachment, QRecord changes)
|
||||
{
|
||||
}
|
||||
};
|
||||
service = new AttachmentsService(config, storage, legacyProvider, journal);
|
||||
assertThatThrownBy(() -> service.rename(file.id(), "changed.txt", null))
|
||||
.hasMessageContaining("atomic audit transaction policy");
|
||||
assertThat(raw(file.id()).getValueString("fileName")).isEqualTo("original.txt");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both collaboration and bucket size limits are enforced while streaming.
|
||||
*******************************************************************************/
|
||||
@@ -261,7 +1020,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
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);
|
||||
List<QRecord> results = customizer.filter(List.of(visible, hidden), false, true, service);
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(storage.signatures).hasSize(2);
|
||||
assertThat(results.getFirst().getValueString("previewUrl")).contains("signed.example");
|
||||
@@ -270,7 +1029,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
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");
|
||||
assertThatThrownBy(() -> customizer.filter(List.of(hidden), true, true, service)).hasMessage("Not found");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -369,7 +1128,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage failures leave rows for retries; trusted cleanup works after parent deletion.
|
||||
** Storage failures leave durable cleanup; trusted cleanup works after parent deletion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failuresAndCleanup() throws Exception
|
||||
@@ -377,8 +1136,13 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
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);
|
||||
assertThat(service.list("document", 1)).isEmpty();
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
storage.failDelete = false;
|
||||
admin();
|
||||
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
|
||||
login("alice");
|
||||
upload("parent-cleanup.txt", "parent bytes");
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found");
|
||||
QContext.getQSession().getPermissions().clear();
|
||||
@@ -386,10 +1150,56 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
cleanup.onParentsDeleted("document", List.of());
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
admin();
|
||||
new AttachmentCleanupQueue(storage).retryPending();
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A required intent failure prevents even the first object write.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedStorageRequiresIntentBeforePut() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
lifecycle.beforeStoredFails = true;
|
||||
assertThatThrownBy(() -> upload("evidence.txt", "original")).hasMessageContaining("Storage intent unavailable");
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
assertThat(lifecycle.storedCalls).isZero();
|
||||
assertThatThrownBy(() -> new AttachmentLifecyclePolicy().beforeStored(new QRecord().withValue("retained", true)))
|
||||
.hasMessageContaining("durable write-intent");
|
||||
lifecycle.beforeStoredFails = false;
|
||||
QContext.setTransaction(new QBackendTransaction());
|
||||
try
|
||||
{
|
||||
assertThatThrownBy(() -> upload("evidence.txt", "original")).isInstanceOf(QException.class).hasMessageContaining("own committed transactions");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clearTransaction();
|
||||
}
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A stored retained original survives a metadata insertion failure.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retainedFailedInsertNeverDeletesOriginal() throws Exception
|
||||
{
|
||||
withLifecycle();
|
||||
lifecycle.retained = true;
|
||||
instance.getTable(CollaborationTables.ATTACHMENT).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class));
|
||||
assertThatThrownBy(() -> upload("evidence.txt", "original")).hasMessageContaining("Could not save attachment");
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
assertThat(lifecycle.beforeStoredCalls).isEqualTo(1);
|
||||
assertThat(lifecycle.storedCalls).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A rejected row compensates the put; a broken upload never creates a row.
|
||||
*******************************************************************************/
|
||||
@@ -444,7 +1254,8 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB");
|
||||
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");
|
||||
"ON collaboration_attachment (table_name, record_id)", "retained BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
"ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE").doesNotContain("preview_url", "download_url");
|
||||
QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT);
|
||||
assertThat(table.getField("sizeBytes").getType()).isEqualTo(QFieldType.LONG);
|
||||
assertThat(table.getVirtualField("previewUrl")).isNotNull();
|
||||
@@ -484,11 +1295,50 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
private static class FakeStorage extends AttachmentStorage
|
||||
{
|
||||
private final Map<String, byte[]> objects = new HashMap<>();
|
||||
private final Map<String, Map<String, String>> objectMetadata = new HashMap<>();
|
||||
private final List<PresignGet> signatures = new ArrayList<>();
|
||||
private long limit = Long.MAX_VALUE;
|
||||
private PutRequest lastPut;
|
||||
private PresignGet lastGet;
|
||||
private boolean failDelete;
|
||||
private Integer readCount = 0;
|
||||
private Boolean lastReadClosed = false;
|
||||
private Boolean reportLength = true;
|
||||
|
||||
/***************************************************************************
|
||||
** Simulate a closeable object response with independently reported length.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ObjectStream open(String name, String key)
|
||||
{
|
||||
readCount++;
|
||||
lastReadClosed = false;
|
||||
byte[] bytes = objects.get(name + "/" + key);
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(bytes)
|
||||
{
|
||||
/*******************************************************************
|
||||
** Observe cleanup on success and on every integrity failure path.
|
||||
*******************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
lastReadClosed = true;
|
||||
super.close();
|
||||
}
|
||||
};
|
||||
return (new ObjectStream(input, reportLength ? (long) bytes.length : null, "text/plain", null, Instant.now(),
|
||||
new ObjectHeaders().withUserMetadata(objectMetadata.get(name + "/" + key))));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Report presence for retry reconciliation without exposing content.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<StoredObject> head(String name, String key)
|
||||
{
|
||||
byte[] bytes = objects.get(name + "/" + key);
|
||||
return (bytes == null ? Optional.empty() : Optional.of(new StoredObject().withSize((long) bytes.length)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public long maxBytes(String name)
|
||||
@@ -500,9 +1350,15 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
public void put(String name, PutRequest request)
|
||||
{
|
||||
lastPut = request;
|
||||
String key = name + "/" + request.getKey();
|
||||
if(Boolean.TRUE.equals(request.getIfNoneMatch()) && objects.containsKey(key))
|
||||
{
|
||||
throw (new StorageException(StorageException.Reason.PRECONDITION_FAILED, "Object already exists"));
|
||||
}
|
||||
try
|
||||
{
|
||||
objects.put(name + "/" + request.getKey(), request.getStream().readAllBytes());
|
||||
objects.put(key, request.getBytes() == null ? request.getStream().readAllBytes() : request.getBytes().clone());
|
||||
objectMetadata.put(key, request.getMetadata());
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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.sql.DriverManager;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner transaction/control tests with real H2 and fixed existing bytes. The
|
||||
** injected lifecycle is a test policy, not a durability qualification claim.
|
||||
*******************************************************************************/
|
||||
class ExistingAttachmentAdoptionTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final byte[] BYTES = new byte[] {0, 1, 2, 3};
|
||||
private RDBMSBackendMetaData database;
|
||||
private Policy policy;
|
||||
private AttachmentsService service;
|
||||
private int reads;
|
||||
|
||||
/***************************************************************************
|
||||
** Native metadata controls physical field mappings; no validation bypass.
|
||||
***************************************************************************/
|
||||
@BeforeEach
|
||||
void database() throws Exception
|
||||
{
|
||||
ConnectionManager.closeConnectionProviders();
|
||||
database = new RDBMSBackendMetaData().withName("adoption_database").withVendor("h2").withUsername("sa")
|
||||
.withJdbcUrl("jdbc:h2:mem:adoption_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE");
|
||||
instance.addBackend(database);
|
||||
var table = instance.getTable(CollaborationTables.ATTACHMENT);
|
||||
table.setBackendName(database.getName());
|
||||
try(var connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); var statement = connection.createStatement())
|
||||
{
|
||||
List<String> columns = new ArrayList<>();
|
||||
for(var field : table.getFields().values())
|
||||
{
|
||||
String type = switch(field.getType())
|
||||
{
|
||||
case INTEGER -> "INTEGER";
|
||||
case LONG -> "BIGINT";
|
||||
case BOOLEAN -> "BOOLEAN";
|
||||
case DATE_TIME -> "TIMESTAMP";
|
||||
case STRING, TEXT -> "VARCHAR(4096)";
|
||||
default -> throw new IllegalStateException("Unexpected fixture field");
|
||||
};
|
||||
columns.add(field.getBackendName() + " " + type + (field.getName().equals("id") ? " PRIMARY KEY" : "")
|
||||
+ (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
|
||||
}
|
||||
statement.execute("CREATE TABLE collaboration_attachment (" + String.join(",", columns) + ")");
|
||||
String hash = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(BYTES));
|
||||
statement.execute("INSERT INTO collaboration_attachment (id,table_name,record_id,file_name,content_type,size_bytes,sha256,bucket_name,storage_key,created_by_user_id,parent_deleted,retained,create_date) VALUES ("
|
||||
+ "9,'document','1','original.bin','application/octet-stream',4,'" + hash + "','test','original-key','alice',FALSE,FALSE,TIMESTAMP '2026-10-31 00:00:00')");
|
||||
}
|
||||
policy = new Policy();
|
||||
service = new AttachmentsService(config, new AttachmentStorage()
|
||||
{
|
||||
/*******************************************************************
|
||||
** Fixed existing storage: any put/delete falls through to refusal.
|
||||
*******************************************************************/
|
||||
@Override
|
||||
public ObjectStream open(String bucket, String key)
|
||||
{
|
||||
assertThat(bucket).isEqualTo("test");
|
||||
assertThat(key).isEqualTo("original-key");
|
||||
reads++;
|
||||
return (new ObjectStream(new ByteArrayInputStream(BYTES), 4L, "application/octet-stream", null, java.time.Instant.now(), null));
|
||||
}
|
||||
}, policy);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Replay keeps the same row and does not repeat the retention transition.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void sameIdentityAndBytesAreAdoptedWithoutUpload() throws Exception
|
||||
{
|
||||
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
|
||||
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
|
||||
assertThat(reads).isEqualTo(2);
|
||||
assertThat(policy.transitions).isEqualTo(1);
|
||||
assertThat(policy.registrations).isEqualTo(2);
|
||||
assertThat(retained()).isTrue();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Failure before read cannot transition retention or register anything.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void refusedIntentDoesNotReadOrChangeTheOwner() throws Exception
|
||||
{
|
||||
policy.failIntent = true;
|
||||
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("intent refused");
|
||||
assertThat(reads).isZero();
|
||||
assertThat(retained()).isFalse();
|
||||
assertThat(policy.registrations).isZero();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The retention result callback is part of the owner's actual transaction.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void outcomeFailureRollsBackRetention() throws Exception
|
||||
{
|
||||
policy.failOutcome = true;
|
||||
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("outcome refused");
|
||||
assertThat(reads).isEqualTo(1);
|
||||
assertThat(retained()).isFalse();
|
||||
assertThat(policy.registrations).isZero();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Later registration failure cannot pretend the earlier retention rolled back.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void registrationFailureRemainsRetainedAndRetriesSameRow() throws Exception
|
||||
{
|
||||
policy.failRegistration = true;
|
||||
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("registration refused");
|
||||
assertThat(retained()).isTrue();
|
||||
policy.failRegistration = false;
|
||||
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
|
||||
assertThat(policy.transitions).isEqualTo(1);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Ordinary/default policy does not silently become an adoption authority.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void absentPolicyAndDifferentAuthorRefuse() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> new AttachmentsService(config, new AttachmentStorage(), new AttachmentLifecyclePolicy())
|
||||
.adoptRetainedAttachment(9)).hasMessageContaining("not configured");
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessageContaining("author");
|
||||
assertThat(reads).isZero();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Independent connection proves committed state rather than in-memory values.
|
||||
***************************************************************************/
|
||||
private boolean retained() throws Exception
|
||||
{
|
||||
try(var connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", "");
|
||||
var statement = connection.createStatement(); var result = statement.executeQuery("SELECT retained FROM collaboration_attachment WHERE id=9"))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
return (result.getBoolean(1));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit fixture hooks isolate owner transaction behavior from RF auditing.
|
||||
***************************************************************************/
|
||||
private static final class Policy extends AttachmentLifecyclePolicy
|
||||
{
|
||||
private boolean failIntent;
|
||||
private boolean failOutcome;
|
||||
private boolean failRegistration;
|
||||
private int transitions;
|
||||
private int registrations;
|
||||
|
||||
/***********************************************************************
|
||||
** Fixture current-authority grant only.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void authorizeAdoption(QRecord attachment) {}
|
||||
|
||||
/***********************************************************************
|
||||
** Fault before the owner opens existing bytes.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void beforeAdoption(QRecord attachment) throws QException
|
||||
{
|
||||
if(failIntent)
|
||||
{
|
||||
throw (new QException("intent refused"));
|
||||
}
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
** The fixture admits its real H2 transaction without claiming RF durability.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction) {}
|
||||
|
||||
/***********************************************************************
|
||||
** Failure must roll back the actual retained column.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void afterAdoption(QRecord attachment, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(failOutcome)
|
||||
{
|
||||
throw (new QException("outcome refused"));
|
||||
}
|
||||
transitions++;
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
** Failure occurs after the owner's retention commit.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void afterStored(QRecord attachment) throws QException
|
||||
{
|
||||
if(failRegistration)
|
||||
{
|
||||
throw (new QException("registration refused"));
|
||||
}
|
||||
registrations++;
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
|
||||
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadRecoveryNavigation;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
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.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Native visibility and predicate tests. Memory is only the ordinary query
|
||||
** backend here; no memory durability or retained upload claim is made.
|
||||
*******************************************************************************/
|
||||
public class UploadJournalVisibilityTest extends PersonalServiceFixture
|
||||
{
|
||||
@Test
|
||||
void journalRequiresOriginalActorAndCurrentParentVisibility() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
QRecord row = seed();
|
||||
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).hasSize(1);
|
||||
assertThat(new GetAction().execute(new GetInput(UploadJournalTable.NAME).withPrimaryKey(row.getValue("id"))).getRecord()).isNotNull();
|
||||
login("bob", "collaboration.view");
|
||||
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).isEmpty();
|
||||
login("alice", "collaboration.view");
|
||||
com.kingsrook.qqq.backend.core.context.QContext.getQSession().getSecurityKeyValues().remove("tenant");
|
||||
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void privatePhysicalColumnsCannotBeProbedOrProjected() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
seed();
|
||||
for(String field : List.of("storageKey", "storage_key", "bucket_name", "manifest_hash", "lease_token"))
|
||||
{
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
|
||||
new QQueryFilter().withCriteria(field, QCriteriaOperator.EQUALS, "probe")))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
|
||||
new QQueryFilter().withOrderBy(new QFilterOrderBy(field, true))))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new GetAction().execute(new GetInput(UploadJournalTable.NAME).withUniqueKey(Map.of(field, "probe"))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new CountAction().execute(new CountInput(UploadJournalTable.NAME))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryProcessRequiresOneVisibleSelectionAndExplicitConfirmation() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
var metadata = RecoverRetainedUploadProcess.metadata();
|
||||
var prepare = new RecoverRetainedUploadProcess.Prepare();
|
||||
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName(metadata.getName());
|
||||
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
|
||||
assertThatThrownBy(() -> prepare.run(input, output)).hasMessageContaining("exactly one");
|
||||
input.setRecords(List.of(seed()));
|
||||
prepare.run(input, output);
|
||||
assertThat(output.getValue("journalId")).isNotNull();
|
||||
assertThat(output.getValue("confirmRecovery")).isEqualTo(false);
|
||||
assertThatThrownBy(() -> new RecoverRetainedUploadProcess.Recover().run(input, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
|
||||
.hasMessageContaining("Confirm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoveryNavigationUsesCurrentActorAndCanonicalSourcePins() throws Exception
|
||||
{
|
||||
login("alice", "collaboration.view");
|
||||
seed();
|
||||
var source = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable("document");
|
||||
UploadRecoveryNavigation.configure(source);
|
||||
Object descriptor = RecordFeaturesMetaData.of(source).getSettings().get(RecordFeature.ATTACHMENTS).get("recoveryView");
|
||||
assertThat(descriptor).isInstanceOf(Map.class);
|
||||
Map<?, ?> view = (Map<?, ?>) descriptor;
|
||||
assertThat(view.get("tableName")).isEqualTo(UploadJournalTable.NAME);
|
||||
assertThat(view.get("label")).isEqualTo("Review interrupted uploads");
|
||||
QQueryFilter filter = (QQueryFilter) view.get("filter");
|
||||
filter.withCriteria("tableName", QCriteriaOperator.EQUALS, "document").withCriteria("recordId", QCriteriaOperator.EQUALS, "1");
|
||||
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(filter)).getRecords()).hasSize(1);
|
||||
login("bob", "collaboration.view");
|
||||
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(filter)).getRecords()).isEmpty();
|
||||
login("alice", "collaboration.view");
|
||||
for(String field : List.of("recordId", "state"))
|
||||
{
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
|
||||
new QQueryFilter().withCriteria(field, QCriteriaOperator.EQUALS, new CurrentUser())))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
|
||||
new QQueryFilter().withCriteria("createdByUserId", QCriteriaOperator.IN, new CurrentUser())))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public manifest projection only; physical storage secrets are not fields.
|
||||
*******************************************************************************/
|
||||
private QRecord seed() throws Exception
|
||||
{
|
||||
return (CollaborationRecords.insert(UploadJournalTable.NAME, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
|
||||
.withValue("createdByUserId", "alice").withValue("fileName", "pending.txt").withValue("state", "PREPARED")
|
||||
.withValue("storageState", "UNKNOWN").withValue("sha256", "a".repeat(64)).withValue("sizeBytes", 5L).withValue("locatorHash", "b".repeat(64))));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user