Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca89a385b
|
||
|
|
2f3dcdafd4
|
||
|
|
1ee794110d | ||
|
|
f22831844c
|
||
|
|
0b8a7427da
|
No files matched your search
+57
-11
@@ -1,14 +1,60 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
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
|
||||
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
ignore: /.*/
|
||||
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+$/]
|
||||
@@ -0,0 +1,20 @@
|
||||
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 3
|
||||
tab_width = 3
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.{cmd,bat}]
|
||||
end_of_line = crlf
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.cmd text eol=crlf
|
||||
mvnw text eol=lf
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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,25 @@
|
||||
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
|
||||
# major updates stay separate so each gets its own review. At most three version-update PRs per
|
||||
# ecosystem are open at once. Security updates are enabled in the repository settings and are
|
||||
# not limited by this schedule.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: maven
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
maven-minor-and-patch:
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 3
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ['*']
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
+16
-15
@@ -1,20 +1,21 @@
|
||||
# Maven
|
||||
target/
|
||||
.env
|
||||
.idea
|
||||
*.iml
|
||||
*.class
|
||||
*.log
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
hs_err_pid*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.sw*
|
||||
.flattened-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/.env.local
|
||||
/.cache/
|
||||
.claude/settings.local.json
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
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: none
|
||||
test_branch_policy: all
|
||||
publish:
|
||||
snapshot: false
|
||||
release_candidate: false
|
||||
release: false
|
||||
asciidoc: false
|
||||
@@ -0,0 +1,53 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**QBit Collaboration**: record collaboration for QQQ. Phase a implements feature settings, people tables and the app home. Design: [QRun-IO/qqq#1136](https://github.com/QRun-IO/qqq/pull/1136) (`docs/COLLABORATION.md`). Phases b to i are planned.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests
|
||||
./mvnw verify # Unit tests + JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `CollaborationQBitProducer implements QBitMetaDataProducer<CollaborationQBitConfig>` in `com.kingsrook.qbits.collaboration`. The producer registers table metadata, possible values and the optional app home.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The home reads real data with the current session.
|
||||
- People are a service-managed directory; direct user writes are denied.
|
||||
- Feature settings are audited at FIELD level; writes require `collaboration.admin`.
|
||||
- Runtime override application and record feature services await the core contracts.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case prefixed with the full qbit name: `collaboration_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
|
||||
- Permissions use the full qbit name: `collaboration.<action>` (for example `collaboration.view`, `collaboration.admin`).
|
||||
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
|
||||
- Processes run as table or record actions, never in the nav.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## 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.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic versioning.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Phase-c attachments against QQQ 4.1.0-RC.5 and object storage 0.1.0-RC.2: parent-checked upload/list/download/rename/describe/delete service, bounded streaming, SHA-256 and configurable byte/extension limits.
|
||||
- Visible `collaboration_attachment` table, FIELD auditing, file-name/description search fields, virtual presigned FILE_PREVIEW/FILE_DOWNLOAD adornments, PostgreSQL BIGINT size and parent index, and an approximate count/bytes home card.
|
||||
- Temporary `ParentRecordAccess`, readable-table query restriction through RC.5's permission hook and parent post-filtering. Panel calls do not require the admin browse grant; direct attachment table writes are service-managed.
|
||||
- Streamed ETL **Delete attachments** Review/Result record action and retryable `AttachmentCleanup.onParentsDeleted` callback. No Attach files table action; endpoint integration awaits #1128 and cleanup registration awaits core.
|
||||
- Memory-backed attachment/security/process tests. Thumbnails use FILE_PREVIEW; `thumbnailKey` remains nullable/unused because object storage has no generator.
|
||||
|
||||
- Repository scaffold; feature settings and people tables.
|
||||
- Phase-b favorites and recents on QQQ 4.1.0-RC.5: visible `collaboration_favorite` and `collaboration_recent` tables, target-type possible values, unique personal targets and audit disabled for high-volume data.
|
||||
- `CollaborationSecurity` owner locks based on the session user's ID reference, with admin all-access keys and automatic initialization before enforced table actions and in services/home. No collaboration permission is required to manage one's own rows.
|
||||
- `FavoritesService` and `FavoriteEntry`: idempotent star/unstar, reorder/folders, refreshed labels, batched record visibility checks, unavailable-target redaction and Next UI paths.
|
||||
- `RecentsService`: persisted one-minute throttling, visible newest-first lists (up to 50), and hidden daily `collaborationTrimRecents` scheduling with configurable per-user retention (default 200).
|
||||
- Home row/distinct-user summaries, PostgreSQL 15+ unique indexes that treat null record IDs as equal, and memory-backend security/service/retention tests.
|
||||
- UI API endpoints (§10.2) deferred to a follow-up PR after QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released; no unreleased core classes are required.
|
||||
@@ -1,17 +1,53 @@
|
||||
# qbit-template-extension
|
||||
# CLAUDE.md
|
||||
|
||||
GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package.
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
|
||||
## QQQ 4.0 baseline — 2026-09-24
|
||||
## Project Overview
|
||||
|
||||
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
|
||||
**QBit Collaboration**: record collaboration for QQQ. Phase a implements feature settings, people tables and the app home. Design: [QRun-IO/qqq#1136](https://github.com/QRun-IO/qqq/pull/1136) (`docs/COLLABORATION.md`). Phases b to i are planned.
|
||||
|
||||
## Knowledge base
|
||||
## Build Commands
|
||||
|
||||
Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault:
|
||||
```bash
|
||||
./mvnw compile # Compile (always use the wrapper: Maven 3.10 cannot read the parent POM)
|
||||
./mvnw test # Unit tests
|
||||
./mvnw verify # Unit tests + JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
- Hub / entry point: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
|
||||
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-extension.md`
|
||||
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
|
||||
## Architecture
|
||||
|
||||
Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold.
|
||||
**Entry point:** `CollaborationQBitProducer implements QBitMetaDataProducer<CollaborationQBitConfig>` in `com.kingsrook.qbits.collaboration`. The producer registers table metadata, possible values and the optional app home.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The home reads real data with the current session.
|
||||
- People are a service-managed directory; direct user writes are denied.
|
||||
- Feature settings are audited at FIELD level; writes require `collaboration.admin`.
|
||||
- Runtime override application and record feature services await the core contracts.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case prefixed with the full qbit name: `collaboration_<name>`. The QQQ table name and the backend (SQL) name are identical; possible-value sources take the table name.
|
||||
- Permissions use the full qbit name: `collaboration.<action>` (for example `collaboration.view`, `collaboration.admin`).
|
||||
- Qbit data lives in standard, visible QQQ tables, secured by permissions and record security locks, never hidden.
|
||||
- Processes run as table or record actions, never in the nav.
|
||||
|
||||
## Code Style
|
||||
|
||||
- 3-space indentation, opening braces on the next line, `return (x);`.
|
||||
- Javadoc flower-box comments (`/*****` with `**` lines) on every class and method.
|
||||
- Fluent setters (`withX()`).
|
||||
- Wrapper types (`Integer`, `Boolean`) in config and entities.
|
||||
|
||||
## 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.
|
||||
- Tests: JUnit 5, AssertJ.
|
||||
|
||||
## Release
|
||||
|
||||
- CircleCI qqq-orb 0.6.8.
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- A `release/*` branch publishes RCs (the orb bumps the version).
|
||||
- A `v*` tag publishes the release.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code of Conduct
|
||||
|
||||
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Code Style Guide
|
||||
|
||||
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Contributing to QBit Collaboration
|
||||
|
||||
Follow the [QQQ contribution guide](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md).
|
||||
|
||||
Use Java 21 and run `./mvnw verify` before proposing a change. Feature branches target `develop`. Design: [Record Collaboration, PR #1136](https://github.com/QRun-IO/qqq/pull/1136) (`docs/COLLABORATION.md`).
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
Copyright 2024 Kingsrook, LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
qBit Template - Extension
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
QBit Collaboration
|
||||
Copyright 2026 Kingsrook, LLC
|
||||
|
||||
This product includes software developed at QRunIO, LLC (https://qrun.io/).
|
||||
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
|
||||
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
@@ -1,42 +1,164 @@
|
||||
# QBit Template: Extension
|
||||
# QBit Collaboration
|
||||
|
||||
Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers.
|
||||
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phases d to i are planned; stored settings are not yet applied as runtime overrides and people are not yet populated automatically.
|
||||
|
||||
## What is an Extension QBit?
|
||||
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
|
||||
|
||||
Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide:
|
||||
- New backend implementations
|
||||
- Authentication mechanisms
|
||||
- Audit capabilities
|
||||
- Table customizers
|
||||
- Action handlers
|
||||
## Requirements
|
||||
|
||||
## Quick Start
|
||||
- Java 21.
|
||||
- QQQ 4.1.0-RC.5 or newer compatible 4.1 release. The build pins RC.5.
|
||||
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
|
||||
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
|
||||
- Later phases and runtime overrides need the core contracts from the QQQ collaboration PRs. Favorites and recents use only APIs released in RC.5. String enum ids match the spec until the core collaboration enums are released.
|
||||
|
||||
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md).
|
||||
## Install
|
||||
|
||||
## Structure
|
||||
Add the dependency (the snapshot must first be published by CI, or installed locally):
|
||||
|
||||
```
|
||||
src/main/java/com/kingsrook/qbits/example/
|
||||
├── ExampleExtensionQBitConfig.java # Configuration
|
||||
├── ExampleExtensionQBitProducer.java # Entry point
|
||||
└── customizers/
|
||||
└── ExampleTableCustomizer.java # Table customization
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-collaboration</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Key Characteristics
|
||||
After registering your backend and optional parent app, register the producer:
|
||||
|
||||
- **No QAppSection** - Pure infrastructure
|
||||
- **No user-facing tables** (or minimal operational)
|
||||
- **Wraps existing tables/processes** with new behavior
|
||||
- **Validates dependencies** on other QQQ components
|
||||
```java
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
|
||||
|
||||
## Documentation
|
||||
new CollaborationQBitProducer()
|
||||
.withQBitConfig(new CollaborationQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withParentAppName("admin"))
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Extension Patterns](docs/01-extension-patterns.md)
|
||||
Omit `withParentAppName` for a top-level app. Run normal QQQ instance validation after all producers.
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `collaboration_feature_setting` (Feature Settings) | Scope type (INSTANCE, APP, TABLE), scope name, one of the 12 record features, enabled flag, settings JSON stored as TEXT, permission mode. Unique on scope type + scope name + feature; FIELD-level audit. |
|
||||
| `collaboration_user` (People) | Unique session user ID, display name, email, avatar URL, last seen timestamp and active flag. Service-managed, read-only to users. |
|
||||
| `collaboration_favorite` (Favorites) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `sortOrder`, optional `folder`. Unique on user + type + name + record ID. Owner-locked, audit off. |
|
||||
| `collaboration_recent` (Recently Viewed) | `userId`, `targetType`, `targetName`, optional `recordId`, cached `label`, `lastViewedAt`, `viewCount`. Same unique key and owner lock; audit off. |
|
||||
|
||||
| `collaboration_attachment` (Attachments) | Parent table and record ID, file metadata, SHA-256, bucket/key, optional description/thumbnail key, author and parent-deleted flag. FIELD audit; parent-based security. Preview/download URLs are virtual, never stored. |
|
||||
|
||||
All five include `id`, `createDate` and `modifyDate`, identity/details/dates sections, and table possible-value sources. SQL table names match metadata table names; SQL columns use snake_case. Use a stable nonempty scope name (convention: `instance` for INSTANCE; the metadata app/table name otherwise). JSON validation and override resolution arrive with the provider, not in this scaffold.
|
||||
|
||||
The home reports persisted enabled settings by scope type, people known and people seen within the inclusive last seven days, plus Favorites and Recently viewed row counts and distinct user counts, and attachment count and total bytes. Attachment aggregates are explicitly approximate until core can mirror parent locks. Recent activity is based on `lastSeenAt`, independently of the directory's `active` account flag. Empty states explain missing data. These counts do not represent resolved feature enablement on host tables.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Behavior |
|
||||
|---|---|
|
||||
| `collaboration.view` | Browse Attachments, read Feature Settings, People, the app and home. Not required for personal favorites or recents. |
|
||||
| `collaboration.admin` | Write feature settings, read the app/home, access all users' favorite/recent rows, and run recents retention. Can moderate others' attachments after the parent check. Does not permit direct People or attachment table writes. |
|
||||
| `collaboration.fields.manage` | Reserved constant for the later custom-field designer. |
|
||||
| `collaboration.tags.manage` | Reserved constant for later tag management. |
|
||||
|
||||
These use the reference qbit's custom `QPermissionRules` checker. Extra permissions are constants, not automatically registered catalog entries. Configure permission grants in your host. Home count actions explicitly enforce permissions; attachment totals have the parent-lock approximation described below. Hosts should enable `qInstance.setEnforcePermissionsInCoreActions(true)` for direct model API calls as well as middleware checks.
|
||||
|
||||
Personal tables use `PermissionLevel.NOT_PROTECTED` with a `READ_AND_WRITE` owner lock on `userId`. Every signed-in user can manage their own rows. `CollaborationSecurity` derives `collaboration_user` from `QSession.getUser().getIdReference()` and gives `collaboration_user_all_access` to admins. Its custom permission checker initializes keys before enforced table actions; services and the home also call `ensureSessionKeys`. Hosts may call `applyToSession(qInstance, session)` after authentication. Refreshing keys also removes revoked admin access. System sessions are untouched; create scheduler system sessions after the full instance is registered so QQQ includes the all-access key.
|
||||
|
||||
Favorites and recents are omitted from app nav sections. They remain normal, addressable QQQ tables, including for admin inspection. The header and palette will use the services through the follow-up API. These two personal tables intentionally skip audit logging. Personal service methods explicitly filter the current owner even when called by an admin; they never elevate the session. Use standard table actions for admin-wide management.
|
||||
|
||||
## Favorites and recents services
|
||||
|
||||
Call these static methods inside the caller's `QContext`:
|
||||
|
||||
```java
|
||||
FavoritesService.star("RECORD", "order", "123");
|
||||
FavoritesService.star("TABLE", "order", null);
|
||||
List<FavoriteEntry> favorites = FavoritesService.list();
|
||||
FavoritesService.reorder(List.of(12, 8), Map.of(12, "Work"));
|
||||
FavoritesService.unstar("RECORD", "order", "123");
|
||||
|
||||
RecentsService.recordView("RECORD", "order", "123");
|
||||
List<FavoriteEntry> recents = RecentsService.list(50);
|
||||
```
|
||||
|
||||
`FavoritesService` and `FavoriteEntry` are in `com.kingsrook.qbits.collaboration.favorites`; `RecentsService` is in `com.kingsrook.qbits.collaboration.recents`. Target types are `TABLE`, `APP`, `RECORD`, `PROCESS`, `REPORT` and `SAVED_VIEW`. Only `RECORD` accepts a non-null record ID. For `SAVED_VIEW`, `targetName` is the saved view ID; the instance must contain a readable core `savedView` table and the view's target table must also be readable.
|
||||
|
||||
- Star and unstar are idempotent. New stars append to the existing sort order. Reorder moves the supplied IDs first and preserves the order of omitted favorites; folder entries may be null to clear a folder. All supplied IDs must belong to the current user.
|
||||
- Targets are checked using core table/app/process/report permissions; record targets use `GetAction` and the session's record locks. Invisible and missing targets both fail with `Not found`.
|
||||
- Lists refresh labels, using one batched record query per target table per batch. Favorites retain unavailable entries as `available=false`, `label="Unavailable"`, `href=null`; the cached label is never returned. Unstar remains possible after access is lost. Recents drop unavailable entries and refill from older rows up to the requested limit (1–50).
|
||||
- `FavoriteEntry` contains `id`, `targetType`, `targetName`, `recordId`, `label`, `folder`, `sortOrder`, `available` and `href`. Recents use the same target DTO in newest-first order; their folder and sort order are null. `viewCount` and `lastViewedAt` remain available on the underlying recent table.
|
||||
- Hrefs mirror Next's accepted flat routes: `/app/<table>/`, `/app/<table>/<id>/`, `/app/<app-or-process-or-report>/`, and `/app/<table>/savedView/<id>/`. Segments are URL-encoded. This avoids reconstructing nested app paths; trailing slashes support dotted process names.
|
||||
- Record views upsert a unique personal target. If its persisted `lastViewedAt` is less than 60 seconds ago, nothing is written; otherwise its timestamp, cached label and count update. The hidden backend-only `collaborationTrimRecents` process retains the newest `recentsPerUser` targets independently for each user, breaking timestamp ties by ID.
|
||||
|
||||
The UI API endpoints in spec §10.2 will arrive in a **follow-up PR once QQQ's UI API extension point ([QRun-IO/qqq#1128](https://github.com/QRun-IO/qqq/pull/1128)) is released**. This PR adds no endpoint or unreleased core dependency. Runtime feature overrides and the record-feature summary SPI also await released core contracts; the home counts are implemented now.
|
||||
|
||||
## Attachments
|
||||
|
||||
Call the service inside the caller's `QContext`; configure object storage before using it:
|
||||
|
||||
```java
|
||||
AttachmentsService attachments = new AttachmentsService();
|
||||
AttachmentEntry added = attachments.upload("order", "123", "invoice.pdf", "application/pdf", stream, "Signed invoice");
|
||||
List<AttachmentEntry> files = attachments.list("order", "123");
|
||||
URL download = attachments.downloadUrl(added.id(), false);
|
||||
attachments.rename(added.id(), "invoice-final.pdf", null); // null leaves a field unchanged
|
||||
attachments.describe(added.id(), ""); // empty clears the description
|
||||
attachments.delete(List.of(added.id()));
|
||||
```
|
||||
|
||||
Uploads check the parent and `attach`, count bytes and compute SHA-256 while streaming to a temporary file, then stream that file through object storage. This keeps memory bounded and rejects oversize files before a put. Temporary files are removed on success and failure; the caller owns the input stream. A failed row insert triggers object cleanup. Storage and SQL are not a distributed transaction: a cleanup failure is attached to the original error and requires operational reconciliation. Deletion removes the object first and retains the row on a storage failure for retry.
|
||||
|
||||
File names are reduced to a safe basename (maximum 255 characters). Object keys are `attachments/{table}/{recordId}/{uuid}/{fileName}`; table/id segments are URL-encoded to protect string primary keys containing slashes. Renames change the display name and subsequent download filename, retaining the original storage key. `AttachmentEntry` returns display metadata, creation time and `canDelete` (author or admin); it omits bucket/key and signed URLs. Lists are newest first. Changing or deleting an own row also rechecks `attach`; admins may moderate another author's row but must still see its parent. A batch authorizes all selected rows before deleting its first object.
|
||||
|
||||
**Permission model.** The panel service checks the parent with an enforced `GetAction` under the caller's session. Missing, unreadable and locked parents produce `QNotFoundException("Not found")`. Upload requires `<permissionBaseName>.attach` or table EDIT plus a visible parent. This is the explicitly temporary approximation of write-lock checks; distinct WRITE-only parent locks are not enforced until core's record-reference helper is released. Explicit `attach` grants work without EDIT. Collaboration tables cannot themselves be parents.
|
||||
|
||||
The admin Attachments table additionally requires `collaboration.view` (or `collaboration.admin`). The panel service bypasses that browse grant only after its own parent/feature/ownership checks. Direct table insert/update/delete is denied; use the service and record action so storage and rows remain coordinated. Storage operations use `ObjectStorage.asSystem("collaboration", runId).bucket(alias)` after these checks, preserving calling-qbit/initiating-user operation logging without requiring users to hold `object_storage.*` grants.
|
||||
|
||||
RC.5 has no PRE_QUERY customizer. `AttachmentSecurity` uses its custom permission checker as the pre-query hook, adding `tableName IN (readable parent tables)` to queries, counts and aggregates. Enable core permission enforcement for direct model API use. A POST_QUERY customizer checks each row's parent and removes invisible rows before signing any URLs. This applies to direct get/query as well as browsing. **Counts and aggregate bytes are approximate, and pages may be short** because parent record locks are checked after querying; the home labels the approximation. RC.5 cannot publish an `approximate` flag or refill pages through this hook. Replace this stand-in with core's record-reference security when released. Per-table feature enablement/settings and permission catalog integration also await those contracts.
|
||||
|
||||
**Preview and download.** The record view has `FILE_PREVIEW` and `FILE_DOWNLOAD` virtual fields, filled with five-minute presigned GETs only for visible rows (up to 200 rows per query). Downloads use RC.2's `PresignGet.downloadAs(fileName)`. RC.2 has no arbitrary response-header override for explicit inline disposition, so uploads store `Content-Disposition: inline`, and previews leave that header unchanged. URLs are never persisted. Images/PDF use the standard media preview; the host must allow its storage host in the frontend CSP (PDF frames need compatible `frame-src`). `thumbnailKey` is nullable and unused: object storage has no thumbnail generator, so thumbnails use FILE_PREVIEW rather than generated thumbnail files.
|
||||
|
||||
**Actions and cleanup.** `Delete attachments` is a standard streamed ETL record action with Review/Result counts, skipped unauthorized rows, permission rechecks at execution and per-row failure summaries. It is not in navigation. Soft deletion follows the bucket's versioning/trash; without either, deletion is permanent. There is deliberately no **Attach files** table action: uploads come through the API/panel. The current browse action requires `collaboration.view`; the panel may call the service without that grant.
|
||||
|
||||
After a parent deletion commits, trusted host code can call `new AttachmentCleanup().onParentsDeleted(table, ids)`. It deletes objects and rows in batches and is retryable. It cannot check an already-deleted parent and must never be exposed as a user endpoint. Wiring to core's `RecordReferenceCleanupInterface` waits for its release. UI API endpoints and the panel transport wait for [#1128](https://github.com/QRun-IO/qqq/pull/1128); direct/presigned upload endpoints, activity entries and realtime events remain future integrations. The table declares `fileName`/`description` search fields and uses normal QQQ writes, so host-configured record-change/ESB listeners receive events; this qbit does not install an ESB publication or Quick Search provider.
|
||||
|
||||
Tests use real QQQ memory actions and record locks plus an in-memory `AttachmentStorage` adapter. Object storage RC.2 exposes no fake bucket; its provider conformance suite uses MinIO/Testcontainers. This suite requires no Docker and does not claim MinIO/S3 conformance.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `backendName` | Required | Existing QQQ backend used by all five tables. |
|
||||
| `schedulerName` | None | Existing scheduler for the hidden daily recents trim process (86400-second interval and initial delay). Without it, the process is available for explicit admin/system execution. |
|
||||
| `attachmentsBucketName` | `collaboration` | PRIVATE object-storage bucket alias for new uploads; existing rows retain their bucket alias. |
|
||||
| `defaultMaxBytes` | `104857600` (100 MiB) | Positive byte limit, capped by the bucket's `maxUploadBytes`. |
|
||||
| `blockedExtensions` | Empty | Case-insensitive extension denylist, with or without leading dots. |
|
||||
| `recentsPerUser` | `200` | Positive retention count per user. Enforced by the trim process. |
|
||||
| `appName` | `collaboration` | Metadata name of the app. |
|
||||
| `parentAppName` | None | Optional existing parent, such as `admin`. |
|
||||
| `adminApp` | `true` | Set false for tables without the app and widget. |
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
1. Register the host backend and optional Admin app, then invoke the producer above.
|
||||
2. For PostgreSQL, generate `CollaborationSchema.postgresScript(config)` and apply it with the host's migration tool. It creates tables and unique indexes, never executes DDL, and does not migrate existing schemas. QQQ owns date/default values on writes.
|
||||
3. Grant `collaboration.view` and `collaboration.admin` as appropriate; wire the host's audit persistence for FIELD audit events.
|
||||
4. Validate the assembled instance. Navigate to Collaboration for Attachments, Feature Settings and People. Expect empty tables until settings or trusted directory integrations provide rows.
|
||||
5. Wait for the released core contracts and later qbit phases before enabling collaboration features on host records. Favorites, recents and attachments services can be used now. This phase installs no endpoints or host-table overlays; recents scheduling is opt-in.
|
||||
|
||||
## Build
|
||||
|
||||
Always use the Maven 3.9.11 wrapper (Maven 3.10 cannot read the parent POM):
|
||||
|
||||
```sh
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
Offline verification: `./mvnw -o -q verify` (Windows: `./mvnw.cmd -o -q verify`). Verification runs Checkstyle, JUnit tests and JaCoCo gates: 70% instructions and 90% classes.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
[Apache License 2.0](LICENSE). Copyright 2026 Kingsrook, LLC; see [NOTICE](NOTICE).
|
||||
@@ -0,0 +1,4 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
|
||||
* Report a vulnerability privately through this repository's **Security** tab ("Report a vulnerability").
|
||||
@@ -0,0 +1,246 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2022. 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.
|
||||
-->
|
||||
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
|
||||
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
|
||||
<!--
|
||||
See reference at https://checkstyle.sourceforge.io/checks.html
|
||||
-->
|
||||
<module name = "Checker">
|
||||
<property name="charset" value="UTF-8"/>
|
||||
|
||||
<property name="severity" value="warning"/>
|
||||
|
||||
<property name="fileExtensions" value="java, properties, xml"/>
|
||||
<!-- Checks for whitespace -->
|
||||
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
|
||||
<!--
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
|
||||
</module>
|
||||
-->
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="SuppressWarningsHolder"/>
|
||||
<module name="OuterTypeFilename"/>
|
||||
<module name="IllegalTokenText">
|
||||
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
|
||||
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
|
||||
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
|
||||
</module>
|
||||
<module name="AvoidEscapedUnicodeCharacters">
|
||||
<property name="allowEscapesForControlCharacters" value="true"/>
|
||||
<property name="allowByTailComment" value="true"/>
|
||||
<property name="allowNonPrintableEscapes" value="true"/>
|
||||
</module>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="OneTopLevelClass"/>
|
||||
<module name="NoLineWrap"/>
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="TEXT"/>
|
||||
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
|
||||
</module>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="WhitespaceAround">
|
||||
<property name="allowEmptyConstructors" value="true"/>
|
||||
<property name="allowEmptyMethods" value="true"/>
|
||||
<property name="allowEmptyTypes" value="true"/>
|
||||
<property name="allowEmptyLoops" value="true"/>
|
||||
<message key="ws.notFollowed"
|
||||
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
|
||||
<message key="ws.notPreceded"
|
||||
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
-->
|
||||
<module name="OneStatementPerLine"/>
|
||||
<module name="MultipleVariableDeclarations"/>
|
||||
<module name="ArrayTypeStyle"/>
|
||||
<module name="MissingSwitchDefault"/>
|
||||
<module name="FallThrough"/>
|
||||
<module name="UpperEll"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapDot"/>
|
||||
<property name="tokens" value="DOT"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapComma"/>
|
||||
<property name="tokens" value="COMMA"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
|
||||
<property name="id" value="SeparatorWrapEllipsis"/>
|
||||
<property name="tokens" value="ELLIPSIS"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
|
||||
<property name="id" value="SeparatorWrapArrayDeclarator"/>
|
||||
<property name="tokens" value="ARRAY_DECLARATOR"/>
|
||||
<property name="option" value="EOL"/>
|
||||
</module>
|
||||
<module name="SeparatorWrap">
|
||||
<property name="id" value="SeparatorWrapMethodRef"/>
|
||||
<property name="tokens" value="METHOD_REF"/>
|
||||
<property name="option" value="nl"/>
|
||||
</module>
|
||||
<module name="PackageName">
|
||||
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Package name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="TypeName">
|
||||
<message key="name.invalidPattern"
|
||||
value="Type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="CatchParameterName">
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="LocalVariableName">
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
|
||||
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="ClassTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="MethodTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="InterfaceTypeParameterName">
|
||||
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
|
||||
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="NoFinalizer"/>
|
||||
<module name="GenericWhitespace">
|
||||
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
|
||||
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
|
||||
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
|
||||
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
|
||||
</module>
|
||||
<module name="Indentation">
|
||||
<property name="basicOffset" value="3"/>
|
||||
<property name="braceAdjustment" value="0"/>
|
||||
<property name="caseIndent" value="3"/>
|
||||
<property name="throwsIndent" value="6"/>
|
||||
<property name="lineWrappingIndentation" value="3"/>
|
||||
<property name="arrayInitIndent" value="6"/>
|
||||
</module>
|
||||
<!--
|
||||
<module name="AbbreviationAsWordInName">
|
||||
<property name="ignoreFinal" value="false"/>
|
||||
<property name="allowedAbbreviationLength" value="1"/>
|
||||
</module>
|
||||
<module name="OverloadMethodsDeclarationOrder"/>
|
||||
<module name="VariableDeclarationUsageDistance"/>
|
||||
<module name="CustomImportOrder">
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="true"/>
|
||||
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
|
||||
</module>
|
||||
-->
|
||||
<module name="CustomImportOrder">
|
||||
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
|
||||
<property name="specialImportsRegExp" value="^javax\."/>
|
||||
<property name="standardPackageRegExp" value="^java\."/>
|
||||
<property name="sortImportsInGroupAlphabetically" value="true"/>
|
||||
<property name="separateLineBetweenGroups" value="false"/>
|
||||
</module>
|
||||
<module name="MethodParamPad"/>
|
||||
<module name="NoWhitespaceBefore">
|
||||
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
|
||||
<property name="allowLineBreaks" value="true"/>
|
||||
</module>
|
||||
<module name="ParenPad"/>
|
||||
<module name="OperatorWrap">
|
||||
<property name="option" value="NL"/>
|
||||
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationMostCases"/>
|
||||
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
|
||||
</module>
|
||||
<module name="AnnotationLocation">
|
||||
<property name="id" value="AnnotationLocationVariables"/>
|
||||
<property name="tokens" value="VARIABLE_DEF"/>
|
||||
<property name="allowSamelineMultipleAnnotations" value="true"/>
|
||||
</module>
|
||||
<module name="JavadocMethod">
|
||||
<property name="allowMissingParamTags" value="true"/>
|
||||
<property name="allowMissingReturnTag" value="true"/>
|
||||
<property name="allowedAnnotations" value="Override, Test"/>
|
||||
</module>
|
||||
<module name="MissingJavadocMethod">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MissingJavadocType">
|
||||
<property name="scope" value="private"/>
|
||||
</module>
|
||||
<module name="MethodName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
|
||||
<message key="name.invalidPattern"
|
||||
value="Method name ''{0}'' must match pattern ''{1}''."/>
|
||||
</module>
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected"/>
|
||||
</module>
|
||||
<module name="CommentsIndentation"/>
|
||||
|
||||
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
|
||||
<module name="MissingOverride"/>
|
||||
|
||||
</module>
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
@@ -1,28 +0,0 @@
|
||||
# Getting Started
|
||||
|
||||
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
|
||||
|
||||
1. Choose **Use this template** on GitHub, then clone the repository you created.
|
||||
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
|
||||
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
|
||||
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
|
||||
5. Build the generated project:
|
||||
|
||||
```bash
|
||||
mvn clean verify
|
||||
```
|
||||
|
||||
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
|
||||
|
||||
## Register with a host application
|
||||
|
||||
The following uses the original example names; substitute the names chosen above:
|
||||
|
||||
```java
|
||||
new ExampleExtensionQBitProducer()
|
||||
.withConfig(new ExampleExtensionQBitConfig()
|
||||
.withTargetTableName("order"))
|
||||
.produce(qInstance, "my-extension");
|
||||
```
|
||||
|
||||
Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md).
|
||||
@@ -1,16 +0,0 @@
|
||||
# Extension Patterns
|
||||
|
||||
[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java).
|
||||
|
||||
```java
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Target table not found: " + tableName);
|
||||
}
|
||||
new ExampleTableCustomizer().customize(table);
|
||||
```
|
||||
|
||||
When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List<String>)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior.
|
||||
|
||||
For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List<QRecord>)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Collaboration design
|
||||
|
||||
The authoritative design is [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md` ([develop document](https://github.com/QRun-IO/qqq/blob/develop/docs/COLLABORATION.md)).
|
||||
|
||||
Collaboration adds metadata-driven record features to QQQ: attachments, tags, custom fields, comments and mentions, presence, sharing, favorites, follow, timeline, recents, tasks and assignment. Core owns security, query and runtime metadata contracts; this qbit owns visible `collaboration_*` tables and feature services. Implemented: phase-a configuration, feature settings and people tables, plus phase-b favorites/recents tables, services, owner locks, retention and home counts against QQQ 4.1.0-RC.5. The phase-b UI API endpoints await the released extension point from QRun-IO/qqq#1128. The override and record-feature summary providers await released core contracts; phase-c attachments are implemented on object storage 0.1.0-RC.2; phases d to i remain planned. The table below is the full roadmap from the specification, not a list of shipped functionality.
|
||||
|
||||
| Phase | Core | Qbit | Next UI |
|
||||
|---|---|---|---|
|
||||
| a. Contracts | `RecordFeaturesMetaData` + resolver + frontend publication; `RecordReference`, `RecordReferenceTableMetaData` and the security rules of §3.2 (table filter, mirrored locks, parent check, post-filter, cleanup hook); feature permissions in the catalog; `RecordFeatureSummaryProviderInterface` and `GET /table/{t}/{pk}/features`; `RuntimeMetaDataProviderInterface` and overlay (generation only, no fields yet); `RecordAccessCheckerInterface`, `UserSessionProviderInterface` | repo scaffold, qbit config, `collaboration_feature_setting`, `collaboration_user`, override provider, home skeleton | types for `RecordFeaturesV1` |
|
||||
| b. Favorites, recents, palette | | favorites, recents, endpoints, summary provider | star in record and table headers, favorites menu, palette favorites group, server recents |
|
||||
| c. Attachments | | attachments with object storage, previews and thumbnails, delete action | Files tab, upload, preview |
|
||||
| d. Tags | `QSubQuery`; virtual field criteria expander in query, count, aggregate and search; `CONTAINS_ALL`; `#tag` search | tags, vocabulary modes, bulk actions, filter field | tag chips, Tags filter and column, `#tag` in palette and search |
|
||||
| e. Comments, follow, timeline | | comments, revisions, mentions, follows, activity, timeline merge with audit, notifications | Comments and Activity tabs, composer, follow bell |
|
||||
| f. User-defined fields | `JsonPathStorage` in RDBMS, memory and MongoDB; overlay field contributions | definitions, designer, publish and archive, index job, EAV fallback | designer preview widget; fields render through normal metadata |
|
||||
| g. Realtime and presence | `RealtimeBus` (in-memory, ESB), SSE spec, subscriptions, `record.changed` producer, `RecordVersionCheck` with 409 | presence endpoints and replicated map, events for its features | stream client, presence avatars and field rings, conflict dialog, live panel refresh |
|
||||
| h. Sharing | | link, shares with preview, access requests | share dialog, request-access page |
|
||||
| i. Tasks, owner/assignee | `CurrentUser` expression, ownership metadata, "My records" view | tasks, reminders, assignment table and virtual fields, assign actions, My work | Tasks tab, My work page, assign actions |
|
||||
|
||||
|
||||
## Phase-c implementation choices
|
||||
|
||||
- `ParentRecordAccess` is the named replacement point for the unreleased core helper. GetAction enforces parent READ and locks. Attach is an explicit feature grant or EDIT plus visible parent; separate WRITE locks remain an approximation.
|
||||
- Admin browsing requires collaboration.view, while panel service calls authorize the parent directly. Direct table writes are denied. Internal persistence uses explicit per-action permission bypasses after service checks, and per-input metadata clones without preview customizers. The caller's session is never elevated or replaced. Cloning also lets delete prefetch find rows after the parent is deleted, while preserving FIELD audits and record-change listeners.
|
||||
- RC.5 offers no PRE_QUERY customizer. The attachment permission checker adds a readable-table IN filter before query/count/aggregate; POST_QUERY filters parent locks before presigning. Approximate counts/bytes and short pages are documented. Full mirrored locks, page refill and API approximate flags await core.
|
||||
- Uploads spool to bounded temporary disk while hashing; no full-file heap buffer. The bucket cap also applies. Keys contain a UUID and safe basename; renames do not move bytes. Object/row writes are ordered with best-effort compensation, not a distributed transaction.
|
||||
- The object-storage adapter uses the calling-qbit system facade only after authorization. Soft deletion follows bucket policy. Object retention failures leave retryable rows. In-memory adapter tests exercise QQQ actions and security; provider conformance stays with object storage's MinIO suite.
|
||||
- Previews/downloads are virtual fields in the RECORD_VIEW alternative section (RC.5 disallows virtual fields in base sections). Five-minute URLs are never stored. RC.2 supports attachment filename overrides, but not explicit inline overrides; uploads carry an inline header. There is no thumbnail generator or generated thumbnail file.
|
||||
- Delete attachments uses standard streamed ETL. Attach files is omitted for API/panel uploads. Cleanup is a trusted public after-commit callback pending RecordReferenceCleanupInterface. Endpoints await #1128; runtime feature settings, activity and realtime publication are future integrations. Native search fields are present; ESB and Quick Search publication are host integrations.
|
||||
|
||||
See [README](../README.md#attachments) for the service contract, permissions and configuration.
|
||||
@@ -0,0 +1,295 @@
|
||||
#!/bin/sh
|
||||
# ----------------------------------------------------------------------------
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you 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
|
||||
#
|
||||
# http://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.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
#
|
||||
# Optional ENV vars
|
||||
# -----------------
|
||||
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
set -euf
|
||||
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||
|
||||
# OS specific support.
|
||||
native_path() { printf %s\\n "$1"; }
|
||||
case "$(uname)" in
|
||||
CYGWIN* | MINGW*)
|
||||
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||
native_path() { cygpath --path --windows "$1"; }
|
||||
;;
|
||||
esac
|
||||
|
||||
# set JAVACMD and JAVACCMD
|
||||
set_java_home() {
|
||||
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||
if [ -n "${JAVA_HOME-}" ]; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||
|
||||
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
JAVACMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v java
|
||||
)" || :
|
||||
JAVACCMD="$(
|
||||
'set' +e
|
||||
'unset' -f command 2>/dev/null
|
||||
'command' -v javac
|
||||
)" || :
|
||||
|
||||
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# hash string like Java String::hashCode
|
||||
hash_string() {
|
||||
str="${1:-}" h=0
|
||||
while [ -n "$str" ]; do
|
||||
char="${str%"${str#?}"}"
|
||||
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||
str="${str#?}"
|
||||
done
|
||||
printf %x\\n $h
|
||||
}
|
||||
|
||||
verbose() { :; }
|
||||
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||
|
||||
die() {
|
||||
printf %s\\n "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
trim() {
|
||||
# MWRAPPER-139:
|
||||
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||
# Needed for removing poorly interpreted newline sequences when running in more
|
||||
# exotic environments such as mingw bash on Windows.
|
||||
printf "%s" "${1}" | tr -d '[:space:]'
|
||||
}
|
||||
|
||||
scriptDir="$(dirname "$0")"
|
||||
scriptName="$(basename "$0")"
|
||||
|
||||
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||
while IFS="=" read -r key value; do
|
||||
case "${key-}" in
|
||||
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||
esac
|
||||
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
|
||||
case "${distributionUrl##*/}" in
|
||||
maven-mvnd-*bin.*)
|
||||
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||
*)
|
||||
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||
distributionPlatform=linux-amd64
|
||||
;;
|
||||
esac
|
||||
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||
;;
|
||||
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||
esac
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||
|
||||
exec_maven() {
|
||||
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||
}
|
||||
|
||||
if [ -d "$MAVEN_HOME" ]; then
|
||||
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
exec_maven "$@"
|
||||
fi
|
||||
|
||||
case "${distributionUrl-}" in
|
||||
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||
esac
|
||||
|
||||
# prepare tmp dir
|
||||
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||
trap clean HUP INT TERM EXIT
|
||||
else
|
||||
die "cannot create temp dir"
|
||||
fi
|
||||
|
||||
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||
|
||||
# Download and Install Apache Maven
|
||||
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
verbose "Downloading from: $distributionUrl"
|
||||
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
# select .zip or .tar.gz
|
||||
if ! command -v unzip >/dev/null; then
|
||||
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||
distributionUrlName="${distributionUrl##*/}"
|
||||
fi
|
||||
|
||||
# verbose opt
|
||||
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||
|
||||
# normalize http auth
|
||||
case "${MVNW_PASSWORD:+has-password}" in
|
||||
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||
esac
|
||||
|
||||
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||
verbose "Found wget ... using wget"
|
||||
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||
verbose "Found curl ... using curl"
|
||||
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||
elif set_java_home; then
|
||||
verbose "Falling back to use Java to download"
|
||||
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
cat >"$javaSource" <<-END
|
||||
public class Downloader extends java.net.Authenticator
|
||||
{
|
||||
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||
{
|
||||
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||
}
|
||||
public static void main( String[] args ) throws Exception
|
||||
{
|
||||
setDefault( new Downloader() );
|
||||
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||
}
|
||||
}
|
||||
END
|
||||
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||
verbose " - Compiling Downloader.java ..."
|
||||
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||
verbose " - Running Downloader.java ..."
|
||||
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||
fi
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
if [ -n "${distributionSha256Sum-}" ]; then
|
||||
distributionSha256Result=false
|
||||
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
elif command -v sha256sum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
elif command -v shasum >/dev/null; then
|
||||
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||
distributionSha256Result=true
|
||||
fi
|
||||
else
|
||||
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $distributionSha256Result = false ]; then
|
||||
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# unzip and move
|
||||
if command -v unzip >/dev/null; then
|
||||
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||
else
|
||||
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||
fi
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
actualDistributionDir=""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$distributionUrlNameMain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
# enable globbing to iterate over items
|
||||
set +f
|
||||
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||
if [ -d "$dir" ]; then
|
||||
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||
actualDistributionDir="$(basename "$dir")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
set -f
|
||||
fi
|
||||
|
||||
if [ -z "$actualDistributionDir" ]; then
|
||||
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||
die "Could not find Maven distribution directory in extracted archive"
|
||||
fi
|
||||
|
||||
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||
|
||||
clean || :
|
||||
exec_maven "$@"
|
||||
@@ -0,0 +1,189 @@
|
||||
<# : batch portion
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||
@REM or more contributor license agreements. See the NOTICE file
|
||||
@REM distributed with this work for additional information
|
||||
@REM regarding copyright ownership. The ASF licenses this file
|
||||
@REM to you under the Apache License, Version 2.0 (the
|
||||
@REM "License"); you may not use this file except in compliance
|
||||
@REM with the License. You may obtain a copy of the License at
|
||||
@REM
|
||||
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||
@REM
|
||||
@REM Unless required by applicable law or agreed to in writing,
|
||||
@REM software distributed under the License is distributed on an
|
||||
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
@REM KIND, either express or implied. See the License for the
|
||||
@REM specific language governing permissions and limitations
|
||||
@REM under the License.
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@REM ----------------------------------------------------------------------------
|
||||
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||
@REM
|
||||
@REM Optional ENV vars
|
||||
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||
@REM ----------------------------------------------------------------------------
|
||||
|
||||
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||
@SET __MVNW_CMD__=
|
||||
@SET __MVNW_ERROR__=
|
||||
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||
@SET PSModulePath=
|
||||
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||
)
|
||||
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||
@SET __MVNW_PSMODULEP_SAVE=
|
||||
@SET __MVNW_ARG0_NAME__=
|
||||
@SET MVNW_USERNAME=
|
||||
@SET MVNW_PASSWORD=
|
||||
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||
@GOTO :EOF
|
||||
: end batch / begin powershell #>
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($env:MVNW_VERBOSE -eq "true") {
|
||||
$VerbosePreference = "Continue"
|
||||
}
|
||||
|
||||
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||
if (!$distributionUrl) {
|
||||
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||
}
|
||||
|
||||
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||
"maven-mvnd-*" {
|
||||
$USE_MVND = $true
|
||||
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||
$MVN_CMD = "mvnd.cmd"
|
||||
break
|
||||
}
|
||||
default {
|
||||
$USE_MVND = $false
|
||||
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
if ($env:MVNW_REPOURL) {
|
||||
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||
}
|
||||
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||
|
||||
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||
if ($env:MAVEN_USER_HOME) {
|
||||
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||
}
|
||||
|
||||
$MAVEN_WRAPPER_DISTS = $null
|
||||
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||
} else {
|
||||
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||
}
|
||||
|
||||
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||
|
||||
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
exit $?
|
||||
}
|
||||
|
||||
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||
}
|
||||
|
||||
# prepare tmp dir
|
||||
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||
trap {
|
||||
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||
|
||||
# Download and Install Apache Maven
|
||||
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||
Write-Verbose "Downloading from: $distributionUrl"
|
||||
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||
|
||||
$webclient = New-Object System.Net.WebClient
|
||||
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||
}
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||
|
||||
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||
if ($distributionSha256Sum) {
|
||||
if ($USE_MVND) {
|
||||
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||
}
|
||||
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||
}
|
||||
}
|
||||
|
||||
# unzip and move
|
||||
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||
|
||||
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||
$actualDistributionDir = ""
|
||||
|
||||
# First try the expected directory name (for regular distributions)
|
||||
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||
$actualDistributionDir = $distributionUrlNameMain
|
||||
}
|
||||
|
||||
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||
if (!$actualDistributionDir) {
|
||||
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||
$actualDistributionDir = $_.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$actualDistributionDir) {
|
||||
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||
}
|
||||
|
||||
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||
try {
|
||||
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||
} catch {
|
||||
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||
Write-Error "fail to move MAVEN_HOME"
|
||||
}
|
||||
} finally {
|
||||
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||
}
|
||||
|
||||
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||
@@ -1,36 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<!--
|
||||
~ Copyright 2026 Kingsrook, LLC
|
||||
~
|
||||
~ 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
|
||||
~
|
||||
~ http://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.
|
||||
-->
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-example-extension</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Example Extension</name>
|
||||
<description>Example Extension QBit - rename this for your QBit</description>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<qqq.version>4.0.0</qqq.version>
|
||||
<jacoco.version>0.8.15</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-collaboration</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Collaboration</name>
|
||||
<description>QQQ QBit: record collaboration: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline, tasks</description>
|
||||
<url>https://github.com/QRun-IO/qbit-collaboration</url>
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<licenses>
|
||||
<license>
|
||||
<name>Apache License, Version 2.0</name>
|
||||
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||
<distribution>repo</distribution>
|
||||
</license>
|
||||
</licenses>
|
||||
|
||||
<developers>
|
||||
<developer>
|
||||
<id>kingsrook</id>
|
||||
<name>Kingsrook Team</name>
|
||||
<email>contact@kingsrook.com</email>
|
||||
<organization>Kingsrook, LLC</organization>
|
||||
<organizationUrl>https://kingsrook.com</organizationUrl>
|
||||
</developer>
|
||||
</developers>
|
||||
|
||||
<scm>
|
||||
<connection>scm:git:git://github.com/QRun-IO/qbit-collaboration.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-collaboration.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-collaboration</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -45,14 +93,106 @@
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.3.1</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.12.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle/config.xml</configLocation>
|
||||
<headerLocation>checkstyle/license.txt</headerLocation>
|
||||
<encoding>UTF-8</encoding>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>false</failsOnError>
|
||||
<failOnViolation>true</failOnViolation>
|
||||
<violationSeverity>warning</violationSeverity>
|
||||
<includeTestSourceDirectory>false</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
<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.5 ahead of the parent's 4.0.0 BOM. -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration for the Collaboration qbit.
|
||||
*******************************************************************************/
|
||||
public class CollaborationQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final String TABLE_NAME_PREFIX = "collaboration_";
|
||||
public static final String PERMISSION_BASE_NAME = "collaboration";
|
||||
|
||||
private String backendName;
|
||||
private String schedulerName;
|
||||
private String appName = "collaboration";
|
||||
private String parentAppName;
|
||||
private Boolean adminApp = true;
|
||||
private Integer recentsPerUser = 200;
|
||||
private String attachmentsBucketName = "collaboration";
|
||||
private Long defaultMaxBytes = 100L * 1024 * 1024;
|
||||
private List<String> blockedExtensions = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName.
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName.
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public void setSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appName.
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (appName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appName.
|
||||
*******************************************************************************/
|
||||
public void setAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parentAppName.
|
||||
*******************************************************************************/
|
||||
public String getParentAppName()
|
||||
{
|
||||
return (parentAppName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parentAppName.
|
||||
*******************************************************************************/
|
||||
public void setParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parentAppName.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for adminApp.
|
||||
*******************************************************************************/
|
||||
public Boolean getAdminApp()
|
||||
{
|
||||
return (adminApp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for adminApp.
|
||||
*******************************************************************************/
|
||||
public void setAdminApp(Boolean adminApp)
|
||||
{
|
||||
this.adminApp = adminApp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for adminApp.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAdminApp(Boolean adminApp)
|
||||
{
|
||||
this.adminApp = adminApp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Maximum retained recent targets per user.
|
||||
*******************************************************************************/
|
||||
public Integer getRecentsPerUser()
|
||||
{
|
||||
return (recentsPerUser);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure retention; validated when the producer is installed.
|
||||
*******************************************************************************/
|
||||
public void setRecentsPerUser(Integer recentsPerUser)
|
||||
{
|
||||
this.recentsPerUser = recentsPerUser;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent retention configuration.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withRecentsPerUser(Integer recentsPerUser)
|
||||
{
|
||||
setRecentsPerUser(recentsPerUser);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the configured backend and app placement.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance instance, List<String> errors)
|
||||
{
|
||||
if(assertCondition(StringUtils.hasContent(backendName), "backendName is required for Collaboration", errors))
|
||||
{
|
||||
assertCondition(instance.getBackend(backendName) != null, "Unknown Collaboration backend: " + backendName, errors);
|
||||
}
|
||||
assertCondition(StringUtils.hasContent(attachmentsBucketName), "attachmentsBucketName is required", errors);
|
||||
assertCondition(defaultMaxBytes != null && defaultMaxBytes > 0, "defaultMaxBytes must be positive", errors);
|
||||
assertCondition(blockedExtensions != null && blockedExtensions.stream().allMatch(value -> value != null && !value.isBlank()), "blockedExtensions must contain nonblank extensions", errors);
|
||||
assertCondition(recentsPerUser != null && recentsPerUser > 0, "recentsPerUser must be positive", errors);
|
||||
if(schedulerName != null)
|
||||
{
|
||||
assertCondition(instance.getScheduler(schedulerName) != null, "Unknown Collaboration scheduler: " + schedulerName, errors);
|
||||
}
|
||||
if(Boolean.TRUE.equals(adminApp))
|
||||
{
|
||||
assertCondition(StringUtils.hasContent(appName), "appName is required when adminApp is enabled", errors);
|
||||
if(parentAppName != null)
|
||||
{
|
||||
assertCondition(instance.getApp(parentAppName) != null, "Unknown Collaboration parent app: " + parentAppName, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default backend for the qbit tables.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBackendNameForTables()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for attachmentsBucketName.
|
||||
*******************************************************************************/
|
||||
public String getAttachmentsBucketName()
|
||||
{
|
||||
return (attachmentsBucketName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for attachmentsBucketName.
|
||||
*******************************************************************************/
|
||||
public void setAttachmentsBucketName(String attachmentsBucketName)
|
||||
{
|
||||
this.attachmentsBucketName = attachmentsBucketName;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for attachmentsBucketName.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withAttachmentsBucketName(String attachmentsBucketName)
|
||||
{
|
||||
setAttachmentsBucketName(attachmentsBucketName);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxBytes.
|
||||
*******************************************************************************/
|
||||
public Long getDefaultMaxBytes()
|
||||
{
|
||||
return (defaultMaxBytes);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for defaultMaxBytes.
|
||||
*******************************************************************************/
|
||||
public void setDefaultMaxBytes(Long defaultMaxBytes)
|
||||
{
|
||||
this.defaultMaxBytes = defaultMaxBytes;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxBytes.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withDefaultMaxBytes(Long defaultMaxBytes)
|
||||
{
|
||||
setDefaultMaxBytes(defaultMaxBytes);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for blockedExtensions.
|
||||
*******************************************************************************/
|
||||
public List<String> getBlockedExtensions()
|
||||
{
|
||||
return (blockedExtensions);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for blockedExtensions.
|
||||
*******************************************************************************/
|
||||
public void setBlockedExtensions(List<String> blockedExtensions)
|
||||
{
|
||||
this.blockedExtensions = blockedExtensions;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for blockedExtensions.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitConfig withBlockedExtensions(List<String> blockedExtensions)
|
||||
{
|
||||
setBlockedExtensions(blockedExtensions);
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.recents.TrimRecentsProcess;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registers the Collaboration tables, possible values and optional app home.
|
||||
*******************************************************************************/
|
||||
public class CollaborationQBitProducer implements QBitMetaDataProducer<CollaborationQBitConfig>
|
||||
{
|
||||
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";
|
||||
|
||||
private CollaborationQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the qbit.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitProducer withQBitConfig(CollaborationQBitConfig config)
|
||||
{
|
||||
qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the configuration.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public CollaborationQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the namespace.
|
||||
*******************************************************************************/
|
||||
public CollaborationQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the namespace.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Describe this qbit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-scanned producers remain disabled until configured.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and register metadata, following the reference qbit convention.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
|
||||
{
|
||||
qBitConfig.validate(instance);
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
instance.addQBit(qBit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
CollaborationSecurity.buildSecurityKeyTypes().forEach(output::add);
|
||||
output.add(TrimRecentsProcess.metadata(qBitConfig));
|
||||
output.add(DeleteAttachmentsProcess.metadata());
|
||||
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
|
||||
{
|
||||
output.add(source);
|
||||
}
|
||||
for(QTableMetaData table : CollaborationTables.tables(qBitConfig))
|
||||
{
|
||||
table.setSourceQBitName(qBit.getName());
|
||||
output.add(table);
|
||||
}
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()))
|
||||
{
|
||||
output.add(CollaborationHome.widget());
|
||||
output.add(CollaborationHome.app(qBitConfig));
|
||||
}
|
||||
output.addSelfToInstance(instance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getAdminApp()) && qBitConfig.getParentAppName() != null)
|
||||
{
|
||||
QAppMetaData parent = instance.getApp(qBitConfig.getParentAppName());
|
||||
parent.withChild(instance.getApp(qBitConfig.getAppName()));
|
||||
}
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PostgreSQL DDL generated from the same fields and unique keys as QQQ metadata.
|
||||
** Hosts apply it through their migration tooling; this class never executes DDL.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationSchema()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create tables and unique indexes using QQQ's inferred snake_case columns.
|
||||
*******************************************************************************/
|
||||
public static String postgresScript(CollaborationQBitConfig config)
|
||||
{
|
||||
List<String> statements = new ArrayList<>();
|
||||
for(QTableMetaData table : CollaborationTables.tables(config))
|
||||
{
|
||||
List<String> columns = new ArrayList<>();
|
||||
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" : ""));
|
||||
}
|
||||
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
|
||||
int index = 0;
|
||||
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>of() : table.getUniqueKeys())
|
||||
{
|
||||
index++;
|
||||
List<String> names = key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList();
|
||||
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + index + " ON " + table.getName() + " (" + String.join(", ", names) + ")"
|
||||
+ (key.getFieldNames().contains("recordId") ? " NULLS NOT DISTINCT" : ""));
|
||||
}
|
||||
}
|
||||
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
|
||||
return (String.join(";\n", statements) + ";\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported PostgreSQL types; fail explicitly for unsupported metadata.
|
||||
*******************************************************************************/
|
||||
private static String sqlType(QFieldMetaData field)
|
||||
{
|
||||
return (switch(field.getType())
|
||||
{
|
||||
case STRING -> "VARCHAR(" + (field.getMaxLength() == null ? 255 : field.getMaxLength()) + ")";
|
||||
case TEXT -> "TEXT";
|
||||
case INTEGER -> "INTEGER";
|
||||
case LONG -> "BIGINT";
|
||||
case BOOLEAN -> "BOOLEAN";
|
||||
case DATE_TIME -> "TIMESTAMPTZ";
|
||||
case DATE -> "DATE";
|
||||
case DECIMAL -> "NUMERIC";
|
||||
default -> throw new IllegalArgumentException("Unsupported schema type: " + field.getType());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal tables require no collaboration.view grant: NOT_PROTECTED plus a
|
||||
** READ_AND_WRITE owner lock lets every signed-in user manage their own rows.
|
||||
** Administrators receive the all-access key. The permission checker initializes
|
||||
** keys before enforced core actions (RC.5 has no pre-query customizer); services
|
||||
** and the home also ensure keys, like Alerts. Hosts may call applyToSession
|
||||
** after authentication. System sessions are left untouched.
|
||||
*******************************************************************************/
|
||||
public class CollaborationSecurity implements CustomPermissionChecker
|
||||
{
|
||||
public static final String KEY_USER = "collaboration_user";
|
||||
public static final String KEY_USER_ALL_ACCESS = "collaboration_user_all_access";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the owner key and its administrator override.
|
||||
*******************************************************************************/
|
||||
public static List<QSecurityKeyType> buildSecurityKeyTypes()
|
||||
{
|
||||
return (List.of(new QSecurityKeyType().withName(KEY_USER).withAllAccessKeyName(KEY_USER_ALL_ACCESS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply ownership to reads and writes, including changes to the owner field.
|
||||
*******************************************************************************/
|
||||
public static RecordSecurityLock ownerLock(String fieldName)
|
||||
{
|
||||
return (new RecordSecurityLock().withSecurityKeyType(KEY_USER).withFieldName(fieldName)
|
||||
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace our keys so a changed user or revoked admin permission cannot linger.
|
||||
*******************************************************************************/
|
||||
public static void applyToSession(QInstance instance, QSession session)
|
||||
{
|
||||
if(instance == null || session == null || session instanceof QSystemUserSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(session.getSecurityKeyValues() != null)
|
||||
{
|
||||
session.getSecurityKeyValues().remove(KEY_USER);
|
||||
session.getSecurityKeyValues().remove(KEY_USER_ALL_ACCESS);
|
||||
}
|
||||
String userId = userId(session);
|
||||
if(userId != null && !userId.isBlank())
|
||||
{
|
||||
session.withSecurityKeyValue(KEY_USER, userId);
|
||||
if(session.hasPermission(CollaborationPermissions.ADMIN))
|
||||
{
|
||||
session.withSecurityKeyValue(KEY_USER_ALL_ACCESS, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cheaply refresh the two keys on every entry point; no metadata scan needed.
|
||||
*******************************************************************************/
|
||||
public static void ensureSessionKeys(QInstance instance, QSession session)
|
||||
{
|
||||
applyToSession(instance, session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication's stable user reference, never a display name or session id.
|
||||
*******************************************************************************/
|
||||
public static String userId(QSession session)
|
||||
{
|
||||
return (session == null || session.getUser() == null ? null : session.getUser().getIdReference());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Initialize before direct query/get/count/aggregate/insert/update/delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
ensureSessionKeys(QContext.getQInstance(), session);
|
||||
if(!(session instanceof QSystemUserSession) && (userId(session) == null || userId(session).isBlank()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Sign in to manage favorites and recents."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
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.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.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.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared current-session persistence. Explicit user filters keep personal service
|
||||
** calls scoped to the caller even when administrators hold the all-access key.
|
||||
*******************************************************************************/
|
||||
public final class PersonalRecords
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static operations only.
|
||||
*******************************************************************************/
|
||||
private PersonalRecords()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require an authenticated owner before accepting a personal operation.
|
||||
*******************************************************************************/
|
||||
public static String currentUserId() throws QPermissionDeniedException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
String userId = CollaborationSecurity.userId(QContext.getQSession());
|
||||
if(userId == null || userId.isBlank())
|
||||
{
|
||||
throw (new QPermissionDeniedException("Sign in to manage favorites and recents."));
|
||||
}
|
||||
return (userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always constrain personal services to the current owner.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter ownFilter() throws QPermissionDeniedException
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("userId", QCriteriaOperator.EQUALS, currentUserId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null record ids use IS_BLANK, not SQL equality with NULL.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter targetFilter(String type, String name, String recordId) throws QPermissionDeniedException
|
||||
{
|
||||
QQueryFilter filter = ownFilter().withCriteria("targetType", QCriteriaOperator.EQUALS, type)
|
||||
.withCriteria("targetName", QCriteriaOperator.EQUALS, name);
|
||||
if(recordId == null)
|
||||
{
|
||||
filter.withCriteria("recordId", QCriteriaOperator.IS_BLANK);
|
||||
}
|
||||
else
|
||||
{
|
||||
filter.withCriteria("recordId", QCriteriaOperator.EQUALS, recordId);
|
||||
}
|
||||
return (filter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforce permissions independently of the host's core-action default.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Propagate per-record validation failures instead of reporting success.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
InsertInput input = new InsertInput(table).withRecords(List.of(record));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
QRecord result = new InsertAction().execute(input).getRecords().getFirst();
|
||||
checkErrors(List.of(result));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update only fields supplied by the service, using the current session.
|
||||
*******************************************************************************/
|
||||
public static void update(String table, List<QRecord> records) throws QException
|
||||
{
|
||||
if(records.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
UpdateInput input = new UpdateInput(table).withRecords(records);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete already selected ids; row locks are still enforced by DeleteAction.
|
||||
*******************************************************************************/
|
||||
public static void delete(String table, List<Serializable> ids) throws QException
|
||||
{
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
DeleteInput input = new DeleteInput(table).withPrimaryKeys(ids);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ actions can return record errors without throwing.
|
||||
*******************************************************************************/
|
||||
private static void checkErrors(List<QRecord> records) throws QException
|
||||
{
|
||||
if(records != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not save personal target: " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* 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.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
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.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QValueException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves targets only with released RC.5 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.
|
||||
*******************************************************************************/
|
||||
public final class TargetResolver
|
||||
{
|
||||
private static final Set<String> TYPES = Set.of("TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static operations only.
|
||||
*******************************************************************************/
|
||||
private TargetResolver()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate target shape without requiring visibility (also used to unstar).
|
||||
*******************************************************************************/
|
||||
public static String recordId(String type, String name, Serializable recordId) throws QException
|
||||
{
|
||||
String id = recordId == null ? null : recordId.toString();
|
||||
if(type == null || !TYPES.contains(type) || name == null || name.isBlank()
|
||||
|| ("RECORD".equals(type) ? id == null || id.isBlank() : id != null))
|
||||
{
|
||||
throw (new QException("Invalid target"));
|
||||
}
|
||||
if("RECORD".equals(type))
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(name);
|
||||
if(table != null && table.getPrimaryKeyField() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
id = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), id).toString();
|
||||
}
|
||||
catch(QValueException expected)
|
||||
{
|
||||
throw (new QException("Not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve one target for writes; missing and invisible have the same error.
|
||||
*******************************************************************************/
|
||||
public static FavoriteEntry require(String type, String name, Serializable id) throws QException
|
||||
{
|
||||
String recordId = recordId(type, name, id);
|
||||
QRecord row = new QRecord().withValue("targetType", type).withValue("targetName", name).withValue("recordId", recordId);
|
||||
FavoriteEntry entry;
|
||||
if("RECORD".equals(type))
|
||||
{
|
||||
QRecord record = get(name, recordId);
|
||||
entry = entry(row, record == null ? null : record.getRecordLabel(), record == null ? null : path(name) + encode(recordId) + "/");
|
||||
}
|
||||
else
|
||||
{
|
||||
entry = metadataEntry(row);
|
||||
}
|
||||
if(!entry.available())
|
||||
{
|
||||
throw (new QException("Not found"));
|
||||
}
|
||||
return (entry);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One QueryAction per referenced record table; no cached labels escape.
|
||||
*******************************************************************************/
|
||||
public static List<FavoriteEntry> refresh(List<QRecord> rows) throws QException
|
||||
{
|
||||
Map<String, List<String>> idsByTable = new LinkedHashMap<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
if("RECORD".equals(row.getValueString("targetType")))
|
||||
{
|
||||
idsByTable.computeIfAbsent(row.getValueString("targetName"), key -> new ArrayList<>()).add(row.getValueString("recordId"));
|
||||
}
|
||||
}
|
||||
Map<String, Map<String, QRecord>> recordsByTable = new HashMap<>();
|
||||
for(Map.Entry<String, List<String>> group : idsByTable.entrySet())
|
||||
{
|
||||
String table = group.getKey();
|
||||
Map<String, QRecord> records = new HashMap<>();
|
||||
recordsByTable.put(table, records);
|
||||
if(canReadTable(table))
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withShouldGenerateDisplayValues(true)
|
||||
.withFilter(new QQueryFilter().withCriteria(QContext.getQInstance().getTable(table).getPrimaryKeyField(), QCriteriaOperator.IN, group.getValue()));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
try
|
||||
{
|
||||
for(QRecord record : new QueryAction().execute(input).getRecords())
|
||||
{
|
||||
records.put(record.getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()), record);
|
||||
}
|
||||
}
|
||||
catch(QPermissionDeniedException expected)
|
||||
{
|
||||
// Permission may have changed since the metadata check; fail closed.
|
||||
}
|
||||
}
|
||||
}
|
||||
List<FavoriteEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
String table = row.getValueString("targetName");
|
||||
String id = row.getValueString("recordId");
|
||||
if("RECORD".equals(row.getValueString("targetType")))
|
||||
{
|
||||
QRecord record = recordsByTable.get(table).get(id);
|
||||
entries.add(entry(row, record == null ? null : record.getRecordLabel(), record == null ? null : path(table) + encode(id) + "/"));
|
||||
}
|
||||
else
|
||||
{
|
||||
entries.add(metadataEntry(row));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata targets use the same permission helpers as core. Saved views also
|
||||
** require a visible savedView row and READ on the view's target table.
|
||||
*******************************************************************************/
|
||||
private static FavoriteEntry metadataEntry(QRecord row) throws QException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
AbstractActionInput input = new AbstractActionInput();
|
||||
String name = row.getValueString("targetName");
|
||||
String label = null;
|
||||
String href = null;
|
||||
switch(row.getValueString("targetType"))
|
||||
{
|
||||
case "TABLE" ->
|
||||
{
|
||||
if(canReadTable(name))
|
||||
{
|
||||
label = instance.getTable(name).getLabel();
|
||||
href = path(name);
|
||||
}
|
||||
}
|
||||
case "APP" ->
|
||||
{
|
||||
if(PermissionsHelper.hasAppPermission(input, name))
|
||||
{
|
||||
label = instance.getApp(name).getLabel();
|
||||
href = path(name);
|
||||
}
|
||||
}
|
||||
case "PROCESS" ->
|
||||
{
|
||||
if(PermissionsHelper.hasProcessPermission(input, name))
|
||||
{
|
||||
label = instance.getProcess(name).getLabel();
|
||||
href = path(name);
|
||||
}
|
||||
}
|
||||
case "REPORT" ->
|
||||
{
|
||||
if(PermissionsHelper.hasReportPermission(input, name))
|
||||
{
|
||||
label = instance.getReport(name).getLabel();
|
||||
href = path(name);
|
||||
}
|
||||
}
|
||||
case "SAVED_VIEW" ->
|
||||
{
|
||||
QRecord view = get("savedView", name);
|
||||
if(view != null && canReadTable(view.getValueString("tableName")))
|
||||
{
|
||||
label = view.getValueString("label");
|
||||
href = path(view.getValueString("tableName")) + "savedView/" + encode(name) + "/";
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
// Unknown persisted types are unavailable rather than exposing their cache.
|
||||
}
|
||||
}
|
||||
return (entry(row, label, href));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both table visibility and per-record security are checked by GetAction.
|
||||
*******************************************************************************/
|
||||
private static QRecord get(String table, Serializable id) throws QException
|
||||
{
|
||||
if(!canReadTable(table))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
GetInput input = new GetInput(table).withPrimaryKey(id).withShouldGenerateDisplayValues(true);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
try
|
||||
{
|
||||
return (new GetAction().execute(input).getRecord());
|
||||
}
|
||||
catch(QPermissionDeniedException expected)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guard existence before invoking the core table permission helper.
|
||||
*******************************************************************************/
|
||||
private static boolean canReadTable(String table)
|
||||
{
|
||||
return (table != null && QContext.getQInstance().getTable(table) != null
|
||||
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), table, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing href means unavailable; never substitute a cached label.
|
||||
*******************************************************************************/
|
||||
private static FavoriteEntry entry(QRecord row, String label, String href)
|
||||
{
|
||||
return (new FavoriteEntry(row.getValueInteger("id"), row.getValueString("targetType"), row.getValueString("targetName"),
|
||||
row.getValueString("recordId"), href == null ? "Unavailable" : label == null ? row.getValueString("targetName") : label,
|
||||
row.getValueString("folder"), row.getValueInteger("sortOrder"), href != null, href));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Flat Next router path; trailing slash also supports dotted process names.
|
||||
*******************************************************************************/
|
||||
private static String path(String name)
|
||||
{
|
||||
return ("/app/" + encode(name) + "/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode a single route segment, including slash, hash and spaces in ids.
|
||||
*******************************************************************************/
|
||||
private static String encode(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
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.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
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.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
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.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Collaboration app and its home. Counts use QQQ actions with the current
|
||||
** QContext session, preserving permissions and record security.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationHome
|
||||
{
|
||||
public static final String HOME_WIDGET = "collaborationHome";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationHome()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Main tables only; processes never appear in navigation.
|
||||
*******************************************************************************/
|
||||
public static QAppMetaData app(CollaborationQBitConfig config)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.withWidgets(List.of(HOME_WIDGET))
|
||||
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
|
||||
.withTables(List.of(CollaborationTables.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
return (app);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full-width HTML widget, following the reference Storage home renderer.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData widget()
|
||||
{
|
||||
return (new QWidgetMetaData().withName(HOME_WIDGET).withLabel("Overview").withType(WidgetType.HTML.getType())
|
||||
.withCodeReference(new QCodeReference(HomeRenderer.class)).withGridColumns(12).withIsCard(false)
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Show persisted enabled overrides by scope type, and recent directory use.
|
||||
** These are settings counts, not a claim about resolved host-table features.
|
||||
*******************************************************************************/
|
||||
public static String render(Instant now) throws QException
|
||||
{
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
StringBuilder html = new StringBuilder("<div style=\"display:flex;flex-direction:column;gap:1rem;width:100%;overflow-wrap:anywhere;\"><section><h3>Feature settings</h3>");
|
||||
long settings = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter());
|
||||
if(settings == 0)
|
||||
{
|
||||
html.append("<p>No feature settings yet: features follow the code defaults</p>");
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<p>Enabled feature settings per scope:</p><ul>");
|
||||
for(String scope : List.of("INSTANCE", "APP", "TABLE"))
|
||||
{
|
||||
long enabled = count(CollaborationTables.FEATURE_SETTING, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("scopeType", QCriteriaOperator.EQUALS, scope))
|
||||
.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, true)));
|
||||
html.append("<li>").append(scope).append(": ").append(enabled).append("</li>");
|
||||
}
|
||||
html.append("</ul><p>Stored overrides; applying them awaits the core collaboration contracts.</p>");
|
||||
}
|
||||
long people = count(CollaborationTables.USER, new QQueryFilter());
|
||||
long recent = count(CollaborationTables.USER, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS)))
|
||||
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)));
|
||||
html.append("</section><section><h3>People</h3><p>People known: ").append(people)
|
||||
.append("</p><p>Active in the last 7 days: ").append(recent).append("</p>");
|
||||
if(people == 0)
|
||||
{
|
||||
html.append("<p>No people yet: the directory will be filled by collaboration calls when the feature services are installed.</p>");
|
||||
}
|
||||
html.append("</section>");
|
||||
personalSummary(html, CollaborationTables.FAVORITE, "Favorites");
|
||||
personalSummary(html, CollaborationTables.RECENT, "Recently viewed");
|
||||
attachmentSummary(html);
|
||||
return (html.append("</div>").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real table aggregates, restricted to readable parent tables. Parent locks
|
||||
** are post-filtered on rows, so these aggregate totals are approximate.
|
||||
*******************************************************************************/
|
||||
private static void attachmentSummary(StringBuilder html) throws QException
|
||||
{
|
||||
Aggregate count = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
|
||||
Aggregate bytes = new Aggregate().withFieldName("sizeBytes").withOperator(AggregateOperator.SUM);
|
||||
AggregateInput input = new AggregateInput(CollaborationTables.ATTACHMENT).withAggregates(List.of(count, bytes));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
|
||||
Object rowCount = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(count);
|
||||
Object totalBytes = results.isEmpty() ? 0 : results.getFirst().getAggregateValue(bytes);
|
||||
html.append("<section><h3>Attachments</h3><p>Attachments: ").append(rowCount)
|
||||
.append("</p><p>Total size (bytes): ").append(totalBytes == null ? 0 : totalBytes)
|
||||
.append("</p><p>Approximate totals: parent record locks are applied when browsing attachments.</p>");
|
||||
if("0".equals(String.valueOf(rowCount)))
|
||||
{
|
||||
html.append("<p>No attachments yet.</p>");
|
||||
}
|
||||
html.append("</section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate rows and distinct owners without loading high-volume personal data.
|
||||
*******************************************************************************/
|
||||
private static void personalSummary(StringBuilder html, String table, String label) throws QException
|
||||
{
|
||||
Aggregate rows = new Aggregate().withFieldName("id").withOperator(AggregateOperator.COUNT);
|
||||
Aggregate users = new Aggregate().withFieldName("userId").withOperator(AggregateOperator.COUNT_DISTINCT);
|
||||
AggregateInput input = new AggregateInput(table).withAggregates(List.of(rows, users));
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<AggregateResult> results = new AggregateAction().execute(input).getResults();
|
||||
html.append("<section><h3>").append(label).append("</h3><p>Rows: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(rows))
|
||||
.append("</p><p>Distinct users: ").append(results.isEmpty() ? 0 : results.getFirst().getAggregateValue(users)).append("</p></section>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count without materializing rows or substituting a privileged session.
|
||||
*******************************************************************************/
|
||||
private static long count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
CountInput input = new CountInput(table).withFilter(filter);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(input).getCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget renderer instantiated by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class HomeRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render using the current session and time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new RawHTML(null, CollaborationHome.render(Instant.now()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Trusted after-commit callback. Wire to core RecordReferenceCleanupInterface
|
||||
** when released. Never expose as a user endpoint: the parent is already gone.
|
||||
*******************************************************************************/
|
||||
public class AttachmentCleanup
|
||||
{
|
||||
private final AttachmentsService service;
|
||||
|
||||
/*******************************************************************************
|
||||
** Production cleanup using the installed qbit and object storage.
|
||||
*******************************************************************************/
|
||||
public AttachmentCleanup()
|
||||
{
|
||||
this(new AttachmentsService());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the same storage boundary as normal deletion.
|
||||
*******************************************************************************/
|
||||
public AttachmentCleanup(AttachmentsService service)
|
||||
{
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated calls are safe; provider failures leave the row for a later retry.
|
||||
*******************************************************************************/
|
||||
public void onParentsDeleted(String table, List<? extends Serializable> ids) throws QException
|
||||
{
|
||||
if(ids.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
|
||||
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
service.deleteStoredRow(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/*******************************************************************************
|
||||
** Panel DTO. Storage locations and ephemeral signed URLs stay server-side.
|
||||
*******************************************************************************/
|
||||
public record AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
|
||||
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
|
||||
{
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Filter invisible parents before generating ephemeral preview/download URLs.
|
||||
** Counts are approximate and pages can be short until core mirrors locks.
|
||||
*******************************************************************************/
|
||||
public class AttachmentPreviewCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AttachmentPreviewCustomizer.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** GetAction expects one result: throw Not found rather than return an empty list.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
return (filter(records, input instanceof GetInput, new AttachmentsService()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep filtering every row even when the preview generation cap is reached.
|
||||
*******************************************************************************/
|
||||
List<QRecord> filter(List<QRecord> records, boolean isGet, AttachmentsService service) throws QException
|
||||
{
|
||||
List<QRecord> visible = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(Boolean.TRUE.equals(record.getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
ParentRecordAccess.requireVisibleParent(record.getValueString("tableName"), record.getValueString("recordId"));
|
||||
}
|
||||
catch(QNotFoundException expected)
|
||||
{
|
||||
if(isGet)
|
||||
{
|
||||
throw (expected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Never retain URLs from a previous read of a memory-backend record.
|
||||
QRecord row = new QRecord(record);
|
||||
row.setValue("previewUrl", null);
|
||||
row.setValue("downloadUrl", null);
|
||||
row.withDisplayValue("downloadUrl", null).withDisplayValue("downloadUrl:downloadUrlDynamic", null);
|
||||
visible.add(row);
|
||||
if(visible.size() <= 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
row.setValue("previewUrl", service.url(row, true).toString());
|
||||
String downloadUrl = service.url(row, false).toString();
|
||||
row.setValue("downloadUrl", downloadUrl);
|
||||
row.withDisplayValue("downloadUrl", row.getValueString("fileName"))
|
||||
.withDisplayValue("downloadUrl:downloadUrlDynamic", downloadUrl);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
LOG.debug("Could not make an attachment preview URL", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (visible);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-private raw reads for services and trusted parent cleanup. Callers
|
||||
** must check the parent before exposing any values or performing user writes.
|
||||
*******************************************************************************/
|
||||
final class AttachmentRecords
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static persistence helpers only.
|
||||
*******************************************************************************/
|
||||
private AttachmentRecords()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use a per-input clone so shared metadata and session remain unchanged.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> query(QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withFilter(filter).withShouldFetchHeavyFields(true);
|
||||
input.setTableMetaData(storageTable());
|
||||
input.withPermissionBypass("Attachment service checks parent access; cleanup is a trusted after-delete callback.");
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal persistence must not generate URLs or filter a deleted parent.
|
||||
** Clone only this action's metadata; retain auditing and backend behavior.
|
||||
*******************************************************************************/
|
||||
static QTableMetaData storageTable()
|
||||
{
|
||||
return (QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT).clone().withCustomizers(null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ validation errors may be returned on rows rather than thrown.
|
||||
*******************************************************************************/
|
||||
static void checkErrors(List<QRecord> records) throws QException
|
||||
{
|
||||
if(records != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Could not save attachment: " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrCountInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 has no PRE_QUERY customizer. Its permission checker provides the
|
||||
** pre-query restriction for query, count and aggregate; post-query checks
|
||||
** handle parent record locks. Direct writes must use the attachment service.
|
||||
*******************************************************************************/
|
||||
public class AttachmentSecurity implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Browse requires collaboration.view; writes cannot bypass storage lifecycle.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput)
|
||||
{
|
||||
throw (new QPermissionDeniedException("Use the attachment service or Delete attachments action."));
|
||||
}
|
||||
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
|
||||
if(input instanceof QueryOrCountInputInterface query)
|
||||
{
|
||||
restrict(query);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** AND the restriction outside any user OR clauses; preserve paging and sort.
|
||||
** Counts and aggregates are approximate because parent locks are post-filtered.
|
||||
*******************************************************************************/
|
||||
public static void restrict(QueryOrCountInputInterface input)
|
||||
{
|
||||
List<String> readable = QContext.getQInstance().getTables().keySet().stream().filter(ParentRecordAccess::canReadTable).toList();
|
||||
QQueryFilter original = input.getFilter();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("tableName", QCriteriaOperator.IN, readable)
|
||||
.withCriteria("parentDeleted", QCriteriaOperator.EQUALS, false);
|
||||
if(original != null)
|
||||
{
|
||||
filter.withSubFilter(original).withLimit(original.getLimit()).withSkip(original.getSkip());
|
||||
filter.setOrderBys(original.getOrderBys());
|
||||
}
|
||||
input.setFilter(filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.objectstorage.api.DeleteMode;
|
||||
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.StorageBucket;
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow storage boundary. The caller must check collaboration access first.
|
||||
** Tests can substitute an in-memory implementation without an S3 service.
|
||||
*******************************************************************************/
|
||||
public class AttachmentStorage
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Tag storage operations as the calling qbit, retaining the initiating user.
|
||||
*******************************************************************************/
|
||||
private StorageBucket bucket(String name)
|
||||
{
|
||||
return (ObjectStorage.asSystem("collaboration", UUID.randomUUID().toString()).bucket(name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforce the bucket's cap as well as collaboration's configured cap.
|
||||
*******************************************************************************/
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (bucket(name).config().getMaxUploadBytes());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Put a bounded stream through object storage's multipart implementation.
|
||||
*******************************************************************************/
|
||||
public void put(String name, PutRequest request)
|
||||
{
|
||||
bucket(name).put(request);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Prefer soft deletion; allow permanent deletion only where no soft mode exists.
|
||||
*******************************************************************************/
|
||||
public void delete(String name, String key)
|
||||
{
|
||||
if(bucket(name).delete(DeleteRequest.of(key).withAllowPermanentWhenNoSoftDelete(true)).getMode() == DeleteMode.SKIPPED_OBJECT_LOCK)
|
||||
{
|
||||
throw (new IllegalStateException("Object retention prevents attachment deletion."));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a temporary link, never a persistent table value.
|
||||
*******************************************************************************/
|
||||
public URL presign(String name, PresignGet request)
|
||||
{
|
||||
return (bucket(name).presignGet(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.Serializable;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Attachment operations with parent access checked using the caller's session.
|
||||
*******************************************************************************/
|
||||
public class AttachmentsService
|
||||
{
|
||||
private final CollaborationQBitConfig config;
|
||||
private final AttachmentStorage storage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the installed configuration without a global mutable singleton.
|
||||
*******************************************************************************/
|
||||
public AttachmentsService()
|
||||
{
|
||||
this((CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig(), new AttachmentStorage());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable storage boundary for deterministic service tests.
|
||||
*******************************************************************************/
|
||||
public AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage)
|
||||
{
|
||||
this.config = config;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream to a bounded temporary file while hashing, then put and insert.
|
||||
** The caller owns the input stream. Oversize files never reach object storage.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException
|
||||
{
|
||||
ParentRecordAccess.requireFeature(tableName, recordId, "attach");
|
||||
String user = requireUser();
|
||||
String id = canonicalId(tableName, recordId);
|
||||
String name = sanitize(fileName);
|
||||
String bucket = config.getAttachmentsBucketName();
|
||||
String key = "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name;
|
||||
if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024
|
||||
|| (contentType != null && contentType.length() > 255))
|
||||
{
|
||||
throw (new QException("Attachment metadata exceeds the supported field lengths."));
|
||||
}
|
||||
long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket));
|
||||
Path temporary = null;
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
temporary = Files.createTempFile("collaboration-attachment-", ".upload");
|
||||
long size = 0;
|
||||
try(OutputStream out = Files.newOutputStream(temporary))
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while((read = stream.read(buffer)) != -1)
|
||||
{
|
||||
if(read > limit - size)
|
||||
{
|
||||
throw (new QException("Attachment exceeds the size limit of " + limit + " bytes."));
|
||||
}
|
||||
size += read;
|
||||
digest.update(buffer, 0, read);
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
|
||||
try(InputStream upload = Files.newInputStream(temporary))
|
||||
{
|
||||
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
|
||||
}
|
||||
try
|
||||
{
|
||||
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
|
||||
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
|
||||
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
|
||||
.withValue("createdByUserId", user).withValue("parentDeleted", false);
|
||||
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withRecords(List.of(row));
|
||||
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
|
||||
List<QRecord> records = new InsertAction().execute(input).getRecords();
|
||||
AttachmentRecords.checkErrors(records);
|
||||
return (entry(records.getFirst()));
|
||||
}
|
||||
catch(QException | RuntimeException failure)
|
||||
{
|
||||
try
|
||||
{
|
||||
storage.delete(bucket, key);
|
||||
}
|
||||
catch(RuntimeException cleanupFailure)
|
||||
{
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
catch(IOException | NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new QException("Could not read attachment upload", e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(temporary != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
temporary.toFile().deleteOnExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest first, independently of collaboration.view.
|
||||
*******************************************************************************/
|
||||
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
String id = canonicalId(tableName, recordId);
|
||||
List<AttachmentEntry> entries = new ArrayList<>();
|
||||
for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
|
||||
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true)
|
||||
.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false))))
|
||||
{
|
||||
entries.add(entry(row));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-check the attachment's actual parent before signing a five-minute GET.
|
||||
** Inline uses the stored inline header; RC.2 only overrides attachment names.
|
||||
*******************************************************************************/
|
||||
public URL downloadUrl(Serializable attachmentId, boolean inline) throws QException
|
||||
{
|
||||
return (url(requireRow(attachmentId), inline));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign an already-authorized row, also used by the preview customizer.
|
||||
*******************************************************************************/
|
||||
URL url(QRecord row, boolean inline)
|
||||
{
|
||||
PresignGet request = PresignGet.of(row.getValueString("storageKey")).ttl(Duration.ofMinutes(5));
|
||||
if(!inline)
|
||||
{
|
||||
request.downloadAs(row.getValueString("fileName"));
|
||||
}
|
||||
return (storage.presign(row.getValueString("bucketName"), request));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize all selected rows before the first storage mutation.
|
||||
*******************************************************************************/
|
||||
public void delete(List<? extends Serializable> attachmentIds) throws QException
|
||||
{
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(Serializable id : attachmentIds.stream().distinct().toList())
|
||||
{
|
||||
rows.add(requireMutable(id));
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
deleteStoredRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Change display metadata only; the immutable storage key stays stable.
|
||||
** Null means unchanged; an empty description clears it.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry rename(Serializable attachmentId, String fileName, String description) throws QException
|
||||
{
|
||||
QRecord row = requireMutable(attachmentId);
|
||||
QRecord change = new QRecord().withValue("id", row.getValue("id"));
|
||||
if(fileName != null)
|
||||
{
|
||||
change.setValue("fileName", sanitize(fileName));
|
||||
}
|
||||
if(description != null)
|
||||
{
|
||||
change.setValue("description", description);
|
||||
}
|
||||
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withRecords(List.of(change));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("AttachmentsService checked the actual parent and author/admin before metadata update.");
|
||||
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
|
||||
return (entry(requireRow(attachmentId)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Description-only convenience operation.
|
||||
*******************************************************************************/
|
||||
public AttachmentEntry describe(Serializable attachmentId, String description) throws QException
|
||||
{
|
||||
return (rename(attachmentId, null, description));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Process Review uses persisted identity rather than trusting client rows.
|
||||
*******************************************************************************/
|
||||
public QRecord requireMutable(Serializable attachmentId) throws QException
|
||||
{
|
||||
QRecord row = requireRow(attachmentId);
|
||||
requireUser();
|
||||
if(!canDelete(row))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment."));
|
||||
}
|
||||
if(!isAdmin())
|
||||
{
|
||||
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete storage first so a provider failure leaves a retryable row.
|
||||
*******************************************************************************/
|
||||
void deleteStoredRow(QRecord row) throws QException
|
||||
{
|
||||
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
|
||||
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id")));
|
||||
input.setTableMetaData(AttachmentRecords.storageTable());
|
||||
input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row.");
|
||||
AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing rows and invisible parents have the same response.
|
||||
*******************************************************************************/
|
||||
private QRecord requireRow(Serializable id) throws QException
|
||||
{
|
||||
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id));
|
||||
if(rows.isEmpty() || Boolean.TRUE.equals(rows.getFirst().getValueBoolean("parentDeleted")))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
ParentRecordAccess.requireVisibleParent(row.getValueString("tableName"), row.getValueString("recordId"));
|
||||
return (row);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the parent's actual primary key so alternate numeric strings converge.
|
||||
*******************************************************************************/
|
||||
private String canonicalId(String table, Serializable id) throws QException
|
||||
{
|
||||
return (ParentRecordAccess.requireVisibleParent(table, id).getValueString(QContext.getQInstance().getTable(table).getPrimaryKeyField()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ownership is layered on parent visibility; admins still need that visibility.
|
||||
*******************************************************************************/
|
||||
private boolean canDelete(QRecord row)
|
||||
{
|
||||
String user = CollaborationSecurity.userId(QContext.getQSession());
|
||||
return (user != null && (isAdmin() || user.equals(row.getValueString("createdByUserId"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Collaboration moderation grant.
|
||||
*******************************************************************************/
|
||||
private boolean isAdmin()
|
||||
{
|
||||
return (QContext.getQSession() != null && QContext.getQSession().hasPermission(CollaborationPermissions.ADMIN));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable author identity is mandatory for writes.
|
||||
*******************************************************************************/
|
||||
private String requireUser() throws QPermissionDeniedException
|
||||
{
|
||||
String user = CollaborationSecurity.userId(QContext.getQSession());
|
||||
if(user == null || user.isBlank())
|
||||
{
|
||||
throw (new QPermissionDeniedException("Sign in to manage attachments."));
|
||||
}
|
||||
return (user);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return display data, without internal storage coordinates.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry entry(QRecord row)
|
||||
{
|
||||
return (new AttachmentEntry(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
|
||||
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueLong("sizeBytes"), row.getValueString("sha256"),
|
||||
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canDelete(row)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strip paths and unsafe header/key characters; enforce extension policy.
|
||||
*******************************************************************************/
|
||||
private String sanitize(String fileName) throws QException
|
||||
{
|
||||
if(fileName == null || fileName.isBlank())
|
||||
{
|
||||
throw (new QException("A file name is required."));
|
||||
}
|
||||
String name = fileName.replace('\\', '/');
|
||||
name = name.substring(name.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._ -]", "_").strip();
|
||||
if(name.isBlank() || name.equals(".") || name.equals("..") || name.length() > 255)
|
||||
{
|
||||
throw (new QException("Invalid file name."));
|
||||
}
|
||||
String extension = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
|
||||
if(config.getBlockedExtensions().stream().anyMatch(blocked -> extension.equals(blocked.replaceFirst("^\\.", "").toLowerCase(Locale.ROOT))))
|
||||
{
|
||||
throw (new QException("This file extension is blocked."));
|
||||
}
|
||||
return (name);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep arbitrary string primary keys inside one object-key segment.
|
||||
*******************************************************************************/
|
||||
private String segment(String value)
|
||||
{
|
||||
return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20").replace(".", "%2E"));
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** Record action with the standard streamed ETL Review and Result screens.
|
||||
*******************************************************************************/
|
||||
public final class DeleteAttachmentsProcess
|
||||
{
|
||||
public static final String NAME = "collaborationDeleteAttachments";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata factory only.
|
||||
*******************************************************************************/
|
||||
private DeleteAttachmentsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Register on the table, never in navigation.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.ATTACHMENT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.ATTACHMENT);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"Delete the selected attachments. Storage uses versioning or trash where configured; otherwise deletion is permanent.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Delete.class, values)
|
||||
.withName(NAME).withLabel("Delete attachments").withIcon(new QIcon("delete"))
|
||||
.withTableName(CollaborationTables.ATTACHMENT).withPermissionRules(CollaborationPermissions.permissionRules()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Future and past wording for the standard summary renderer.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String past)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage("attachment " + future);
|
||||
line.setPluralFutureMessage("attachments " + future);
|
||||
line.setSingularPastMessage("attachment " + past);
|
||||
line.setPluralPastMessage("attachments " + past);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review re-reads persisted rows and skips unauthorized selections.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentsService service;
|
||||
private final ProcessSummaryLine selected = line(Status.OK, "will be deleted", "deleted");
|
||||
private final ProcessSummaryLine skipped = line(Status.WARNING, "cannot be deleted (skipped)", "skipped");
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ constructor.
|
||||
*******************************************************************************/
|
||||
public Review()
|
||||
{
|
||||
this(new AttachmentsService());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable service for process tests.
|
||||
*******************************************************************************/
|
||||
public Review(AttachmentsService service)
|
||||
{
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Dry run only; no object or row mutations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord checked = service.requireMutable(row.getValue("id"));
|
||||
output.getRecords().add(checked);
|
||||
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
|
||||
}
|
||||
catch(QException expected)
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review summary includes authorized and skipped counts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
selected.addSelfToListIfAnyCount(lines);
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute and re-authorize each row; one failure does not stop the batch.
|
||||
*******************************************************************************/
|
||||
public static class Delete extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final AttachmentsService service;
|
||||
private final ProcessSummaryLine deleted = line(Status.OK, "will be deleted", "deleted");
|
||||
private final ProcessSummaryLine failed = line(Status.ERROR, "could not be deleted", "could not be deleted (retry required)");
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ constructor.
|
||||
*******************************************************************************/
|
||||
public Delete()
|
||||
{
|
||||
this(new AttachmentsService());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable service for process tests.
|
||||
*******************************************************************************/
|
||||
public Delete(AttachmentsService service)
|
||||
{
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count success only after both storage and row deletion succeed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord row : input.getRecords())
|
||||
{
|
||||
try
|
||||
{
|
||||
service.delete(List.of(row.getValue("id")));
|
||||
deleted.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
catch(QException | RuntimeException expected)
|
||||
{
|
||||
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Actual results, plus the Review's skipped rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
deleted.addSelfToListIfAnyCount(lines);
|
||||
if(getTransformStep() instanceof Review review)
|
||||
{
|
||||
review.skipped.addSelfToListIfAnyCount(lines);
|
||||
}
|
||||
failed.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.favorites;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A freshly permission-checked target. Unavailable entries never expose the
|
||||
** cached label or a navigable href. Also used for the ordered recents list.
|
||||
*******************************************************************************/
|
||||
public record FavoriteEntry(Integer id, String targetType, String targetName, String recordId,
|
||||
String label, String folder, Integer sortOrder, Boolean available, String href)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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.favorites;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.TargetResolver;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current-user favorites. All actions preserve QContext; administrators use the
|
||||
** standard table screens for cross-user management, not these personal methods.
|
||||
*******************************************************************************/
|
||||
public final class FavoritesService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static service.
|
||||
*******************************************************************************/
|
||||
private FavoritesService()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotently star a visible target, appending it after existing favorites.
|
||||
*******************************************************************************/
|
||||
public static void star(String targetType, String targetName, Serializable recordId) throws QException
|
||||
{
|
||||
String userId = PersonalRecords.currentUserId();
|
||||
FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId);
|
||||
QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId());
|
||||
if(!PersonalRecords.query(CollaborationTables.FAVORITE, filter).isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<QRecord> last = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter()
|
||||
.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);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.FAVORITE, row);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(PersonalRecords.query(CollaborationTables.FAVORITE, filter).isEmpty())
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
// A concurrent star won the unique-key race; the requested state exists.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing a star does not require continued access to its target.
|
||||
*******************************************************************************/
|
||||
public static void unstar(String targetType, String targetName, Serializable recordId) throws QException
|
||||
{
|
||||
String id = TargetResolver.recordId(targetType, targetName, recordId);
|
||||
List<QRecord> rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.targetFilter(targetType, targetName, id));
|
||||
PersonalRecords.delete(CollaborationTables.FAVORITE, rows.stream().map(row -> row.getValue("id")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered favorites with freshly checked labels and availability.
|
||||
*******************************************************************************/
|
||||
public static List<FavoriteEntry> list() throws QException
|
||||
{
|
||||
return (TargetResolver.refresh(PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter()
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Move the supplied ids first, preserving order of omitted favorites. Folder
|
||||
** values may be null to clear them. Validate all ids before writing anything.
|
||||
*******************************************************************************/
|
||||
public static void reorder(List<Integer> ids, Map<Integer, String> folders) throws QException
|
||||
{
|
||||
List<QRecord> rows = PersonalRecords.query(CollaborationTables.FAVORITE, PersonalRecords.ownFilter()
|
||||
.withOrderBy(new QFilterOrderBy("sortOrder")).withOrderBy(new QFilterOrderBy("id")));
|
||||
Set<Integer> owned = new HashSet<>(rows.stream().map(row -> row.getValueInteger("id")).toList());
|
||||
if(ids == null || folders == null || new HashSet<>(ids).size() != ids.size() || !owned.containsAll(ids) || !owned.containsAll(folders.keySet()))
|
||||
{
|
||||
throw (new QException("Invalid favorite ids"));
|
||||
}
|
||||
for(String folder : folders.values())
|
||||
{
|
||||
if(folder != null && folder.length() > 255)
|
||||
{
|
||||
throw (new QException("Folder is too long"));
|
||||
}
|
||||
}
|
||||
List<Integer> order = new ArrayList<>(ids);
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
Integer id = row.getValueInteger("id");
|
||||
if(!ids.contains(id))
|
||||
{
|
||||
order.add(id);
|
||||
}
|
||||
}
|
||||
List<QRecord> changes = new ArrayList<>();
|
||||
for(int i = 0; i < order.size(); i++)
|
||||
{
|
||||
Integer id = order.get(i);
|
||||
QRecord change = new QRecord().withValue("id", id).withValue("sortOrder", i);
|
||||
if(folders.containsKey(id))
|
||||
{
|
||||
change.setValue("folder", folders.get(id));
|
||||
}
|
||||
changes.add(change);
|
||||
}
|
||||
PersonalRecords.update(CollaborationTables.FAVORITE, changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentPreviewCustomizer;
|
||||
import com.kingsrook.qbits.collaboration.attachments.AttachmentSecurity;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QVirtualFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSectionAlternativeType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Visible Collaboration tables. Enum ids are phase-a stand-ins for the core
|
||||
** collaboration enums; replace them when those contracts are released.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationTables
|
||||
{
|
||||
public static final String ATTACHMENT = "collaboration_attachment";
|
||||
public static final String FEATURE_SETTING = "collaboration_feature_setting";
|
||||
public static final String FAVORITE = "collaboration_favorite";
|
||||
public static final String RECENT = "collaboration_recent";
|
||||
public static final String TARGET_TYPE = "collaboration_target_type";
|
||||
public static final String USER = "collaboration_user";
|
||||
public static final String SCOPE_TYPE = "collaboration_scope_type";
|
||||
public static final String FEATURE = "collaboration_feature";
|
||||
public static final String PERMISSION_MODE = "collaboration_permission_mode";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enumerated feature settings and table references.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
return (List.of(
|
||||
enumSource(TARGET_TYPE, "TABLE", "APP", "RECORD", "PROCESS", "REPORT", "SAVED_VIEW"),
|
||||
QPossibleValueSource.newForTable(ATTACHMENT), QPossibleValueSource.newForTable(FAVORITE), QPossibleValueSource.newForTable(RECENT),
|
||||
enumSource(SCOPE_TYPE, "INSTANCE", "APP", "TABLE"),
|
||||
enumSource(FEATURE, "ATTACHMENTS", "TAGS", "CUSTOM_FIELDS", "COMMENTS", "PRESENCE", "SHARING",
|
||||
"FAVORITES", "FOLLOW", "TIMELINE", "RECENTS", "TASKS", "ASSIGNMENT"),
|
||||
enumSource(PERMISSION_MODE, "EDIT_OR_GRANTED", "GRANTED_ONLY"),
|
||||
QPossibleValueSource.newForTable(FEATURE_SETTING), QPossibleValueSource.newForTable(USER)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** String ids preserve the names of the planned core enums.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource enumSource(String name, String... values)
|
||||
{
|
||||
List<QPossibleValue<?>> entries = new ArrayList<>();
|
||||
for(String value : values)
|
||||
{
|
||||
entries.add(new QPossibleValue<>(value, value));
|
||||
}
|
||||
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(entries));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the tables and stable SQL column names. QQQ infers the SQL table
|
||||
** name from the already snake_case metadata name for RDBMS backends.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(CollaborationQBitConfig config)
|
||||
{
|
||||
QTableMetaData settings = base(config, FEATURE_SETTING, "Feature Settings", "settings")
|
||||
.withRecordLabelFormatAndFields("%s %s: %s", "scopeType", "scopeName", "feature")
|
||||
.withField(string("scopeType", "Scope Type", 20).withIsRequired(true).withPossibleValueSourceName(SCOPE_TYPE))
|
||||
.withField(string("scopeName", "Scope Name", 128).withIsRequired(true))
|
||||
.withField(string("feature", "Feature", 32).withIsRequired(true).withPossibleValueSourceName(FEATURE))
|
||||
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Enabled").withIsRequired(true).withDefaultValue(false))
|
||||
.withField(new QFieldMetaData("settings", QFieldType.TEXT).withLabel("Settings (JSON)"))
|
||||
.withField(string("permissionMode", "Permission Mode", 32).withPossibleValueSourceName(PERMISSION_MODE).withDefaultValue("EDIT_OR_GRANTED"))
|
||||
.withUniqueKey(new UniqueKey("scopeType", "scopeName", "feature"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "scopeType", "scopeName", "feature")).withLabel("Identity"))
|
||||
.withSection(new QFieldSection("details", new QIcon("settings"), Tier.T2, List.of("enabled", "settings", "permissionMode")).withLabel("Details"));
|
||||
QTableMetaData people = base(config, USER, "People", "people")
|
||||
.withRecordLabelFormatAndFields("%s", "displayName")
|
||||
.withField(string("userId", "User ID", 255).withIsRequired(true))
|
||||
.withField(string("displayName", "Display Name", 255))
|
||||
.withField(string("email", "Email", 255))
|
||||
.withField(string("avatarUrl", "Avatar URL", 2048))
|
||||
.withField(new QFieldMetaData("lastSeenAt", QFieldType.DATE_TIME).withLabel("Last Seen At"))
|
||||
.withField(new QFieldMetaData("active", QFieldType.BOOLEAN).withLabel("Active").withDefaultValue(true))
|
||||
.withUniqueKey(new UniqueKey("userId"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "userId", "displayName")).withLabel("Identity"))
|
||||
.withSection(new QFieldSection("details", new QIcon("people"), Tier.T2, List.of("email", "avatarUrl", "lastSeenAt", "active")).withLabel("Details"));
|
||||
for(QFieldMetaData field : people.getFields().values())
|
||||
{
|
||||
field.setIsEditable(false);
|
||||
}
|
||||
QTableMetaData favorites = personal(config, FAVORITE, "Favorites", "star")
|
||||
.withField(new QFieldMetaData("sortOrder", QFieldType.INTEGER).withLabel("Sort Order").withIsRequired(true).withDefaultValue(0))
|
||||
.withField(string("folder", "Folder", 255))
|
||||
.withSection(new QFieldSection("details", new QIcon("star"), Tier.T2, List.of("label", "sortOrder", "folder")).withLabel("Details"));
|
||||
QTableMetaData recents = personal(config, RECENT, "Recently Viewed", "history")
|
||||
.withField(new QFieldMetaData("lastViewedAt", QFieldType.DATE_TIME).withLabel("Last Viewed At").withIsRequired(true))
|
||||
.withField(new QFieldMetaData("viewCount", QFieldType.INTEGER).withLabel("View Count").withIsRequired(true).withDefaultValue(1))
|
||||
.withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details"));
|
||||
List<QTableMetaData> tables = List.of(settings, people, favorites, recents, attachment(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
|
||||
QInstanceEnricher.setInferredFieldBackendNames(table);
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common identity, dates, backend and permissions.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(CollaborationQBitConfig config, String name, String label, String icon)
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withIcon(new QIcon(icon))
|
||||
.withBackendName(config.getBackendName()).withPrimaryKeyField("id").withPermissionRules(CollaborationPermissions.permissionRules())
|
||||
.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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Personal high-volume data: owner locks replace a table-level permission.
|
||||
** Kept out of app navigation; the standard tables remain addressable by admins.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData personal(CollaborationQBitConfig config, String name, String label, String icon)
|
||||
{
|
||||
return (base(config, name, label, icon)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
|
||||
.withCustomPermissionChecker(new QCodeReference(CollaborationSecurity.class)))
|
||||
.withRecordSecurityLock(CollaborationSecurity.ownerLock("userId"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.NONE))
|
||||
.withRecordLabelFormatAndFields("%s", "label")
|
||||
.withField(string("userId", "User ID", 255).withIsRequired(true))
|
||||
.withField(string("targetType", "Target Type", 20).withIsRequired(true).withPossibleValueSourceName(TARGET_TYPE))
|
||||
.withField(string("targetName", "Target Name", 255).withIsRequired(true))
|
||||
.withField(string("recordId", "Record ID", 255))
|
||||
.withField(string("label", "Label", 1024))
|
||||
.withUniqueKey(new UniqueKey("userId", "targetType", "targetName", "recordId"))
|
||||
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1,
|
||||
List.of("id", "userId", "targetType", "targetName", "recordId")).withLabel("Identity")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded string field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData string(String name, String label, Integer length)
|
||||
{
|
||||
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withMaxLength(length));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Main attachment table; virtual URLs never have backend columns.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData attachment(CollaborationQBitConfig config)
|
||||
{
|
||||
return (base(config, ATTACHMENT, "Attachments", "attach_file")
|
||||
.withPermissionRules(CollaborationPermissions.permissionRules()
|
||||
.withCustomPermissionChecker(new QCodeReference(AttachmentSecurity.class)))
|
||||
.withRecordLabelFormatAndFields("%s", "fileName")
|
||||
.withSearchFields(List.of("fileName", "description"))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
|
||||
.withField(string("tableName", "Table", 128).withIsRequired(true))
|
||||
.withField(string("recordId", "Record ID", 64).withIsRequired(true))
|
||||
.withField(string("fileName", "File Name", 255).withIsRequired(true))
|
||||
.withField(string("contentType", "Content Type", 255).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsRequired(true))
|
||||
.withField(string("sha256", "SHA-256", 64).withIsRequired(true))
|
||||
.withField(string("bucketName", "Bucket", 255).withIsRequired(true))
|
||||
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
|
||||
.withField(string("thumbnailKey", "Thumbnail Key", 1024))
|
||||
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description"))
|
||||
.withField(string("createdByUserId", "Created By", 255).withIsRequired(true))
|
||||
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
|
||||
.withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true)
|
||||
.withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment()
|
||||
.withValue(AdornmentType.FilePreviewValues.FILE_NAME_FIELD, "fileName")
|
||||
.withValue(AdornmentType.FilePreviewValues.MIME_TYPE_FIELD, "contentType")
|
||||
.withValue(AdornmentType.FilePreviewValues.SIZE_BYTES_FIELD, "sizeBytes")
|
||||
.withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48)))
|
||||
.withVirtualField(new QVirtualFieldMetaData("downloadUrl", QFieldType.STRING).withLabel("Download").withIsEditable(false).withIsQuerySelectable(true)
|
||||
.withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD)
|
||||
.withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "fileName")
|
||||
.withValue(AdornmentType.FileDownloadValues.DOWNLOAD_URL_DYNAMIC, true)))
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(AttachmentPreviewCustomizer.class))
|
||||
.withSection(new QFieldSection("file", new QIcon("attach_file"), Tier.T1,
|
||||
List.of("fileName", "contentType", "sizeBytes", "description")).withLabel("File")
|
||||
.withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, section -> section.setFieldNames(List.of("previewUrl", "downloadUrl", "fileName", "contentType", "sizeBytes", "description"))))
|
||||
.withSection(new QFieldSection("parent", new QIcon("link"), Tier.T2,
|
||||
List.of("tableName", "recordId", "createdByUserId", "parentDeleted")).withLabel("Parent"))
|
||||
.withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T3,
|
||||
List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256")).withLabel("Storage")));
|
||||
}
|
||||
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads require view (or admin); settings writes require admin. People are
|
||||
** service-managed and cannot be written directly by a user.
|
||||
*******************************************************************************/
|
||||
public class CollaborationPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check the current session without an anonymous-access bypass.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
String needed = CollaborationPermissions.VIEW;
|
||||
boolean write = input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput;
|
||||
if(write && metadata instanceof QTableMetaData table)
|
||||
{
|
||||
if(!CollaborationTables.FEATURE_SETTING.equals(table.getName()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("People are maintained by the collaboration directory service."));
|
||||
}
|
||||
needed = CollaborationPermissions.ADMIN;
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || (!session.hasPermission(needed) && !session.hasPermission(CollaborationPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + needed + " permission."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Full permission names; fields and tags management are reserved for later phases.
|
||||
*******************************************************************************/
|
||||
public final class CollaborationPermissions
|
||||
{
|
||||
public static final String VIEW = "collaboration.view";
|
||||
public static final String ADMIN = "collaboration.admin";
|
||||
public static final String FIELDS_MANAGE = "collaboration.fields.manage";
|
||||
public static final String TAGS_MANAGE = "collaboration.tags.manage";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private CollaborationPermissions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared permission checker for tables, app and home.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName(CollaborationQBitConfig.PERMISSION_BASE_NAME)
|
||||
.withCustomPermissionChecker(new QCodeReference(CollaborationPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.recents;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.TargetResolver;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current-user recent targets, with persisted one-minute throttling. No process
|
||||
** cache or unreleased core contracts are needed. Lists drop unavailable targets.
|
||||
*******************************************************************************/
|
||||
public final class RecentsService
|
||||
{
|
||||
static Clock clock = Clock.systemUTC();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static service.
|
||||
*******************************************************************************/
|
||||
private RecentsService()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate visibility, then insert or refresh at most once per minute.
|
||||
*******************************************************************************/
|
||||
public static void recordView(String targetType, String targetName, Serializable recordId) throws QException
|
||||
{
|
||||
String userId = PersonalRecords.currentUserId();
|
||||
FavoriteEntry target = TargetResolver.require(targetType, targetName, recordId);
|
||||
QQueryFilter filter = PersonalRecords.targetFilter(targetType, targetName, target.recordId());
|
||||
List<QRecord> existing = PersonalRecords.query(CollaborationTables.RECENT, filter);
|
||||
Instant now = clock.instant();
|
||||
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);
|
||||
try
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.RECENT, row);
|
||||
return;
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
existing = PersonalRecords.query(CollaborationTables.RECENT, filter);
|
||||
if(existing.isEmpty())
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
// A concurrent first view inserted this target. Apply normal throttling.
|
||||
}
|
||||
}
|
||||
QRecord row = existing.getFirst();
|
||||
if(row.getValueInstant("lastViewedAt").plusSeconds(60).isAfter(now))
|
||||
{
|
||||
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())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return at most 50 visible entries, newest first. Page until enough visible
|
||||
** targets are found, rather than letting unavailable rows consume the limit.
|
||||
*******************************************************************************/
|
||||
public static List<FavoriteEntry> list(Integer limit) throws QException
|
||||
{
|
||||
if(limit == null || limit < 1 || limit > 50)
|
||||
{
|
||||
throw (new QException("Limit must be between 1 and 50"));
|
||||
}
|
||||
List<FavoriteEntry> result = new ArrayList<>();
|
||||
int skip = 0;
|
||||
while(result.size() < limit)
|
||||
{
|
||||
List<QRecord> rows = PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()
|
||||
.withOrderBy(new QFilterOrderBy("lastViewedAt", false)).withOrderBy(new QFilterOrderBy("id", false)).withSkip(skip).withLimit(50));
|
||||
for(FavoriteEntry entry : TargetResolver.refresh(rows))
|
||||
{
|
||||
if(entry.available() && result.size() < limit)
|
||||
{
|
||||
result.add(entry);
|
||||
}
|
||||
}
|
||||
if(rows.size() < 50)
|
||||
{
|
||||
break;
|
||||
}
|
||||
skip += rows.size();
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
@@ -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.recents;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
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.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
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.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.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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden daily retention job. Runs with its caller's session, which must be a
|
||||
** scheduler system session or collaboration admin; never elevates a user.
|
||||
*******************************************************************************/
|
||||
public class TrimRecentsProcess implements BackendStep, CustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "collaborationTrimRecents";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always register the backend-only process; scheduling is opt-in.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(CollaborationQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Trim recents").withIsHidden(true).withIcon(new QIcon("history"))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(TrimRecentsProcess.class)))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(TrimRecentsProcess.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(86400).withInitialDelaySeconds(86400));
|
||||
}
|
||||
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."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keyset-page users, then delete overflow in bounded batches. Re-query each
|
||||
** user's newest N before every batch, avoiding offsets over deleted rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
checkPermissionsThrowing(input, null);
|
||||
CollaborationSecurity.ensureSessionKeys(QContext.getQInstance(), QContext.getQSession());
|
||||
CollaborationQBitConfig config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
|
||||
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
|
||||
String previousUser = null;
|
||||
while(true)
|
||||
{
|
||||
QQueryFilter nextUser = new QQueryFilter().withOrderBy(new QFilterOrderBy("userId")).withLimit(1);
|
||||
if(previousUser != null)
|
||||
{
|
||||
nextUser.withCriteria("userId", QCriteriaOperator.GREATER_THAN, previousUser);
|
||||
}
|
||||
List<QRecord> next = PersonalRecords.query(CollaborationTables.RECENT, nextUser);
|
||||
if(next.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
previousUser = next.getFirst().getValueString("userId");
|
||||
while(true)
|
||||
{
|
||||
List<QRecord> overflow = PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.EQUALS, previousUser)
|
||||
.withOrderBy(new QFilterOrderBy("lastViewedAt", false)).withOrderBy(new QFilterOrderBy("id", false))
|
||||
.withSkip(config.getRecentsPerUser()).withLimit(500));
|
||||
if(overflow.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
List<Serializable> ids = new ArrayList<>(overflow.stream().map(row -> row.getValue("id")).toList());
|
||||
PersonalRecords.delete(CollaborationTables.RECENT, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.security;
|
||||
|
||||
import java.io.Serializable;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QValueException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
/*******************************************************************************
|
||||
** Stand-in for QQQ core RecordReferenceSecurityHelper (QRun-IO/qqq collaboration phase a); replace when released
|
||||
*******************************************************************************/
|
||||
public final class ParentRecordAccess
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static operations only.
|
||||
*******************************************************************************/
|
||||
private ParentRecordAccess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exclude collaboration tables to avoid recursive references.
|
||||
*******************************************************************************/
|
||||
public static boolean canReadTable(String tableName)
|
||||
{
|
||||
QTableMetaData table = tableName == null ? null : QContext.getQInstance().getTable(tableName);
|
||||
return (table != null && table.getPrimaryKeyField() != null && !tableName.startsWith(CollaborationQBitConfig.TABLE_NAME_PREFIX)
|
||||
&& PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get as the caller, enforcing table permissions and the parent's read locks.
|
||||
*******************************************************************************/
|
||||
public static QRecord requireVisibleParent(String tableName, Serializable recordId) throws QException
|
||||
{
|
||||
if(!canReadTable(tableName) || recordId == null)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
try
|
||||
{
|
||||
Serializable key = ValueUtils.getValueAsFieldType(table.getField(table.getPrimaryKeyField()).getType(), recordId);
|
||||
GetInput input = new GetInput(tableName).withPrimaryKey(key);
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
QRecord parent = new GetAction().execute(input).getRecord();
|
||||
if(parent == null)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (parent);
|
||||
}
|
||||
catch(QPermissionDeniedException | QValueException expected)
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary approximation: EDIT plus a visible parent, not WRITE lock checks.
|
||||
*******************************************************************************/
|
||||
public static void requireFeature(String tableName, Serializable recordId, String feature) throws QException
|
||||
{
|
||||
requireVisibleParent(tableName, recordId);
|
||||
QTableMetaData table = QContext.getQInstance().getTable(tableName);
|
||||
String base = table.getPermissionRules() == null ? null : table.getPermissionRules().getPermissionBaseName();
|
||||
base = base == null || base.isBlank() ? tableName : base;
|
||||
if(!QContext.getQSession().hasPermission(base + "." + feature)
|
||||
&& !PermissionsHelper.hasTablePermission(new AbstractActionInput(), tableName, TablePermissionSubType.EDIT))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This needs the " + base + "." + feature + " permission or table EDIT."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Configuration for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitConfig implements QBitConfig
|
||||
{
|
||||
private String targetTableName;
|
||||
private Boolean enableFeatureX = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate configuration before QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(targetTableName != null && qInstance.getTable(targetTableName) == null)
|
||||
{
|
||||
errors.add("Target table not found: " + targetTableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetTableName
|
||||
*******************************************************************************/
|
||||
public String getTargetTableName()
|
||||
{
|
||||
return targetTableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetTableName
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withTargetTableName(String targetTableName)
|
||||
{
|
||||
this.targetTableName = targetTableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public Boolean getEnableFeatureX()
|
||||
{
|
||||
return enableFeatureX;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enableFeatureX
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX)
|
||||
{
|
||||
this.enableFeatureX = enableFeatureX;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producer class for the Example Extension QBit.
|
||||
*******************************************************************************/
|
||||
public class ExampleExtensionQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-extension";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleExtensionQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce this QBit into the given QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
if(config != null && config.getTargetTableName() != null)
|
||||
{
|
||||
applyTableCustomizations(qInstance, config.getTargetTableName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the target table.
|
||||
*******************************************************************************/
|
||||
private void applyTableCustomizations(QInstance qInstance, String tableName)
|
||||
{
|
||||
// Implement your extension logic here
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for config
|
||||
*******************************************************************************/
|
||||
public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example table customizer.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.customizers;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Example table customizer that adds behavior to existing tables.
|
||||
*******************************************************************************/
|
||||
public class ExampleTableCustomizer
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply customizations to the given table.
|
||||
*******************************************************************************/
|
||||
public void customize(QTableMetaData table)
|
||||
{
|
||||
// Example: Add audit tracking fields
|
||||
// table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* 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.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
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.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
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.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfigValidationException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
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.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase-a metadata, real data, security and schema acceptance tests.
|
||||
*******************************************************************************/
|
||||
class CollaborationTest
|
||||
{
|
||||
private QInstance instance;
|
||||
private CollaborationQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh memory-backed instance for each test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
config = new CollaborationQBitConfig().withBackendName("memory");
|
||||
new CollaborationQBitProducer().withQBitConfig(config).produce(instance);
|
||||
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("admin")).withPermissions(CollaborationPermissions.ADMIN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release context and memory data.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete metadata validates and publishes the intended app and tables.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataValidates() throws Exception
|
||||
{
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(instance.getTables()).containsKeys(CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
assertThat(instance.getPossibleValueSources()).containsKeys(CollaborationTables.FEATURE_SETTING, CollaborationTables.USER,
|
||||
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.ATTACHMENT, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
|
||||
assertThat(instance.getApp("collaboration").getWidgets()).containsExactly(CollaborationHome.HOME_WIDGET);
|
||||
assertThat(instance.getWidget(CollaborationHome.HOME_WIDGET)).isNotNull();
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.FIELD);
|
||||
assertThat(instance.getTable(CollaborationTables.FEATURE_SETTING).getUniqueKeys().getFirst().getFieldNames()).containsExactly("scopeType", "scopeName", "feature");
|
||||
assertThat(instance.getTable(CollaborationTables.USER).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId");
|
||||
assertThat(instance.getTable(CollaborationTables.USER).getSections()).extracting(section -> section.getName()).contains("identity", "details", "dates");
|
||||
assertThat(instance.getQBits()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Required backend, defaults, setters, identity and optional placement.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configAndProducer() throws Exception
|
||||
{
|
||||
assertThat(new CollaborationQBitProducer().isEnabled()).isFalse();
|
||||
CollaborationQBitProducer producer = new CollaborationQBitProducer().withQBitConfig(config).withNamespace("collaboration");
|
||||
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().getArtifactId()).isEqualTo("qbit-collaboration");
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
|
||||
assertThat(config.getAdminApp()).isTrue();
|
||||
assertThat(config.getSchedulerName()).isNull();
|
||||
assertThatThrownBy(() -> new CollaborationQBitConfig().validate(instance)).isInstanceOf(QBitConfigValidationException.class);
|
||||
assertThatThrownBy(() -> config.withBackendName("missing").validate(instance)).isInstanceOf(QBitConfigValidationException.class).hasMessageContaining("Unknown Collaboration backend");
|
||||
config.setBackendName("memory");
|
||||
config.setSchedulerName("scheduler");
|
||||
assertThat(config.getSchedulerName()).isEqualTo("scheduler");
|
||||
config.withSchedulerName(null);
|
||||
config.setAppName("teamwork");
|
||||
config.setParentAppName("admin");
|
||||
assertThatThrownBy(() -> config.validate(instance)).isInstanceOf(QBitConfigValidationException.class).hasMessageContaining("parent app");
|
||||
instance = new QInstance();
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin").withIcon(new QIcon("settings")));
|
||||
producer.produce(instance);
|
||||
new QInstanceValidator().validate(instance);
|
||||
assertThat(instance.getApp("teamwork").getParentAppName()).isEqualTo("admin");
|
||||
assertThat(instance.getApp("admin").getChildren()).extracting(child -> child.getName()).contains("teamwork");
|
||||
assertThatThrownBy(() -> config.withAppName(" ").validate(instance)).isInstanceOf(QBitConfigValidationException.class);
|
||||
config.withAppName("collaboration").withParentAppName(null);
|
||||
config.setAdminApp(false);
|
||||
assertThat(config.getAdminApp()).isFalse();
|
||||
config.withAdminApp(null).validate(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Headless hosts keep the tables without app or widgets.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void headless() throws Exception
|
||||
{
|
||||
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(5);
|
||||
assertThat(headless.getApps()).isEmpty();
|
||||
assertThat(headless.getWidgets()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty states explain what has not been populated.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyHome() throws Exception
|
||||
{
|
||||
String html = CollaborationHome.render(Instant.now());
|
||||
assertThat(html).contains("No feature settings yet: features follow the code defaults", "People known: 0", "Active in the last 7 days: 0", "No people yet");
|
||||
RawHTML widget = (RawHTML) new CollaborationHome.HomeRenderer().render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(widget.getHtml()).contains("People known: 0");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts distinguish disabled settings and the inclusive seven-day window.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void realCounts() throws Exception
|
||||
{
|
||||
Instant now = Instant.parse("2026-10-13T12:00:00Z");
|
||||
seed(CollaborationTables.FEATURE_SETTING,
|
||||
setting("INSTANCE", "instance", "COMMENTS", true), setting("APP", "sales", "TAGS", true),
|
||||
setting("TABLE", "orders", "TASKS", true), setting("TABLE", "orders", "COMMENTS", true),
|
||||
setting("TABLE", "orders", "PRESENCE", false));
|
||||
seed(CollaborationTables.USER, person("one", now), person("boundary", now.minus(7, ChronoUnit.DAYS)),
|
||||
person("old", now.minus(7, ChronoUnit.DAYS).minusSeconds(1)), person("never", null), person("future", now.plusSeconds(1)));
|
||||
String html = CollaborationHome.render(now);
|
||||
assertThat(html).contains("INSTANCE: 1", "APP: 1", "TABLE: 2", "People known: 5", "Active in the last 7 days: 2").doesNotContain("No feature settings yet", "No people yet");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforced core actions use full custom permissions for reads and writes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void permissions() throws Exception
|
||||
{
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>());
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.USER))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> CollaborationHome.render(Instant.now())).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermissions(CollaborationPermissions.VIEW);
|
||||
new QueryAction().execute(new QueryInput(CollaborationTables.FEATURE_SETTING));
|
||||
new QueryAction().execute(new QueryInput(CollaborationTables.USER));
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(setting("INSTANCE", "instance", "TAGS", true)))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(new QRecord().withValue("id", 1)))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput(CollaborationTables.FEATURE_SETTING).withPrimaryKeys(List.of(1))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermissions(CollaborationPermissions.ADMIN);
|
||||
new InsertAction().execute(new InsertInput(CollaborationTables.FEATURE_SETTING).withRecords(List.of(setting("INSTANCE", "instance", "TAGS", true))));
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.USER).withRecords(List.of(person("denied", Instant.now())))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(CollaborationPermissions.FIELDS_MANAGE).isEqualTo("collaboration.fields.manage");
|
||||
assertThat(CollaborationPermissions.TAGS_MANAGE).isEqualTo("collaboration.tags.manage");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget counts honor host-added row locks and enforce even with core default off.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeUsesCurrentSession() throws Exception
|
||||
{
|
||||
Instant now = Instant.now();
|
||||
seed(CollaborationTables.USER, person("visible", now), person("hidden", now));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("person").withAllAccessKeyName("allPeople"));
|
||||
instance.getTable(CollaborationTables.USER).withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType("person").withFieldName("userId"));
|
||||
QContext.init(instance, new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("viewer")).withPermissions(CollaborationPermissions.VIEW).withSecurityKeyValue("person", "visible"));
|
||||
assertThat(CollaborationHome.render(now)).contains("People known: 1", "Active in the last 7 days: 1");
|
||||
instance.setEnforcePermissionsInCoreActions(false);
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>());
|
||||
assertThatThrownBy(() -> new CollaborationHome.HomeRenderer().render(new RenderWidgetInput())).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DDL uses metadata names, field lengths, identity, timestamps and unique keys.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void schema()
|
||||
{
|
||||
String ddl = CollaborationSchema.postgresScript(config);
|
||||
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_feature_setting", "CREATE TABLE IF NOT EXISTS collaboration_user",
|
||||
"id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", "scope_name VARCHAR(128) NOT NULL", "settings TEXT", "enabled BOOLEAN NOT NULL",
|
||||
"last_seen_at TIMESTAMPTZ", "create_date TIMESTAMPTZ", "modify_date TIMESTAMPTZ", "avatar_url VARCHAR(2048)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_feature_setting_1 ON collaboration_feature_setting (scope_type, scope_name, feature)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_user_1 ON collaboration_user (user_id)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert fixture rows as trusted service work, retaining query enforcement.
|
||||
*******************************************************************************/
|
||||
private void seed(String table, QRecord... records) throws Exception
|
||||
{
|
||||
InsertInput input = new InsertInput(table).withRecords(List.of(records));
|
||||
input.withPermissionBypass("Test fixture simulates the future directory service");
|
||||
assertThat(new InsertAction().execute(input).getRecords()).allSatisfy(record -> assertThat(record.getErrors()).isNullOrEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One feature setting.
|
||||
*******************************************************************************/
|
||||
private QRecord setting(String scope, String name, String feature, boolean enabled)
|
||||
{
|
||||
return (new QRecord().withValue("scopeType", scope).withValue("scopeName", name).withValue("feature", feature).withValue("enabled", enabled));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One directory person.
|
||||
*******************************************************************************/
|
||||
private QRecord person(String id, Instant seen)
|
||||
{
|
||||
return (new QRecord().withValue("userId", id).withValue("displayName", id).withValue("lastSeenAt", seen).withValue("active", true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* 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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
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.insert.InsertInput;
|
||||
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.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.QAppMetaData;
|
||||
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.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Behavioral and security acceptance for favorites, including label batching.
|
||||
*******************************************************************************/
|
||||
class FavoritesServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Both record and null-record-id targets are idempotent and append in order.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void starAndUnstarAreIdempotent() throws Exception
|
||||
{
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("First document", "Documents");
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::sortOrder).containsExactly(0, 1);
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::href).containsExactly("/app/document/1/", "/app/document/");
|
||||
FavoritesService.unstar("RECORD", "document", "1");
|
||||
FavoritesService.unstar("RECORD", "document", "1");
|
||||
FavoritesService.unstar("TABLE", "document", null);
|
||||
FavoritesService.unstar("TABLE", "document", null);
|
||||
assertThat(FavoritesService.list()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing, locked and table-denied records expose exactly the same error.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invisibleAndMissingAreIndistinguishable() throws Exception
|
||||
{
|
||||
for(String id : List.of("3", "999"))
|
||||
{
|
||||
assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", id)).isInstanceOf(QException.class).hasMessage("Not found");
|
||||
}
|
||||
assertThatThrownBy(() -> FavoritesService.star("TABLE", "missing", null)).hasMessage("Not found");
|
||||
instance.setEnforcePermissionsInCoreActions(false);
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "1")).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> FavoritesService.star("TABLE", "document", null)).hasMessage("Not found");
|
||||
assertThat(FavoritesService.list()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refresh uses one query, no gets; lock changes redact both label and href.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refreshesLabelsInOneQueryAndRedactsRevokedRecords() throws Exception
|
||||
{
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
FavoritesService.star("RECORD", "document", "2");
|
||||
new UpdateAction().execute(new UpdateInput("document").withRecords(List.of(new QRecord().withValue("id", 1).withValue("title", "Renamed"))));
|
||||
ReadCounter.queries = 0;
|
||||
ReadCounter.gets = 0;
|
||||
instance.getTable("document").withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(ReadCounter.class));
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("Renamed", "Second document");
|
||||
assertThat(ReadCounter.queries).isEqualTo(1);
|
||||
assertThat(ReadCounter.gets).isZero();
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
|
||||
assertThat(FavoritesService.list()).allSatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.available()).isFalse();
|
||||
assertThat(entry.label()).isEqualTo("Unavailable");
|
||||
assertThat(entry.href()).isNull();
|
||||
});
|
||||
FavoritesService.unstar("RECORD", "document", "1");
|
||||
assertThat(FavoritesService.list()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct table reads/writes initialize keys and enforce both sides of a lock.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownershipAndAdminAccess() throws Exception
|
||||
{
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
Integer aliceId = FavoritesService.list().getFirst().id();
|
||||
login("bob");
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).isEmpty();
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.FAVORITE).withPrimaryKey(aliceId)).getRecord()).isNull();
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.FAVORITE)).getCount()).isZero();
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
QRecord spoof = new QRecord().withValue("userId", "alice").withValue("targetType", "TABLE").withValue("targetName", "other").withValue("sortOrder", 0);
|
||||
assertThat(new InsertAction().execute(new InsertInput(CollaborationTables.FAVORITE).withRecords(List.of(spoof))).getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
Integer bobId = FavoritesService.list().getFirst().id();
|
||||
assertThat(new UpdateAction().execute(new UpdateInput(CollaborationTables.FAVORITE).withRecords(List.of(new QRecord().withValue("id", bobId).withValue("userId", "alice"))))
|
||||
.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
admin();
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).hasSize(2);
|
||||
assertThat(FavoritesService.list()).isEmpty();
|
||||
QContext.getQSession().getPermissions().remove(CollaborationPermissions.ADMIN);
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE)).getRecords()).isEmpty();
|
||||
assertThat(QContext.getQSession().getSecurityKeyValues()).doesNotContainKey(CollaborationSecurity.KEY_USER_ALL_ACCESS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reordering validates all ownership before writing and can clear a folder.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void reorderAndFolders() throws Exception
|
||||
{
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
FavoritesService.star("RECORD", "document", "2");
|
||||
List<Integer> ids = FavoritesService.list().stream().map(FavoriteEntry::id).toList();
|
||||
FavoritesService.reorder(List.of(ids.get(1)), Map.of(ids.get(1), "Work"));
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::id).containsExactly(ids.get(1), ids.get(0));
|
||||
assertThat(FavoritesService.list().getFirst().folder()).isEqualTo("Work");
|
||||
Map<Integer, String> clear = new HashMap<>();
|
||||
clear.put(ids.get(1), null);
|
||||
FavoritesService.reorder(List.of(), clear);
|
||||
assertThat(FavoritesService.list().getFirst().folder()).isNull();
|
||||
assertThatThrownBy(() -> FavoritesService.reorder(List.of(ids.get(0), ids.get(0)), Map.of())).hasMessage("Invalid favorite ids");
|
||||
assertThatThrownBy(() -> FavoritesService.reorder(List.of(), Map.of(ids.get(0), "x".repeat(256)))).hasMessage("Folder is too long");
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> FavoritesService.reorder(ids, Map.of())).hasMessage("Invalid favorite ids");
|
||||
assertThatThrownBy(() -> FavoritesService.reorder(List.of(), Map.of(ids.get(0), "stolen"))).hasMessage("Invalid favorite ids");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All metadata types use their core permissions, refresh labels and redact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataTargetsAndRevocation() throws Exception
|
||||
{
|
||||
QPermissionRules rules = new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withPermissionBaseName("targets");
|
||||
instance.addApp(new QAppMetaData().withName("work").withLabel("Work").withPermissionRules(rules));
|
||||
instance.addProcess(new QProcessMetaData().withName("document.export").withLabel("Export").withPermissionRules(rules));
|
||||
instance.addReport(new QReportMetaData().withName("report").withLabel("Report").withPermissionRules(rules));
|
||||
for(String type : List.of("APP", "PROCESS", "REPORT"))
|
||||
{
|
||||
String name = type.equals("APP") ? "work" : type.equals("PROCESS") ? "document.export" : "report";
|
||||
assertThatThrownBy(() -> FavoritesService.star(type, name, null)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> FavoritesService.star(type, "missing", null)).hasMessage("Not found");
|
||||
}
|
||||
QContext.getQSession().withPermissions("targets.hasAccess");
|
||||
FavoritesService.star("APP", "work", null);
|
||||
FavoritesService.star("PROCESS", "document.export", null);
|
||||
FavoritesService.star("REPORT", "report", null);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::label).containsExactly("Work", "Export", "Report", "Documents");
|
||||
assertThat(FavoritesService.list().get(1).href()).isEqualTo("/app/document.export/");
|
||||
instance.getApp("work").setLabel("Renamed app");
|
||||
assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Renamed app");
|
||||
QContext.getQSession().getPermissions().clear();
|
||||
assertThat(FavoritesService.list()).allSatisfy(entry -> assertThat(entry.label()).isEqualTo("Unavailable"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved views are conditional on the core table and respect its row locks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void savedViews() throws Exception
|
||||
{
|
||||
assertThatThrownBy(() -> FavoritesService.star("SAVED_VIEW", "7", null)).hasMessage("Not found");
|
||||
instance.addTable(new QTableMetaData().withName("savedView").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("label", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("tableName", QFieldType.STRING)).withField(new QFieldMetaData("userId", QFieldType.STRING))
|
||||
.withRecordLabelFormatAndFields("%s", "label").withRecordSecurityLock(CollaborationSecurity.ownerLock("userId")));
|
||||
CollaborationSecurity.ensureSessionKeys(instance, QContext.getQSession());
|
||||
new InsertAction().execute(new InsertInput("savedView").withRecords(List.of(new QRecord().withValue("id", 7).withValue("label", "My view")
|
||||
.withValue("tableName", "document").withValue("userId", "alice"))));
|
||||
FavoritesService.star("SAVED_VIEW", "7", null);
|
||||
assertThat(FavoritesService.list().getFirst().label()).isEqualTo("My view");
|
||||
assertThat(FavoritesService.list().getFirst().href()).isEqualTo("/app/document/savedView/7/");
|
||||
login("bob");
|
||||
assertThatThrownBy(() -> FavoritesService.star("SAVED_VIEW", "7", null)).hasMessage("Not found");
|
||||
login("alice");
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Unavailable");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Integer aliases refer to one target; arbitrary string keys are URL-encoded.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canonicalRecordIdsAndEncodedPaths() throws Exception
|
||||
{
|
||||
FavoritesService.star("RECORD", "document", "01");
|
||||
FavoritesService.star("RECORD", "document", 1);
|
||||
assertThat(FavoritesService.list()).extracting(FavoriteEntry::recordId).containsExactly("1");
|
||||
assertThat(FavoritesService.list().getFirst().available()).isTrue();
|
||||
FavoritesService.unstar("RECORD", "document", "001");
|
||||
assertThat(FavoritesService.list()).isEmpty();
|
||||
assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "invalid")).hasMessage("Not found");
|
||||
instance.addTable(new QTableMetaData().withName("named").withLabel("Named").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withField(new QFieldMetaData("id", QFieldType.STRING)).withRecordLabelFormatAndFields("%s", "id"));
|
||||
new InsertAction().execute(new InsertInput("named").withRecords(List.of(new QRecord().withValue("id", "a/b #?"))));
|
||||
FavoritesService.star("RECORD", "named", "a/b #?");
|
||||
assertThat(FavoritesService.list().getFirst().href()).isEqualTo("/app/named/a%2Fb%20%23%3F/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deletion has the same unavailable presentation as losing access.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deletedTargetsAndCrossUserWrites() throws Exception
|
||||
{
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
Integer favoriteId = FavoritesService.list().getFirst().id();
|
||||
login("bob");
|
||||
assertThat(new UpdateAction().execute(new UpdateInput(CollaborationTables.FAVORITE)
|
||||
.withRecords(List.of(new QRecord().withValue("id", favoriteId).withValue("folder", "stolen"))))
|
||||
.getRecords().getFirst().getErrors()).isNotEmpty();
|
||||
assertThat(new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(CollaborationTables.FAVORITE).withPrimaryKeys(List.of(favoriteId))).getDeletedRecordCount()).isZero();
|
||||
admin();
|
||||
assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", "3")).hasMessage("Not found");
|
||||
login("alice");
|
||||
PersonalRecords.delete("document", List.of(1));
|
||||
assertThat(FavoritesService.list().getFirst().label()).isEqualTo("Unavailable");
|
||||
instance.getTables().remove("document");
|
||||
assertThat(FavoritesService.list()).allSatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.available()).isFalse();
|
||||
assertThat(entry.label()).isEqualTo("Unavailable");
|
||||
assertThat(entry.href()).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bad target shapes and anonymous callers fail before storing data.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectsInvalidTargetsAndAnonymousSessions()
|
||||
{
|
||||
assertThatThrownBy(() -> FavoritesService.star("OTHER", "document", null)).hasMessage("Invalid target");
|
||||
assertThatThrownBy(() -> FavoritesService.star("RECORD", "document", null)).hasMessage("Invalid target");
|
||||
assertThatThrownBy(() -> FavoritesService.star("TABLE", "document", "1")).hasMessage("Invalid target");
|
||||
assertThatThrownBy(() -> FavoritesService.star("TABLE", " ", null)).hasMessage("Invalid target");
|
||||
QContext.init(instance, new QSession());
|
||||
assertThatThrownBy(() -> FavoritesService.list()).hasMessageContaining("Sign in");
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.FAVORITE))).hasMessageContaining("Sign in");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count actual core reads rather than assuming an implementation batches.
|
||||
*******************************************************************************/
|
||||
public static class ReadCounter implements TableCustomizerInterface
|
||||
{
|
||||
static int queries;
|
||||
static int gets;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core calls this for both get and query, after enforcing record locks.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> rows)
|
||||
{
|
||||
if(input instanceof QueryInput)
|
||||
{
|
||||
queries++;
|
||||
}
|
||||
else
|
||||
{
|
||||
gets++;
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
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.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.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
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.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
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.BeforeEach;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Memory-backed personal services with a real, independently locked host table.
|
||||
*******************************************************************************/
|
||||
public abstract class PersonalServiceFixture
|
||||
{
|
||||
protected QInstance instance;
|
||||
protected CollaborationQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every test starts with two visible records and one invisible record.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
public void setUp() 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);
|
||||
config = new CollaborationQBitConfig().withBackendName("memory");
|
||||
new CollaborationQBitProducer().withQBitConfig(config).produce(instance);
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("tenant").withAllAccessKeyName("allTenants"));
|
||||
instance.addTable(new QTableMetaData().withName("document").withLabel("Documents").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("title", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("tenant", QFieldType.STRING))
|
||||
.withRecordLabelFormatAndFields("%s", "title")
|
||||
.withRecordSecurityLock(new RecordSecurityLock().withFieldName("tenant").withSecurityKeyType("tenant")
|
||||
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE)));
|
||||
login("alice");
|
||||
QContext.getQSession().withSecurityKeyValue("allTenants", true);
|
||||
InsertInput insert = new InsertInput("document").withRecords(List.of(
|
||||
new QRecord().withValue("id", 1).withValue("title", "First document").withValue("tenant", "a"),
|
||||
new QRecord().withValue("id", 2).withValue("title", "Second document").withValue("tenant", "a"),
|
||||
new QRecord().withValue("id", 3).withValue("title", "Secret document").withValue("tenant", "b")));
|
||||
assertThat(new InsertAction().execute(insert).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
|
||||
login("alice");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No collaboration.view grant: personal services must still work.
|
||||
*******************************************************************************/
|
||||
protected void login(String id, String... extraPermissions)
|
||||
{
|
||||
QContext.init(instance, new QSession().withUser(new QUser().withIdReference(id)).withPermissions("document.read", "document.write")
|
||||
.withPermissions(extraPermissions).withSecurityKeyValue("tenant", "a"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin access is independent of ordinary host-table access.
|
||||
*******************************************************************************/
|
||||
protected void admin()
|
||||
{
|
||||
login("admin", CollaborationPermissions.ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No static context or memory records survive a test.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
public void tearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
}
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
package com.kingsrook.qbits.collaboration.attachments;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
|
||||
import com.kingsrook.qbits.objectstorage.api.PresignGet;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/*******************************************************************************
|
||||
** Real QQQ memory actions and parent locks, with an in-memory storage boundary.
|
||||
*******************************************************************************/
|
||||
public class AttachmentsServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
private static AttachmentsService processService;
|
||||
private FakeStorage storage;
|
||||
private AttachmentsService service;
|
||||
|
||||
/*******************************************************************************
|
||||
** No Docker, network, object-storage grants or collaboration.view needed.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void attachments()
|
||||
{
|
||||
storage = new FakeStorage();
|
||||
service = new AttachmentsService(config, storage);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upload, metadata, newest-first list, signed download and deletion round trip.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void roundTrip() throws Exception
|
||||
{
|
||||
AttachmentEntry first = upload("../hello.txt", "hello");
|
||||
AttachmentEntry second = upload("C:\\fakepath\\other.txt", "other");
|
||||
assertThat(first.fileName()).isEqualTo("hello.txt");
|
||||
assertThat(first.sizeBytes()).isEqualTo(5L);
|
||||
assertThat(first.sha256()).isEqualTo("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
|
||||
assertThat(first.createdByUserId()).isEqualTo("alice");
|
||||
assertThat(first.canDelete()).isTrue();
|
||||
assertThat(service.list("document", "01")).extracting(AttachmentEntry::id).containsExactly(second.id(), first.id());
|
||||
assertThat(storage.objects.values()).anySatisfy(bytes -> assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("hello"));
|
||||
assertThat(storage.lastPut.getContentType()).isEqualTo("text/plain");
|
||||
assertThat(storage.lastPut.getContentDisposition()).isEqualTo("inline");
|
||||
assertThat(storage.objects.keySet()).allMatch(key -> key.startsWith("collaboration/attachments/document/1/"));
|
||||
assertThat(service.downloadUrl(first.id(), false).toString()).contains("signed.example");
|
||||
assertThat(storage.lastGet.getTtl()).isEqualTo(Duration.ofMinutes(5));
|
||||
assertThat(storage.lastGet.getDownloadAs()).isEqualTo("hello.txt");
|
||||
service.downloadUrl(first.id(), true);
|
||||
assertThat(storage.lastGet.getDownloadAs()).isNull();
|
||||
assertThat(raw(first.id()).getValue("previewUrl")).isNull();
|
||||
service.delete(List.of(first.id(), first.id()));
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
assertThat(service.list("document", 1)).extracting(AttachmentEntry::id).containsExactly(second.id());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Both collaboration and bucket size limits are enforced while streaming.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void limitsAndExtensions() throws Exception
|
||||
{
|
||||
config.withDefaultMaxBytes(4L);
|
||||
assertThatThrownBy(() -> upload("large.txt", "hello")).hasMessageContaining("size limit");
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(service.list("document", 1)).isEmpty();
|
||||
upload("exact.txt", "four");
|
||||
config.withDefaultMaxBytes(100L);
|
||||
storage.limit = 2;
|
||||
assertThatThrownBy(() -> upload("bucket.txt", "abc")).hasMessageContaining("size limit of 2");
|
||||
config.withBlockedExtensions(List.of(".EXE"));
|
||||
assertThatThrownBy(() -> upload("bad.exe", "x")).hasMessageContaining("blocked");
|
||||
assertThatThrownBy(() -> upload("..", "x")).hasMessageContaining("Invalid file name");
|
||||
assertThatThrownBy(() -> upload(" ", "x")).hasMessageContaining("file name is required");
|
||||
assertThatThrownBy(() -> service.upload("document", 1, "x", "x".repeat(256), stream("x"), null)).hasMessageContaining("field lengths");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A locked, missing or table-denied parent always gives the same Not found.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invisibleParents() throws Exception
|
||||
{
|
||||
AttachmentEntry row = upload("visible.txt", "hello");
|
||||
for(String id : List.of("3", "999", "invalid"))
|
||||
{
|
||||
assertThatThrownBy(() -> service.upload("document", id, "a.txt", null, stream("x"), null))
|
||||
.isInstanceOf(QNotFoundException.class).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> service.list("document", id)).hasMessage("Not found");
|
||||
}
|
||||
QContext.getQSession().getSecurityKeyValues().remove("tenant");
|
||||
assertThatThrownBy(() -> service.downloadUrl(row.id(), false)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Not found");
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> ParentRecordAccess.requireVisibleParent("missing", 1)).hasMessage("Not found");
|
||||
assertThatThrownBy(() -> ParentRecordAccess.requireVisibleParent(CollaborationTables.ATTACHMENT, row.id())).hasMessage("Not found");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit attach uses the table's permission base and does not require EDIT.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void explicitAttachWithoutEdit() throws Exception
|
||||
{
|
||||
QContext.getQSession().getPermissions().remove("document.write");
|
||||
assertThatThrownBy(() -> upload("denied.txt", "x")).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().withPermissions("document.attach");
|
||||
AttachmentEntry row = upload("allowed.txt", "x");
|
||||
service.delete(List.of(row.id()));
|
||||
instance.getTable("document").getPermissionRules().setPermissionBaseName("documents");
|
||||
QContext.getQSession().withPermissions("documents.read", "documents.attach");
|
||||
upload("custom-base.txt", "x");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ownership, moderation, and feature revocation are independent checks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownOtherAndAdmin() throws Exception
|
||||
{
|
||||
AttachmentEntry alice = upload("alice.txt", "a");
|
||||
login("bob");
|
||||
AttachmentEntry bob = upload("bob.txt", "b");
|
||||
assertThat(service.list("document", 1)).filteredOn(row -> row.id().equals(alice.id())).extracting(AttachmentEntry::canDelete).containsExactly(false);
|
||||
assertThatThrownBy(() -> service.delete(List.of(bob.id(), alice.id()))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(storage.objects).hasSize(2);
|
||||
assertThatThrownBy(() -> service.rename(alice.id(), "stolen.txt", null)).isInstanceOf(QPermissionDeniedException.class);
|
||||
QContext.getQSession().getPermissions().remove("document.write");
|
||||
assertThatThrownBy(() -> service.delete(List.of(bob.id()))).isInstanceOf(QPermissionDeniedException.class);
|
||||
admin();
|
||||
QContext.getQSession().getPermissions().remove("document.write");
|
||||
assertThat(service.rename(alice.id(), "renamed.txt", "new description").fileName()).isEqualTo("renamed.txt");
|
||||
assertThat(service.describe(alice.id(), "").description()).isEmpty();
|
||||
service.downloadUrl(alice.id(), false);
|
||||
assertThat(storage.lastGet.getDownloadAs()).isEqualTo("renamed.txt");
|
||||
service.delete(List.of(alice.id(), bob.id()));
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct table writes cannot bypass authorization or the storage lifecycle.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void directWritesDeniedAndPanelBypassesBrowseGrant() throws Exception
|
||||
{
|
||||
AttachmentEntry row = upload("ok.txt", "x");
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(service.list("document", 1)).hasSize(1);
|
||||
admin();
|
||||
assertThatThrownBy(() -> new InsertAction().execute(new InsertInput(CollaborationTables.ATTACHMENT).withRecords(List.of(new QRecord()))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput(CollaborationTables.ATTACHMENT)
|
||||
.withRecords(List.of(new QRecord().withValue("id", row.id()).withValue("createdByUserId", "admin")))))
|
||||
.isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The real pre-query hook restricts query and count, including an empty IN.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableReadRestrictionAndPostFiltering() throws Exception
|
||||
{
|
||||
AttachmentEntry visible = upload("visible.txt", "x");
|
||||
QContext.getQSession().withSecurityKeyValue("allTenants", true);
|
||||
service.upload("document", 3, "secret.txt", "text/plain", stream("secret"), null);
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT);
|
||||
assertThat(new QueryAction().execute(query).getRecords()).extracting(row -> row.getValueInteger("id")).containsExactly(visible.id());
|
||||
assertThat(query.getFilter().toString()).contains("tableName", "document");
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isEqualTo(2);
|
||||
int hiddenId = AttachmentRecords.query(new QQueryFilter().withCriteria("recordId", QCriteriaOperator.EQUALS, "3")).getFirst().getValueInteger("id");
|
||||
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(hiddenId)).getRecord()).isNull();
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT)).getRecords()).isEmpty();
|
||||
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isZero();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Signed previews are generated only after checking visibility and stay virtual.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void previews() throws Exception
|
||||
{
|
||||
AttachmentEntry row = upload("photo.png", "image");
|
||||
QRecord visible = raw(row.id());
|
||||
QRecord hidden = new QRecord(visible).withValue("recordId", "3");
|
||||
AttachmentPreviewCustomizer customizer = new AttachmentPreviewCustomizer();
|
||||
List<QRecord> results = customizer.filter(List.of(visible, hidden), false, service);
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(storage.signatures).hasSize(2);
|
||||
assertThat(results.getFirst().getValueString("previewUrl")).contains("signed.example");
|
||||
assertThat(results.getFirst().getValueString("downloadUrl")).contains("signed.example");
|
||||
assertThat(results.getFirst().getDisplayValue("downloadUrl")).isEqualTo("photo.png");
|
||||
assertThat(results.getFirst().getDisplayValue("downloadUrl:downloadUrlDynamic")).isEqualTo(results.getFirst().getValueString("downloadUrl"));
|
||||
assertThat(visible.getValue("previewUrl")).isNull();
|
||||
assertThat(raw(row.id()).getValue("previewUrl")).isNull();
|
||||
assertThatThrownBy(() -> customizer.filter(List.of(hidden), true, service)).hasMessage("Not found");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Review is dry, Result is actual, and authority is checked again after Review.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteProcessReviewAndResult() throws Exception
|
||||
{
|
||||
AttachmentEntry alice = upload("alice.txt", "a");
|
||||
login("bob");
|
||||
AttachmentEntry bob = upload("bob.txt", "b");
|
||||
DeleteAttachmentsProcess.Review review = new DeleteAttachmentsProcess.Review(service);
|
||||
RunBackendStepOutput reviewed = new RunBackendStepOutput();
|
||||
review.runOnePage(new RunBackendStepInput().withRecords(List.of(raw(alice.id()), raw(bob.id()))), reviewed);
|
||||
assertThat(reviewed.getRecords()).extracting(row -> row.getValueInteger("id")).containsExactly(bob.id());
|
||||
assertThat(storage.objects).hasSize(2);
|
||||
assertThat(review.getProcessSummary(reviewed, false)).hasSize(2);
|
||||
ProcessSummaryLine planned = (ProcessSummaryLine) review.getProcessSummary(reviewed, false).getFirst();
|
||||
assertThat(planned.getCount()).isEqualTo(1);
|
||||
assertThat(planned.getSingularFutureMessage()).contains("will be deleted");
|
||||
DeleteAttachmentsProcess.Delete delete = new DeleteAttachmentsProcess.Delete(service);
|
||||
delete.setTransformStep(review);
|
||||
delete.runOnePage(new RunBackendStepInput().withRecords(reviewed.getRecords()), new RunBackendStepOutput());
|
||||
assertThat(delete.getProcessSummary(new RunBackendStepOutput(), true)).hasSize(2);
|
||||
ProcessSummaryLine result = (ProcessSummaryLine) delete.getProcessSummary(new RunBackendStepOutput(), true).getFirst();
|
||||
assertThat(result.getSingularPastMessage()).isEqualTo("attachment deleted");
|
||||
assertThat(result.getCount()).isEqualTo(1);
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
delete.runOnePage(new RunBackendStepInput().withRecords(List.of(raw(alice.id()))), new RunBackendStepOutput());
|
||||
assertThat(delete.getProcessSummary(new RunBackendStepOutput(), true)).hasSize(3);
|
||||
assertThat(instance.getProcess(DeleteAttachmentsProcess.NAME).getLabel()).isEqualTo("Delete attachments");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the actual streamed ETL through its frontend break and resume boundary.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void streamedProcess() throws Exception
|
||||
{
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
AttachmentEntry row = upload("streamed.txt", "x");
|
||||
processService = service;
|
||||
instance.getProcess(DeleteAttachmentsProcess.NAME).getBackendStep(StreamedETLWithFrontendProcess.STEP_NAME_EXECUTE)
|
||||
.getInputMetaData().getFieldThrowing(StreamedETLWithFrontendProcess.FIELD_LOAD_CODE).setDefaultValue(new QCodeReference(FakeDelete.class));
|
||||
try
|
||||
{
|
||||
RunProcessInput start = new RunProcessInput();
|
||||
start.setProcessName(DeleteAttachmentsProcess.NAME);
|
||||
start.addValue("recordsParam", "recordIds");
|
||||
start.addValue("recordIds", row.id().toString());
|
||||
start.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.BREAK);
|
||||
RunProcessOutput review = new RunProcessAction().execute(start);
|
||||
List<ProcessSummaryLine> reviewLines = (List<ProcessSummaryLine>) review.getValues().get(StreamedETLWithFrontendProcess.FIELD_VALIDATION_SUMMARY);
|
||||
assertThat(reviewLines).anySatisfy(line ->
|
||||
{
|
||||
line.pickMessage(false);
|
||||
assertThat(line.getMessage()).contains("will be deleted");
|
||||
assertThat(line.getCount()).isEqualTo(1);
|
||||
});
|
||||
assertThat(storage.objects).hasSize(1);
|
||||
RunProcessInput go = new RunProcessInput();
|
||||
go.setProcessName(DeleteAttachmentsProcess.NAME);
|
||||
go.setProcessUUID(review.getProcessUUID());
|
||||
go.setStartAfterStep(StreamedETLWithFrontendProcess.STEP_NAME_REVIEW);
|
||||
go.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
RunProcessOutput result = new RunProcessAction().execute(go);
|
||||
List<ProcessSummaryLine> resultLines = (List<ProcessSummaryLine>) result.getValues().get(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY);
|
||||
assertThat(resultLines).anySatisfy(line ->
|
||||
{
|
||||
line.pickMessage(true);
|
||||
assertThat(line.getMessage()).isEqualTo("attachment deleted");
|
||||
assertThat(line.getCount()).isEqualTo(1);
|
||||
});
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(service.list("document", 1)).isEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
processService = null;
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only storage changes in the end-to-end process test, via the normal service.
|
||||
*******************************************************************************/
|
||||
public static class FakeDelete extends DeleteAttachmentsProcess.Delete
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Instantiated by the real process loader.
|
||||
*******************************************************************************/
|
||||
public FakeDelete()
|
||||
{
|
||||
super(processService);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage failures leave rows for retries; trusted cleanup works after parent deletion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failuresAndCleanup() throws Exception
|
||||
{
|
||||
AttachmentEntry row = upload("file.txt", "x");
|
||||
storage.failDelete = true;
|
||||
assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Storage unavailable");
|
||||
assertThat(service.list("document", 1)).hasSize(1);
|
||||
storage.failDelete = false;
|
||||
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
|
||||
assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found");
|
||||
QContext.getQSession().getPermissions().clear();
|
||||
AttachmentCleanup cleanup = new AttachmentCleanup(service);
|
||||
cleanup.onParentsDeleted("document", List.of());
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
cleanup.onParentsDeleted("document", List.of(1));
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A rejected row compensates the put; a broken upload never creates a row.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failedInsertAndInputStream() throws Exception
|
||||
{
|
||||
instance.getTable(CollaborationTables.ATTACHMENT).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class));
|
||||
assertThatThrownBy(() -> upload("long.txt", "x")).isInstanceOf(QException.class).hasMessageContaining("Could not save attachment");
|
||||
assertThat(storage.objects).isEmpty();
|
||||
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
|
||||
assertThatThrownBy(() -> service.upload("document", 1, "x", null, new java.io.InputStream()
|
||||
{
|
||||
@Override
|
||||
public int read() throws IOException
|
||||
{
|
||||
throw (new IOException("broken source"));
|
||||
}
|
||||
}, null)).isInstanceOf(QException.class).hasMessageContaining("Could not read attachment");
|
||||
assertThat(storage.objects).isEmpty();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the real QQQ per-record validation failure/compensation path.
|
||||
*******************************************************************************/
|
||||
public static class RejectInsert implements TableCustomizerInterface
|
||||
{
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview)
|
||||
{
|
||||
records.forEach(row -> row.withError(new BadInputStatusMessage("Rejected by host validation")));
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Real counts and bytes on the home, with explicit approximation and empty state.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeAndSchema() throws Exception
|
||||
{
|
||||
admin();
|
||||
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 0", "No attachments yet.", "Total size (bytes): 0");
|
||||
upload("a.txt", "12345");
|
||||
upload("b.txt", "123");
|
||||
assertThat(CollaborationHome.render(Instant.now())).contains("Attachments: 2", "Total size (bytes): 8", "Approximate totals").doesNotContain("No attachments yet.");
|
||||
String ddl = CollaborationSchema.postgresScript(config);
|
||||
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
"ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url");
|
||||
QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT);
|
||||
assertThat(table.getField("sizeBytes").getType()).isEqualTo(QFieldType.LONG);
|
||||
assertThat(table.getVirtualField("previewUrl")).isNotNull();
|
||||
assertThat(config.getDefaultMaxBytes()).isEqualTo(104857600L);
|
||||
assertThat(config.getAttachmentsBucketName()).isEqualTo("collaboration");
|
||||
assertThat(config.getBlockedExtensions()).isEmpty();
|
||||
assertThatThrownBy(() -> config.withDefaultMaxBytes(0L).validate(instance)).hasMessageContaining("defaultMaxBytes");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Short upload helper using the first visible parent.
|
||||
*******************************************************************************/
|
||||
private AttachmentEntry upload(String name, String value) throws QException
|
||||
{
|
||||
return (service.upload("document", 1, name, "text/plain", stream(value), "description"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UTF-8 payload.
|
||||
*******************************************************************************/
|
||||
private ByteArrayInputStream stream(String value)
|
||||
{
|
||||
return (new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect persistence without producing URLs.
|
||||
*******************************************************************************/
|
||||
private QRecord raw(Integer id) throws QException
|
||||
{
|
||||
return (AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fake bucket preserving bytes and capturing API requests. No provider emulation.
|
||||
*******************************************************************************/
|
||||
private static class FakeStorage extends AttachmentStorage
|
||||
{
|
||||
private final Map<String, byte[]> objects = new HashMap<>();
|
||||
private final List<PresignGet> signatures = new ArrayList<>();
|
||||
private long limit = Long.MAX_VALUE;
|
||||
private PutRequest lastPut;
|
||||
private PresignGet lastGet;
|
||||
private boolean failDelete;
|
||||
|
||||
@Override
|
||||
public long maxBytes(String name)
|
||||
{
|
||||
return (limit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String name, PutRequest request)
|
||||
{
|
||||
lastPut = request;
|
||||
try
|
||||
{
|
||||
objects.put(name + "/" + request.getKey(), request.getStream().readAllBytes());
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String name, String key)
|
||||
{
|
||||
if(failDelete)
|
||||
{
|
||||
throw (new IllegalStateException("Storage unavailable"));
|
||||
}
|
||||
objects.remove(name + "/" + key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL presign(String name, PresignGet request)
|
||||
{
|
||||
lastGet = request;
|
||||
signatures.add(request);
|
||||
try
|
||||
{
|
||||
return (URI.create("https://signed.example/" + request.getKey()).toURL());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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.recents;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSchema;
|
||||
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
|
||||
import com.kingsrook.qbits.collaboration.PersonalRecords;
|
||||
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
|
||||
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoriteEntry;
|
||||
import com.kingsrook.qbits.collaboration.favorites.FavoritesService;
|
||||
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
|
||||
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
||||
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.MemoryBackendModule;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted throttling, visible ordering, bounded retention and summary counts.
|
||||
*******************************************************************************/
|
||||
class RecentsServiceTest extends PersonalServiceFixture
|
||||
{
|
||||
private static final Instant START = Instant.parse("2026-10-13T12:00:00Z");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not leak an injected clock to another test.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void resetClock()
|
||||
{
|
||||
RecentsService.clock = Clock.systemUTC();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact 60-second boundary updates; 59 seconds does not write anything.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void throttlesPerTargetAndUser() throws Exception
|
||||
{
|
||||
at(START);
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
QRecord initial = rows().getFirst();
|
||||
at(START.plusSeconds(59));
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
assertThat(rows().getFirst().getValues()).isEqualTo(initial.getValues());
|
||||
at(START.plusSeconds(60));
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
assertThat(rows()).hasSize(1);
|
||||
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
|
||||
assertThat(rows().getFirst().getValueInstant("lastViewedAt")).isEqualTo(START.plusSeconds(60));
|
||||
RecentsService.recordView("RECORD", "document", "2");
|
||||
assertThat(rows()).hasSize(2);
|
||||
login("bob");
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECENT)).getRecords()).isEmpty();
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
assertThat(rows()).hasSize(1);
|
||||
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(1);
|
||||
admin();
|
||||
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.RECENT)).getRecords()).hasSize(3);
|
||||
assertThat(RecentsService.list(50)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null ids also upsert; visibility is checked even inside the throttle window.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableUpsertAndInvisibleViews() throws Exception
|
||||
{
|
||||
at(START);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
assertThat(rows()).hasSize(1);
|
||||
at(START.plusSeconds(61));
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
assertThat(rows().getFirst().getValueInteger("viewCount")).isEqualTo(2);
|
||||
assertThatThrownBy(() -> RecentsService.recordView("RECORD", "document", "3")).hasMessage("Not found");
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
QContext.getQSession().getPermissions().remove("document.read");
|
||||
assertThatThrownBy(() -> RecentsService.recordView("RECORD", "document", "1")).hasMessage("Not found");
|
||||
assertThat(RecentsService.list(50)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest-first ordering is stable; locked rows are dropped before the limit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void limitOrderAndVisibility() throws Exception
|
||||
{
|
||||
at(START);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
at(START.plusSeconds(1));
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
at(START.plusSeconds(2));
|
||||
RecentsService.recordView("RECORD", "document", "2");
|
||||
assertThat(RecentsService.list(2)).extracting(FavoriteEntry::recordId).containsExactly("2", "1");
|
||||
assertThat(RecentsService.list(50)).hasSize(3);
|
||||
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
|
||||
assertThat(RecentsService.list(1)).extracting(FavoriteEntry::targetType).containsExactly("TABLE");
|
||||
assertThatThrownBy(() -> RecentsService.list(51)).hasMessage("Limit must be between 1 and 50");
|
||||
assertThatThrownBy(() -> RecentsService.list(0)).hasMessage("Limit must be between 1 and 50");
|
||||
assertThatThrownBy(() -> RecentsService.list(null)).hasMessage("Limit must be between 1 and 50");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A page full of unavailable rows must not hide older, accessible targets.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refillsAfterUnavailablePage() throws Exception
|
||||
{
|
||||
at(START);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
for(int i = 0; i < 51; i++)
|
||||
{
|
||||
PersonalRecords.insert(CollaborationTables.RECENT, new QRecord().withValue("userId", "alice").withValue("targetType", "TABLE")
|
||||
.withValue("targetName", "removed" + i).withValue("label", "Old private label").withValue("lastViewedAt", START.plusSeconds(i + 1)).withValue("viewCount", 1));
|
||||
}
|
||||
assertThat(RecentsService.list(50)).extracting(FavoriteEntry::label).containsExactly("Documents");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A real backend process keeps newest N independently for each owner.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void trimProcessAndExecutionPermission() throws Exception
|
||||
{
|
||||
config.setRecentsPerUser(2);
|
||||
for(String user : List.of("alice", "bob"))
|
||||
{
|
||||
login(user);
|
||||
at(START);
|
||||
RecentsService.recordView("RECORD", "document", "1");
|
||||
at(START.plusSeconds(1));
|
||||
RecentsService.recordView("RECORD", "document", "2");
|
||||
at(START.plusSeconds(2));
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
}
|
||||
assertThatThrownBy(() -> new TrimRecentsProcess().run(new RunBackendStepInput(), new RunBackendStepOutput())).hasMessageContaining("collaboration.admin");
|
||||
assertThatThrownBy(() -> new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME))).hasMessageContaining("collaboration.admin");
|
||||
admin();
|
||||
new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME));
|
||||
assertThat(PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter())).hasSize(4);
|
||||
for(String user : List.of("alice", "bob"))
|
||||
{
|
||||
login(user);
|
||||
assertThat(RecentsService.list(50)).extracting(FavoriteEntry::recordId).containsExactly(null, "2");
|
||||
}
|
||||
QSystemUserSession system = new QSystemUserSession();
|
||||
QContext.init(instance, system);
|
||||
var before = new HashMap<>(system.getSecurityKeyValues());
|
||||
CollaborationSecurity.applyToSession(instance, system);
|
||||
CollaborationSecurity.ensureSessionKeys(instance, system);
|
||||
assertThat(system.getSecurityKeyValues()).isEqualTo(before);
|
||||
config.setRecentsPerUser(1);
|
||||
new RunProcessAction().execute(new RunProcessInput().withProcessName(TrimRecentsProcess.NAME));
|
||||
assertThat(PersonalRecords.query(CollaborationTables.RECENT, new QQueryFilter())).hasSize(2);
|
||||
assertThat(QContext.getQSession()).isSameAs(system);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The daily schedule and metadata remain valid without adding nav processes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void metadataSchemaAndConfiguration() throws Exception
|
||||
{
|
||||
assertThat(new CollaborationQBitConfig().getRecentsPerUser()).isEqualTo(200);
|
||||
assertThat(instance.getProcess(TrimRecentsProcess.NAME).getIsHidden()).isTrue();
|
||||
assertThat(instance.getProcess(TrimRecentsProcess.NAME).getSchedule()).isNull();
|
||||
assertThatThrownBy(() -> config.withRecentsPerUser(0).validate(instance)).hasMessageContaining("recentsPerUser");
|
||||
assertThatThrownBy(() -> config.withRecentsPerUser(null).validate(instance)).hasMessageContaining("recentsPerUser");
|
||||
config.withRecentsPerUser(200);
|
||||
assertThatThrownBy(() -> config.withSchedulerName("missing").validate(instance)).hasMessageContaining("scheduler");
|
||||
instance.addScheduler(new SimpleSchedulerMetaData().withName("daily"));
|
||||
config.withSchedulerName("daily");
|
||||
config.validate(instance);
|
||||
QProcessMetaData scheduled = TrimRecentsProcess.metadata(config);
|
||||
assertThat(scheduled.getSchedule().getSchedulerName()).isEqualTo("daily");
|
||||
assertThat(scheduled.getSchedule().getRepeatSeconds()).isEqualTo(86400);
|
||||
instance.getProcess(TrimRecentsProcess.NAME).setSchedule(scheduled.getSchedule());
|
||||
new QInstanceValidator().validate(instance);
|
||||
for(String table : List.of(CollaborationTables.FAVORITE, CollaborationTables.RECENT))
|
||||
{
|
||||
assertThat(instance.getTable(table).getAuditRules().getAuditLevel()).isEqualTo(AuditLevel.NONE);
|
||||
assertThat(instance.getTable(table).getPermissionRules().getLevel()).isEqualTo(PermissionLevel.NOT_PROTECTED);
|
||||
assertThat(instance.getTable(table).getUniqueKeys().getFirst().getFieldNames()).containsExactly("userId", "targetType", "targetName", "recordId");
|
||||
assertThat(instance.getTable(table).getSections()).extracting(section -> section.getName()).containsExactly("identity", "details", "dates");
|
||||
}
|
||||
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName()).doesNotContain(CollaborationTables.FAVORITE, CollaborationTables.RECENT, TrimRecentsProcess.NAME);
|
||||
assertThat(instance.getPossibleValueSource(CollaborationTables.TARGET_TYPE).getEnumValues()).hasSize(6);
|
||||
assertThat(CollaborationSchema.postgresScript(config)).contains("CREATE TABLE IF NOT EXISTS collaboration_favorite", "CREATE TABLE IF NOT EXISTS collaboration_recent",
|
||||
"(user_id, target_type, target_name, record_id) NULLS NOT DISTINCT", "last_viewed_at TIMESTAMPTZ NOT NULL", "view_count INTEGER NOT NULL");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts use the current user's owner lock and admins see real distinct users.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void homeSummaryCounts() throws Exception
|
||||
{
|
||||
at(START);
|
||||
login("alice", CollaborationPermissions.VIEW);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
FavoritesService.star("RECORD", "document", "1");
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
login("bob", CollaborationPermissions.VIEW);
|
||||
FavoritesService.star("TABLE", "document", null);
|
||||
RecentsService.recordView("TABLE", "document", null);
|
||||
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 1</p><p>Distinct users: 1", "<h3>Recently viewed</h3><p>Rows: 1</p><p>Distinct users: 1");
|
||||
admin();
|
||||
assertThat(CollaborationHome.render(START)).contains("<h3>Favorites</h3><p>Rows: 3</p><p>Distinct users: 2", "<h3>Recently viewed</h3><p>Rows: 2</p><p>Distinct users: 2");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Freeze the service clock, including exact boundary cases.
|
||||
*******************************************************************************/
|
||||
private void at(Instant instant)
|
||||
{
|
||||
RecentsService.clock = Clock.fixed(instant, ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect persisted rows for the current owner without altering their values.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows() throws Exception
|
||||
{
|
||||
return (PersonalRecords.query(CollaborationTables.RECENT, PersonalRecords.ownFilter()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user