Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79d241e1f6
|
||
|
|
c47d5a1c18 | ||
|
|
677aa54801
|
||
|
|
44a463e4d9
|
||
|
|
32f91b75c2
|
||
|
|
32239cd57d
|
||
|
|
950a8018c7
|
||
|
|
43bfe48feb
|
||
|
|
ead0c904ce
|
||
|
|
579946d62c
|
||
|
|
4f53c29318
|
||
|
|
cd3c0393a2
|
||
|
|
d9f74cb576
|
||
|
|
d12a7777af | ||
|
|
5f6a5022ce | ||
|
|
3c9a4d229b | ||
|
|
c5e1dd7e14 | ||
|
|
d6ebb60de6 | ||
|
|
e41bf1147b | ||
|
|
d4ca427445 | ||
|
|
2542dc0f89
|
||
|
|
c6954d86f1 | ||
|
|
05bfcd8d4b
|
||
|
|
d3cc594b0e | ||
|
|
ff182b6d7f
|
||
|
|
7482050234 | ||
|
|
27324a4159
|
||
|
|
37ee9028f2 | ||
|
|
c8a231f5f8
|
||
|
|
0a8c95e0ad | ||
|
|
f0e9836a89
|
||
|
|
dd4643e60d | ||
|
|
483c856594
|
||
|
|
275cc26b6b | ||
|
|
3e5cec20d2
|
||
|
|
1713d18fd7 | ||
|
|
6073091434 | ||
|
|
db241b03bc
|
||
|
|
10eb8807ba
|
||
|
|
ac55e437bd | ||
|
|
3ba219d668 | ||
|
|
d0572402a9
|
||
|
|
e6ff62181b
|
||
|
|
dc8ac5f089
|
||
|
|
7a5e42495d
|
||
|
|
5098152460 | ||
|
|
ab64cbf040 | ||
|
|
6261e197d0 | ||
|
|
3672bb513d | ||
|
|
182fb46a66
|
||
|
|
516c250d4f
|
||
|
|
c0646db521
|
||
|
|
5fcf0d1f8d
|
||
|
|
89978cf3d9
|
||
|
|
f0281057d7
|
||
|
|
c1b78d3077
|
||
|
|
00a20eb5c8
|
||
|
|
8aca382843
|
||
|
|
02f4e993d1
|
||
|
|
24f1cbec33
|
||
|
|
415d0bdd0a
|
||
|
|
89e468ca45
|
||
|
|
3296cb9ca4
|
||
|
|
23896eb7f8
|
||
|
|
1cd1caa8c5
|
||
|
|
852d474722 | ||
|
|
afc9d4d29d
|
||
|
|
0d2943a764
|
||
|
|
ec8092e468 | ||
|
|
a26c518fb2
|
No files matched your search
@@ -1,14 +1,14 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
ignore: /.*/
|
||||
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
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -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:
|
||||
update-types: [minor, patch]
|
||||
@@ -0,0 +1,31 @@
|
||||
# GitHub Release Configuration
|
||||
# Auto-generates release notes from PR labels
|
||||
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
- duplicate
|
||||
- invalid
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking
|
||||
- title: New Features
|
||||
labels:
|
||||
- enhancement
|
||||
- feature
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: Dependencies
|
||||
labels:
|
||||
- dependencies
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -20,4 +20,4 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@17c94592d94c7d56c440277be6fe3526692887a6
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+15
-25
@@ -1,31 +1,21 @@
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.env
|
||||
.idea
|
||||
*.iml
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build
|
||||
*.class
|
||||
*.log
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
hs_err_pid*
|
||||
.DS_Store
|
||||
*.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
|
||||
+7
-6
@@ -1,15 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
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
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release-no-integration
|
||||
publish:
|
||||
snapshot: false
|
||||
release_candidate: false
|
||||
release: false
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -0,0 +1,104 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
QBit File Gateway runs an SFTP server (Apache MINA SSHD) that exposes a QQQ application as a
|
||||
folder tree: apps, tables, `upload/<bulk-load-profile>/`, `views/`, `reports/`, `results/` and
|
||||
`archive/`. Users and service accounts sign in with SSH public keys. All data access goes through
|
||||
the `/qqq/v1` UI API with `UiApiInvoker` on the client channel `sftp`, as the signed-in user; never
|
||||
through core actions directly. It runs as its own Kubernetes Deployment from the same application
|
||||
code and QInstance. Design: `docs/DESIGN.md`.
|
||||
|
||||
## Build commands
|
||||
|
||||
```bash
|
||||
./mvnw compile
|
||||
./mvnw test
|
||||
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
|
||||
```
|
||||
|
||||
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Package: `com.kingsrook.qbits.filegateway`. Entry point: `FileGatewayQBitProducer implements
|
||||
QBitMetaDataProducer<FileGatewayQBitConfig>`.
|
||||
|
||||
- `metadata/`: seven stable tables, enum/picker sources, joins, typed child widgets, key/account
|
||||
validators and streamed Review/Result admin actions. Producer adds metadata to the QInstance.
|
||||
- `model/`: QRecordEntity mappings; DDL in `docs/ddl/file_gateway.sql` uses UTC zone-less timestamps.
|
||||
- `permissions/`: view/admin/connect constants and the custom permission checker; system-written
|
||||
logs/jobs/outbox tables remain read-only to people.
|
||||
- `FileGatewayRuntime`: host-owned startup after the UI API server, in-memory host-key parsing,
|
||||
injectable environment and clock/test hooks. Production polling interval is 60 seconds.
|
||||
- `sftp/`: public-key-only MINA listener, per-account transactional authentication, per-pod IP
|
||||
windows, token refresh/revocation and system-session bookkeeping restricted to owned tables.
|
||||
- `vfs/`: custom NIO filesystem; per-session UI API metadata/saved-object caches. Every API call
|
||||
checks its operation spec and sends user credentials plus `sftp/<sessionId>` client detail.
|
||||
- `api/`: isolated consumer/outbox contract. Never import gateway implementation packages here.
|
||||
|
||||
Phase (b) adds `upload/` (bounded streaming channels, database quota reservations, worker claims,
|
||||
scanner/consumer pipeline and UI API bulk load) and `storage/` (the object-storage API adapter
|
||||
and ranged read channels). Production uses object storage; the memory implementation is tests-only.
|
||||
The runtime starts a bounded worker pool with SFTP and releases owned claims on stop. Sidecars,
|
||||
results and archive are readable; sidecars/results can be acknowledged. Retry upload is a
|
||||
standard Review/Result record action. The upload-job schema now includes `results_size_bytes`.
|
||||
|
||||
Claims compare expected status under an account-row transaction; core's optimistic version check
|
||||
alone does not add a SQL WHERE guard. Fresh worker user sessions outlive SSH connections and are
|
||||
revoked after delivery. Bulk process multipart field: `theFile`; mapping frontend step: `fileMapping`
|
||||
(resumes `receiveFileMapping`). RC.2 object storage owns S3 part size at connection level. The
|
||||
private invoker supports multipart process values without a file part, so the mapping request
|
||||
sends only its values. See README adoption prerequisites and compatibility notes.
|
||||
|
||||
Phase (c) adds `GeneratedDownloads` (user-scoped export/render/download API flow and durable
|
||||
account-locked download quotas) and `GeneratedReadChannel` (fixed rolling window, no spool).
|
||||
Saved-view JSON uses `queryFilter.orderBys` and `queryColumns.columns` visibility/order. Generated
|
||||
stat size is zero; reads fill requests until real EOF to avoid MINA's short-read EOF inference.
|
||||
Reports list only with a configured render-process storage table. FieldSecurityLock DENY tests
|
||||
require `enforcePermissionsInCoreActions=true` and match direct same-user UI export bytes.
|
||||
Core `RenderSavedReportExecuteStep` stages a rendered report in a JVM temp file and deletes it
|
||||
in try-with-resources; that is the bounded temp area the owner rule allows (in-memory `/tmp`).
|
||||
The core-valid alert type is `filegateway.lockout` (dotted names only).
|
||||
|
||||
Phase (e) adds endpoints and chroot (`AliasCustomizer` validates paths), `outbox/` (the
|
||||
`FileGatewayOutboxProvider`, registered in every deployment), `pgp/` (BouncyCastle stream
|
||||
decrypt/encrypt, keys in memory), `maintenance/` (daily retention process and record-action
|
||||
effects), `FileGatewayHome` (typed STATISTICS and TABLE widgets only; guardrail G5),
|
||||
`FileGatewayIntegrations` (ESB, Quick Search, API lists) and `file_gateway_server` heartbeats.
|
||||
The `file-transfer` Preferences section requires only `file_gateway.connect`. Its provider uses
|
||||
core's trusted scope with the original session, derives identity only from the request context,
|
||||
and manages the caller's unrevoked USER-account keys through normal table actions. Add key
|
||||
creates an account on first use; Revoke sets `revoked_at`; PATCH edits labels and expiry.
|
||||
Optional `publicHostName` advertises the hostname; port/fingerprint use the latest heartbeat.
|
||||
The k8s example is in `docs/k8s/`.
|
||||
|
||||
Tests run a strict UI API server, H2 from the adopter DDL and real MINA clients. NoDiskExtension
|
||||
snapshots the working tree (excluding target) and isolated test home, and asserts the test temp
|
||||
directory is empty. The host supplies RDBMS; `qqq-backend-module-rdbms` must remain test-scoped.
|
||||
|
||||
## Invariants and naming
|
||||
|
||||
- Data access only through `/qqq/v1` (`UiApiInvoker`, channel `sftp`) as the signed-in user. The
|
||||
gateway writes only its own `file_gateway_*` tables directly.
|
||||
- SFTP only, SSH-key auth only. No password login of any kind; plain FTP is never allowed.
|
||||
- Tables use lowercase snake_case with the `file_gateway_` prefix; backend names equal table
|
||||
names. Permissions use the full `file_gateway` prefix. Processes are table or record actions,
|
||||
never navigation entries. One app home.
|
||||
- No secrets in code or config: host keys come from env-var names.
|
||||
|
||||
## Code style
|
||||
|
||||
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-RC.7` (first release with QRun-IO/qqq#1170); never a SNAPSHOT.
|
||||
Only allow-listed qbit API artifacts (`qbit-object-storage` `api` package) may be depended on.
|
||||
Notifications go through core `QAlerts.notify`, never qbit-alerts classes.
|
||||
|
||||
## Release flow
|
||||
|
||||
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never push `hotfix/*` or `release/*` branches by hand; never publish manually.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upload-job status transitions no longer use `status` as the table's record-version field.
|
||||
QQQ 4.1.0-RC.26 advances a configured version field on every update (a STRING version
|
||||
becomes a random UUID), which overwrote upload status. The guarded transition now re-reads
|
||||
status inside the existing cross-pod account-lock transaction. The upload-job table no longer
|
||||
declares a record-version field; no schema or public API change.
|
||||
|
||||
- Endpoint pickers for saved views, saved reports and saved bulk-load profiles are dropped when
|
||||
the host has not registered those possible-value sources, so hosts without them validate.
|
||||
|
||||
### Added
|
||||
|
||||
- File transfer Preferences with connect-permission visibility, read-only connection details,
|
||||
optional public hostname, self-service Add key/Revoke actions and editable key labels/expiry.
|
||||
- First-key USER account creation with configured defaults and unique usernames; transactional
|
||||
ownership checks, friendly duplicate-key validation, core/HTTP tests and real SFTP acceptance.
|
||||
|
||||
- Phase (c) generated saved-view and saved-report downloads through the user-scoped UI API,
|
||||
CSV/XLSX/JSON account formats, a bounded rolling read window and HTTP stream cleanup.
|
||||
- Generated-file and UTC daily-download quotas, persisted byte charges under account locks,
|
||||
per-file VIEW/REPORT transfer auditing and actionable SFTP API/limit errors.
|
||||
- Real MINA download tests for filters/column order, POI XLSX validation, JSON, FieldSecurityLock
|
||||
DENY parity, report rendering, hidden tables/storage, large streaming exports and quotas.
|
||||
- Phase (b) bounded streaming uploads through the object-storage system API, SHA-256, concurrent
|
||||
byte quotas, temporary-name promotion, dedupe and transfer auditing.
|
||||
- Bounded upload workers with guarded claims, fresh user sessions, scanner/quarantine hooks,
|
||||
named consumers, retry scheduling, stale recovery and shutdown requeue.
|
||||
- Built-in bulk-load consumer using only spec-defined UI API operations, saved mappings,
|
||||
inserted/error summaries, per-row examples, notifications and a Retry upload record action.
|
||||
- Readable sidecars, results CSV and ranged archive downloads with account isolation and
|
||||
acknowledgement. Added `results_size_bytes` to the upload-job schema.
|
||||
- Real MINA upload acceptance coverage, including permissions, auditing, limits, reordering,
|
||||
consumer retries, quarantine, worker concurrency and the unchanged no-disk guard.
|
||||
|
||||
- Phase (a) SSH-key-only SFTP runtime with in-memory host keys, explicit modern algorithms,
|
||||
account/key expiry, numeric CIDR allowlists, transactional account lockout/session limits,
|
||||
per-IP limits, user-session refresh and live revocation.
|
||||
- Virtual listing/stat of permitted apps, tables, saved bulk-load profiles, views and reports
|
||||
through the strict UI API invoker, with per-session caches and paged saved-object queries.
|
||||
- Seven gateway tables and record entities, H2/PostgreSQL DDL, enum sources, dependent endpoint
|
||||
pickers, joins, typed child lists, permissions and channel walls.
|
||||
- SSH key/account validation and Review/Result actions to unlock/disable accounts, revoke keys
|
||||
and end sessions; connection and operation auditing, including denied mutations.
|
||||
- Isolated consumer/outbox API contract and complete configuration for subsequent phases.
|
||||
- Real SFTP/strict UI API acceptance tests, no-disk guard, schema round trips and adoption docs.
|
||||
|
||||
- Endpoints (aliases) with path validation and ENDPOINTS_ONLY chroot; BULK_LOAD, INBOX, VIEW,
|
||||
REPORT and OUTBOX kinds.
|
||||
- The outbox provider (`FileGatewayOutbox.put`), outbox files, pickup tracking and `onDownloaded`.
|
||||
- Optional per-account PGP decryption of uploads and encryption of downloads.
|
||||
- The typed app home, `file_gateway_server` heartbeats, Unlock/Disable/Revoke/End session record
|
||||
actions, the daily retention process and `FileGatewayIntegrations`.
|
||||
- Kubernetes example manifests in `docs/k8s/`.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- Builds on QQQ `4.1.0-RC.7` (the first release with QRun-IO/qqq#1170). RDBMS remains a
|
||||
host-provided runtime dependency.
|
||||
- Alert type `filegateway.lockout` uses the dotted syntax required by core validation.
|
||||
- Object-storage RC.2 controls S3 part size through its connection configuration.
|
||||
- Core #1170 sends multipart process values without re-uploading the original file.
|
||||
- FieldSecurityLock requires core permission enforcement enabled. Saved-report rendering still
|
||||
uses a temporary file inside core; fully disk-free report generation needs a core change.
|
||||
The report folder is hidden without the render process's configured storage table.
|
||||
@@ -1,25 +1,104 @@
|
||||
# qbit-template-application
|
||||
# AGENTS.md
|
||||
|
||||
GitHub template repository for scaffolding Application QBits (complete mini-applications
|
||||
with tables, processes, widgets, and UI navigation) on the QQQ platform.
|
||||
Guidance for coding agents working in this repository.
|
||||
|
||||
## QQQ 4.0 baseline — 2026-09-24
|
||||
## 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 File Gateway runs an SFTP server (Apache MINA SSHD) that exposes a QQQ application as a
|
||||
folder tree: apps, tables, `upload/<bulk-load-profile>/`, `views/`, `reports/`, `results/` and
|
||||
`archive/`. Users and service accounts sign in with SSH public keys. All data access goes through
|
||||
the `/qqq/v1` UI API with `UiApiInvoker` on the client channel `sftp`, as the signed-in user; never
|
||||
through core actions directly. It runs as its own Kubernetes Deployment from the same application
|
||||
code and QInstance. Design: `docs/DESIGN.md`.
|
||||
|
||||
## Knowledge base
|
||||
## Build commands
|
||||
|
||||
Reviewed dossier and platform knowledge live in the second-brain vault:
|
||||
```bash
|
||||
./mvnw compile
|
||||
./mvnw test
|
||||
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
|
||||
```
|
||||
|
||||
- Hub: `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-application.md`
|
||||
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
|
||||
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
|
||||
|
||||
Reviewed at commit `fb9395ac0d07` (branch `main`, 2026-07-04).
|
||||
## Architecture
|
||||
|
||||
Key caveat from that review: the scaffold in `src/main` references six qqq APIs that do not
|
||||
exist in any qqq version (wrong packages for `MetaDataProducerInterface` /
|
||||
`AbstractWidgetRenderer` / `RenderWidgetInput`, plus nonexistent `QInstance.addAppSection`,
|
||||
`QProcessMetaData.withInputFields`, `QAppSection.withSourceQBitName`) — it has never compiled,
|
||||
and CI on `main` is red. Verify against the qqq checkout (`R:/Git.Local/QRunIO/qqq`) before
|
||||
trusting any API usage shown here.
|
||||
Package: `com.kingsrook.qbits.filegateway`. Entry point: `FileGatewayQBitProducer implements
|
||||
QBitMetaDataProducer<FileGatewayQBitConfig>`.
|
||||
|
||||
- `metadata/`: seven stable tables, enum/picker sources, joins, typed child widgets, key/account
|
||||
validators and streamed Review/Result admin actions. Producer adds metadata to the QInstance.
|
||||
- `model/`: QRecordEntity mappings; DDL in `docs/ddl/file_gateway.sql` uses UTC zone-less timestamps.
|
||||
- `permissions/`: view/admin/connect constants and the custom permission checker; system-written
|
||||
logs/jobs/outbox tables remain read-only to people.
|
||||
- `FileGatewayRuntime`: host-owned startup after the UI API server, in-memory host-key parsing,
|
||||
injectable environment and clock/test hooks. Production polling interval is 60 seconds.
|
||||
- `sftp/`: public-key-only MINA listener, per-account transactional authentication, per-pod IP
|
||||
windows, token refresh/revocation and system-session bookkeeping restricted to owned tables.
|
||||
- `vfs/`: custom NIO filesystem; per-session UI API metadata/saved-object caches. Every API call
|
||||
checks its operation spec and sends user credentials plus `sftp/<sessionId>` client detail.
|
||||
- `api/`: isolated consumer/outbox contract. Never import gateway implementation packages here.
|
||||
|
||||
Phase (b) adds `upload/` (bounded streaming channels, database quota reservations, worker claims,
|
||||
scanner/consumer pipeline and UI API bulk load) and `storage/` (the object-storage API adapter
|
||||
and ranged read channels). Production uses object storage; the memory implementation is tests-only.
|
||||
The runtime starts a bounded worker pool with SFTP and releases owned claims on stop. Sidecars,
|
||||
results and archive are readable; sidecars/results can be acknowledged. Retry upload is a
|
||||
standard Review/Result record action. The upload-job schema now includes `results_size_bytes`.
|
||||
|
||||
Claims compare expected status under an account-row transaction; core's optimistic version check
|
||||
alone does not add a SQL WHERE guard. Fresh worker user sessions outlive SSH connections and are
|
||||
revoked after delivery. Bulk process multipart field: `theFile`; mapping frontend step: `fileMapping`
|
||||
(resumes `receiveFileMapping`). RC.2 object storage owns S3 part size at connection level. The
|
||||
private invoker supports multipart process values without a file part, so the mapping request
|
||||
sends only its values. See README adoption prerequisites and compatibility notes.
|
||||
|
||||
Phase (c) adds `GeneratedDownloads` (user-scoped export/render/download API flow and durable
|
||||
account-locked download quotas) and `GeneratedReadChannel` (fixed rolling window, no spool).
|
||||
Saved-view JSON uses `queryFilter.orderBys` and `queryColumns.columns` visibility/order. Generated
|
||||
stat size is zero; reads fill requests until real EOF to avoid MINA's short-read EOF inference.
|
||||
Reports list only with a configured render-process storage table. FieldSecurityLock DENY tests
|
||||
require `enforcePermissionsInCoreActions=true` and match direct same-user UI export bytes.
|
||||
Core `RenderSavedReportExecuteStep` stages a rendered report in a JVM temp file and deletes it
|
||||
in try-with-resources; that is the bounded temp area the owner rule allows (in-memory `/tmp`).
|
||||
The core-valid alert type is `filegateway.lockout` (dotted names only).
|
||||
|
||||
Phase (e) adds endpoints and chroot (`AliasCustomizer` validates paths), `outbox/` (the
|
||||
`FileGatewayOutboxProvider`, registered in every deployment), `pgp/` (BouncyCastle stream
|
||||
decrypt/encrypt, keys in memory), `maintenance/` (daily retention process and record-action
|
||||
effects), `FileGatewayHome` (typed STATISTICS and TABLE widgets only; guardrail G5),
|
||||
`FileGatewayIntegrations` (ESB, Quick Search, API lists) and `file_gateway_server` heartbeats.
|
||||
The `file-transfer` Preferences section requires only `file_gateway.connect`. Its provider uses
|
||||
core's trusted scope with the original session, derives identity only from the request context,
|
||||
and manages the caller's unrevoked USER-account keys through normal table actions. Add key
|
||||
creates an account on first use; Revoke sets `revoked_at`; PATCH edits labels and expiry.
|
||||
Optional `publicHostName` advertises the hostname; port/fingerprint use the latest heartbeat.
|
||||
The k8s example is in `docs/k8s/`.
|
||||
|
||||
Tests run a strict UI API server, H2 from the adopter DDL and real MINA clients. NoDiskExtension
|
||||
snapshots the working tree (excluding target) and isolated test home, and asserts the test temp
|
||||
directory is empty. The host supplies RDBMS; `qqq-backend-module-rdbms` must remain test-scoped.
|
||||
|
||||
## Invariants and naming
|
||||
|
||||
- Data access only through `/qqq/v1` (`UiApiInvoker`, channel `sftp`) as the signed-in user. The
|
||||
gateway writes only its own `file_gateway_*` tables directly.
|
||||
- SFTP only, SSH-key auth only. No password login of any kind; plain FTP is never allowed.
|
||||
- Tables use lowercase snake_case with the `file_gateway_` prefix; backend names equal table
|
||||
names. Permissions use the full `file_gateway` prefix. Processes are table or record actions,
|
||||
never navigation entries. One app home.
|
||||
- No secrets in code or config: host keys come from env-var names.
|
||||
|
||||
## Code style
|
||||
|
||||
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-RC.7` (first release with QRun-IO/qqq#1170); never a SNAPSHOT.
|
||||
Only allow-listed qbit API artifacts (`qbit-object-storage` `api` package) may be depended on.
|
||||
Notifications go through core `QAlerts.notify`, never qbit-alerts classes.
|
||||
|
||||
## Release flow
|
||||
|
||||
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never push `hotfix/*` or `release/*` branches by hand; never publish manually.
|
||||
@@ -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,6 @@
|
||||
# Contributing to QBit File Gateway
|
||||
|
||||
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
|
||||
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
|
||||
* Commits are signed and follow Conventional Commits.
|
||||
* Build and test locally with `./mvnw verify` (Java 21).
|
||||
@@ -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 - Application
|
||||
Copyright 2022-2025 QRunIO, LLC
|
||||
QBit File Gateway
|
||||
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,55 +1,374 @@
|
||||
# QBit Template - Application
|
||||
# QBit File Gateway
|
||||
|
||||
Template repository for creating **Application QBits** - complete, self-contained applications or major functional modules for the QQQ framework.
|
||||
An SSH-key-only SFTP server that presents a QQQ application's permitted apps and tables as a
|
||||
virtual folder tree. Application metadata and saved objects are read through the spec-defined
|
||||
`/qqq/v1` UI API as the signed-in user, using `UiApiInvoker` on channel `sftp`.
|
||||
|
||||
## When to Use This Template
|
||||
Phases (a) through (c) implement authentication, listing/stat, account administration, uploads into
|
||||
saved bulk-load profiles, background consumer delivery, readable sidecars/results/archive and
|
||||
saved-view/report downloads. Phase (e) adds endpoint routing/chroot, outbox delivery, PGP,
|
||||
Preferences, retention and the app home. No native filesystem, host-key file, home directory
|
||||
or spool is used by the gateway. See [the approved design](docs/DESIGN.md).
|
||||
|
||||
Use this template when building:
|
||||
- Complete mini-applications (WMS, OMS, CRM)
|
||||
- Major functional modules with multiple tables
|
||||
- QBits that require UI navigation (QAppSection)
|
||||
- Self-contained features with business logic
|
||||
## Requirements
|
||||
|
||||
## Quick Start
|
||||
Java 21, Apache MINA SSHD 2.16.0, and the QQQ 4.1 invoker APIs. The host supplies the RDBMS
|
||||
backend, qbit-object-storage 0.1.0-RC.2, and core saved bulk-load profile, saved view and saved
|
||||
report metadata. The gateway's
|
||||
RDBMS module dependency is test-scoped. Maven 3.9.11 is pinned by the wrapper.
|
||||
|
||||
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create a repository with **Use this template**, customize its Maven coordinates and Java package/classes, then run `mvn clean verify`. Follow the [Getting Started guide](docs/00-getting-started.md) for the full sequence.
|
||||
QQQ is pinned to `4.1.0-RC.7`, the first release with the UiApiInvoker in-process sessions,
|
||||
streams and multipart (QRun-IO/qqq#1170).
|
||||
|
||||
## Structure
|
||||
## Install
|
||||
|
||||
```
|
||||
src/main/java/com/kingsrook/qbits/example/
|
||||
├── ExampleAppQBitConfig.java
|
||||
├── ExampleAppQBitProducer.java
|
||||
├── model/
|
||||
│ ├── ExampleEntity.java
|
||||
│ └── ExampleChildEntity.java
|
||||
├── processes/
|
||||
│ ├── ExampleProcessStep.java
|
||||
│ └── ExampleProcessMetaDataProducer.java
|
||||
└── widgets/
|
||||
├── ExampleDashboardWidgetRenderer.java
|
||||
└── ExampleDashboardWidgetMetaDataProducer.java
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-file-gateway</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Key Characteristics
|
||||
## Adopting
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| QAppSection | Required - provides UI navigation |
|
||||
| Tables | Multiple interconnected business tables |
|
||||
| Processes | Full CRUD with business logic |
|
||||
| Widgets | Dashboard components |
|
||||
| Security | Permission key support |
|
||||
| Integration | Hooks for other QBits |
|
||||
Apply [docs/ddl/file_gateway.sql](docs/ddl/file_gateway.sql) to the host's database. It supports
|
||||
H2 and PostgreSQL and stores UTC timestamps without a zone, matching QQQ's RDBMS binding.
|
||||
The gateway does not create or migrate tables at runtime.
|
||||
|
||||
## Documentation
|
||||
Register core's saved views, saved reports and saved bulk-load profiles (if the app uses them)
|
||||
before the gateway producer: endpoint pickers for a source the app hasn't registered become plain
|
||||
id fields, so the instance still validates.
|
||||
|
||||
- [Getting Started](docs/00-getting-started.md)
|
||||
- [Tables and Relationships](docs/01-tables-and-relationships.md)
|
||||
- [Processes](docs/02-processes.md)
|
||||
- [Widgets and UI](docs/03-widgets-and-ui.md)
|
||||
Register metadata while building the QInstance:
|
||||
|
||||
## See Also
|
||||
```java
|
||||
FileGatewayQBitConfig config = new FileGatewayQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSftpEnabled(Boolean.parseBoolean(System.getenv("FILE_GATEWAY_SFTP_ENABLED")))
|
||||
.withHostKeyEnvVarName("FILE_GATEWAY_SSH_HOST_KEY");
|
||||
|
||||
- [qbit-template-extension](https://github.com/QRun-IO/qbit-template-extension) - For infrastructure extensions
|
||||
- [qbit-template-data](https://github.com/QRun-IO/qbit-template-data) - For reference data providers
|
||||
new FileGatewayQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
```
|
||||
|
||||
After starting the host's `QApplicationJavalinServer`, start SFTP only in the gateway deployment:
|
||||
|
||||
```java
|
||||
FileGatewayRuntime gateway = null;
|
||||
if(config.getSftpEnabled())
|
||||
{
|
||||
gateway = FileGatewayRuntime.start(qInstance, server);
|
||||
}
|
||||
// Call gateway.stop() during application shutdown when gateway is non-null.
|
||||
```
|
||||
|
||||
Supply an unencrypted OpenSSH private host key as the value of the named environment variable.
|
||||
It is parsed in memory; a missing key fails startup. `allowGeneratedHostKey` is for development
|
||||
only and generates an ephemeral key with a warning. Each deployment uses the same host application
|
||||
and QInstance; expose its SFTP listener separately from the UI HTTP port.
|
||||
|
||||
Create an ACTIVE account with a QQQ `userId`, then add an Ed25519, ECDSA, or RSA public key of at
|
||||
least 3072 bits. Public-key validation runs on insert and update and computes its unique SHA-256
|
||||
fingerprint, type and size. The host authentication module must assign the user's permissions
|
||||
when `createAutomatedSessionForUser` is called, including `file_gateway.connect`.
|
||||
|
||||
## Preferences
|
||||
|
||||
Users with `file_gateway.connect` manage their own SSH keys in **Preferences → File transfer**.
|
||||
They need no File Gateway admin or table permissions. **Add key** accepts an OpenSSH public key,
|
||||
a required label and optional expiry. The first successful key creates a USER account with
|
||||
FULL_TREE access, the configured account defaults and a unique login derived from the user ID.
|
||||
The section displays that login, plus the port and host key fingerprint from the latest gateway
|
||||
heartbeat. Set `.withPublicHostName("sftp.example.com")` on the shared config to display the
|
||||
public hostname; it defaults to null and is omitted when unset. The heartbeat port is the gateway
|
||||
listener port; deployments publishing a different external port must communicate that mapping.
|
||||
|
||||
The keys list contains only the user's unrevoked keys. Users can edit labels and expiry dates,
|
||||
or choose **Revoke**. Revoked keys cannot sign in; existing sessions close at the next revocation
|
||||
poll (normally within 60 seconds). Fingerprints, key types and last-used times are read-only.
|
||||
Ed25519, ECDSA and RSA of at least 3072 bits are supported. Duplicate keys are rejected with a
|
||||
validation message. Normal table customizers and configured audits apply, attributed to the
|
||||
signed-in user and client channel. Administrators manage service accounts and other users' keys
|
||||
through the standard account and SSH-key tables.
|
||||
|
||||
## Tables and permissions
|
||||
|
||||
All seven tables are registered, with camelCase QQQ fields mapped to snake_case database columns.
|
||||
`backendName` selects the host backend; physical table names match the QQQ table names.
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `file_gateway_account` | User/service login, limits, CIDRs, expiry and lockout state |
|
||||
| `file_gateway_ssh_key` | Account child keys and computed fingerprints |
|
||||
| `file_gateway_alias` | Endpoint definitions; routing is a later phase |
|
||||
| `file_gateway_upload_job` | Receiving, queued and completed uploads, retries, results and consumer references |
|
||||
| `file_gateway_outbox_file` | Stable schema for later outbound delivery |
|
||||
| `file_gateway_transfer_log` | Listings, uploads, object downloads and denied operations |
|
||||
| `file_gateway_connection_log` | Accept, auth outcome, heartbeat and disconnect |
|
||||
|
||||
One File Gateway app lists Accounts, Endpoints, Upload Jobs, Transfers and Connections. Keys,
|
||||
outbox files and related jobs/logs appear in typed child lists. Endpoint saved-object pickers
|
||||
are filtered by the selected table. There is no HTML widget or navigation process.
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `file_gateway.connect` | SFTP login, checked again when the user session is refreshed |
|
||||
| `file_gateway.view` | App and read access to gateway tables |
|
||||
| `file_gateway.admin` | Read access and management of accounts, keys, endpoints and record actions |
|
||||
|
||||
System-written job/outbox/audit tables are read-only to people, including admins. Record actions
|
||||
use the standard Review/Result flow: Unlock account, Disable account, Revoke key, End session and
|
||||
Retry upload. End session is enforced by the owning listener's next revocation poll. Retry upload
|
||||
requeues FAILED/REJECTED jobs with a completed stored object and resets their attempts; it
|
||||
rechecks eligibility under an account lock when applying the action.
|
||||
|
||||
The `sftp` channel cannot access gateway, Authentik, audit, credential, API-key, secret, password,
|
||||
permission, role, user-session or webhook administration surfaces. The `assistant` channel also
|
||||
cannot access gateway tables.
|
||||
|
||||
## Listing and authentication
|
||||
|
||||
The root lists visible apps; apps contain child apps and readable tables. Table folders include:
|
||||
|
||||
- `upload/<profile-slug>/` when the user has insert permission, a visible profile and a bulk-insert process;
|
||||
- `views/<slug>.csv` and `.xlsx` (or account-enabled JSON);
|
||||
- `reports/<slug>.<format>` when the saved-report table, render process and its configured storage table are available;
|
||||
- account-scoped `results/<jobId>-<file>.results.csv` and `archive/<yyyy-mm-dd>/<jobId>-<file>`.
|
||||
|
||||
Hidden paths return no-such-file. Slug collisions append record IDs. Saved-object queries are
|
||||
paged. Metadata and saved objects are cached per connection for 60 and 30 seconds respectively.
|
||||
Saved-view/report files report size zero (unknown); clients read until EOF. Stored artifacts
|
||||
report their actual lengths and support ranged reads.
|
||||
|
||||
Only public-key auth is advertised. Password, keyboard-interactive, GSSAPI, host-based auth,
|
||||
shell, exec and forwarding are disabled. The algorithm lists exclude CBC, SHA-1 MAC/KEX and
|
||||
`ssh-rsa` signatures. Idle timeout is 15 minutes; authentication timeout is 30 seconds.
|
||||
|
||||
Account failure counters, operation-rate windows and session reservations are serialized with a database row lock across
|
||||
listeners. Per-IP failure and connection windows are held in each pod. Unlocking an account also
|
||||
clears an IP lock caused solely by that account on its next attempt; mixed-user IP locks expire
|
||||
independently. User sessions are re-minted after five minutes on the next operation, expire after
|
||||
ten minutes, and are revoked on close. The 60-second poll closes disabled/expired/revoked sessions
|
||||
and heartbeats connection rows; three-minute-old heartbeats no longer count as active sessions.
|
||||
|
||||
Lockouts raise core `QAlerts` signals. **Core compatibility:** the alert type is
|
||||
`filegateway.lockout`, because the core validator rejects the design's underscore-only
|
||||
`file_gateway_lockout`. Without an alert sink the core logging fallback is used.
|
||||
|
||||
## Generated downloads
|
||||
|
||||
Opening `views/<slug>.<format>` queries the saved view again as the account user and starts
|
||||
`POST /table/{table}/export` through `UiApiInvoker.invokeForStream`. The saved view's
|
||||
`queryFilter` (including `orderBys`) and visible `queryColumns.columns` are preserved in order;
|
||||
the UI selection column and pagination are excluded. Legacy explicit `fieldNames` are also
|
||||
supported. CSV includes a header; CSV, XLSX and JSON are offered only when enabled in the
|
||||
account's `formats` (default `csv,xlsx`).
|
||||
|
||||
Opening `reports/<slug>.<format>` selects that saved report through the `renderSavedReport`
|
||||
record action, submits `reportFormat`, polls asynchronous status and streams the returned
|
||||
`/download/{file}` storage reference. The process's `storageTableName` input default must name
|
||||
an existing storage-capable table, backed by object storage in production. Without that table,
|
||||
`reports/` is hidden. Reports requiring additional interactive values fail with the API's error.
|
||||
|
||||
Each generated handle retains at most `downloadReadAheadMb` MiB (default 16). Sequential reads
|
||||
and reordered reads within this rolling window work; a seek outside it fails with a message
|
||||
to download sequentially. The HTTP stream closes with the SFTP handle. Read requests are filled
|
||||
until EOF so MINA does not mistake a short HTTP packet for the end of an unknown-size file.
|
||||
|
||||
`maxGeneratedFileMb` (default 1024) and `max_download_mb_per_day` are enforced as generated
|
||||
bytes enter the window. The daily total queries DOWNLOAD transfer rows for this account since
|
||||
UTC midnight, including failed and in-progress transfers; updates hold the account row lock so
|
||||
concurrent generated downloads share the cap. A rejected chunk is not exposed. Re-reading cached
|
||||
bytes does not charge them twice. One DOWNLOAD row per handle records the view/report ID, format,
|
||||
bytes, duration and outcome: OK, LIMIT, ERROR or DENIED for an HTTP 403. API error text reaches
|
||||
the SFTP client.
|
||||
|
||||
**Field security:** configure `QInstance.enforcePermissionsInCoreActions=true` when using this
|
||||
private core build's `FieldSecurityLock` enforcement. Acceptance tests use a DENY lock and compare
|
||||
the generated CSV byte-for-byte with a direct UI export by the same user: default columns omit
|
||||
the denied field. Explicitly selecting a denied field fails as it does through the UI API.
|
||||
|
||||
**Core filesystem limitation:** the current private core's `RenderSavedReportExecuteStep`
|
||||
creates and deletes a temporary file before publishing to storage. The gateway does not spool,
|
||||
and the existing no-disk snapshot/cleanup guard passes, but that guard cannot detect transient
|
||||
files. Fully disk-free saved-report generation remains blocked on replacing core's temporary
|
||||
file with storage streaming. CSV/XLSX/JSON table exports use core's streaming exporters; the
|
||||
50,000-row CSV acceptance test consumes the result with a fixed buffer.
|
||||
|
||||
## Uploads and object storage
|
||||
|
||||
Configure an object-storage bucket alias matching `config.fileGatewayBucketAlias` (default
|
||||
`file-gateway`). Production always uses `ObjectStorage.asSystem("file-gateway", runId)` and only
|
||||
the allow-listed `com.kingsrook.qbits.objectstorage.api` package. Tests inject a memory store.
|
||||
Objects use `file-gateway/uploads/{accountId}/{yyyy}/{mm}/{dd}/{uuid}/{fileName}`;
|
||||
results and quarantine use separate `file-gateway/results/` and `file-gateway/quarantine/` prefixes.
|
||||
The original object backs the virtual archive; it is not copied after a successful import.
|
||||
|
||||
`uploadPartSizeMb` bounds the gateway pipe and `uploadReorderWindowMb` bounds pending writes
|
||||
and gaps. The storage API streams unknown-length content and owns multipart aborts. Its actual
|
||||
S3 part size is configured on the **object-storage connection** (`multipartPartSizeMb`); set it
|
||||
to 8 for the design's default part size. RC.2 does not expose a per-put part-size override through
|
||||
its API. Account file-size and UTC daily-upload quotas are reserved in the database while writing,
|
||||
including concurrent uploads on other pods. Failed transfers retain the received-byte charge.
|
||||
|
||||
The host's `QJavalinMetaData.uploadedFileArchiveTableName` must name a storage-capable table
|
||||
backed by object storage, not a filesystem backend. The bulk-insert UI API uses that table for
|
||||
its process input. The test harness uses core memory storage. Configure the host HTTP server's
|
||||
request limits for the permitted file sizes; these limits also apply to loopback UI API calls.
|
||||
|
||||
A normal close queues the job. Names ending in `.filepart`, `.tmp`, `.part` or `.partial` stay
|
||||
RECEIVING after close until renamed within the same profile folder. Each put is a new job.
|
||||
Dedupe skips content that already SUCCEEDED for the same account, table, profile and consumer
|
||||
within `dedupeWindow`; its `.done` text identifies the earlier job.
|
||||
|
||||
The worker mints a fresh account-user session, requires `file_gateway.connect`, sends every
|
||||
application request through the `sftp` UI API channel, refreshes credentials after five minutes,
|
||||
and revokes them after delivery. It follows the bulk-insert process through upload, mapping,
|
||||
review and result. Profiles must contain their value mappings; the worker fails instead of
|
||||
interactively guessing a mapping. Results contain core summary lines and row examples where
|
||||
core supplies them; a blank CSV field column means no field identifier was supplied by core.
|
||||
|
||||
**Process steps:** the mapping step sends its values as form fields only; the invoker encodes
|
||||
them as multipart because the step operation declares a multipart body (QRun-IO/qqq#1170). The
|
||||
file is uploaded once, at process init.
|
||||
|
||||
Workers are bounded by `uploadWorkerThreads`. Claims compare the expected status inside a
|
||||
short account-row transaction (core's expected-version check alone is not a SQL conditional
|
||||
update). Completion also checks the owning pod and claim timestamp. Retries back off by
|
||||
1 minute, 5 minutes, 15 minutes and 1 hour, then fail on the fifth attempt. The configured
|
||||
schedule also includes a 6-hour slot; the five-attempt cap takes precedence. Consumer-requested
|
||||
delays can lengthen the scheduled wait. Stale RUNNING jobs requeue with a three-start cap;
|
||||
shutdown releases this pod's claims. Delivery is at least once, so custom consumers must dedupe
|
||||
on job ID and digest.
|
||||
|
||||
Optional `FileGatewayScannerInterface` implementations return CLEAN, INFECTED or ERROR.
|
||||
INFECTED objects are copied into quarantine and removed from the original key; ERROR retries.
|
||||
Consumers receive an `InboundFile` with fresh object streams and run with the user's `QContext`.
|
||||
Unknown consumer names are rejected.
|
||||
|
||||
`<file>.processing` appears during receiving/queueing/running. Finished jobs expose `.done` or
|
||||
`.error` text. Sidecars and results disappear after acknowledgement (`rm` on either artifact)
|
||||
or seven days. Archive files remain until later retention processing. Core notifications go to
|
||||
the account user (service account: `ownerUserId`) on FAILED/PARTIAL/REJECTED and on SUCCEEDED
|
||||
when `notifyOnSuccess` is enabled. Without an alerts sink, sending is a no-op.
|
||||
|
||||
Existing phase-(a) databases need this additional column before adopting phase (b):
|
||||
|
||||
```sql
|
||||
ALTER TABLE file_gateway_upload_job ADD COLUMN results_size_bytes BIGINT;
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Every option has a getter and fluent `withX(...)` setter. Configuration for later phases remains
|
||||
present without enabling those features.
|
||||
|
||||
| Options | Defaults |
|
||||
|---|---|
|
||||
| `backendName`, `sftpEnabled`, `sftpPort` | `rdbms`, `false`, `2222` |
|
||||
| `publicHostName` | null (omit host from Preferences) |
|
||||
| `hostKeyEnvVarName`, `allowGeneratedHostKey` | `FILE_GATEWAY_SSH_HOST_KEY`, `false` |
|
||||
| `lockoutThreshold`, `lockoutWindow`, `lockoutDuration` | 5, 15 minutes, 30 minutes |
|
||||
| `maxConnectionsPerIpPerMinute` | 30 |
|
||||
| `defaultMaxConcurrentSessions`, `defaultMaxOperationsPerMinute` | 4, 600 |
|
||||
| `defaultMaxFileSizeMb` | 100 |
|
||||
| `defaultMaxUploadMbPerDay`, `defaultMaxDownloadMbPerDay` | null (unlimited) |
|
||||
| `fileGatewayBucketAlias` | `file-gateway` |
|
||||
| `uploadPartSizeMb`, `uploadReorderWindowMb`, `downloadReadAheadMb`, `maxGeneratedFileMb` | 8, 64, 16, 1024 |
|
||||
| `uploadWorkerThreads`, `dedupeWindow`, `jobStaleAfter` | 4, 7 days, 30 minutes |
|
||||
| `temporaryUploadSuffixes` | `.filepart`, `.tmp`, `.part`, `.partial` |
|
||||
| `scanner`, `pgpPrivateKeyEnvVarName`, `pgpPassphraseEnvVarName` | null |
|
||||
| `logRetention`, `archiveRetention`, `outboxRetention`, `resultsRetention`, `receivingStaleAfter` | 180 days, 30 days, 30 days, 90 days, 1 hour |
|
||||
| `tableMetaDataCustomizer` | null |
|
||||
|
||||
## Consumer and outbox API
|
||||
|
||||
The isolated `com.kingsrook.qbits.filegateway.api` package contains `FileGatewayConsumer`,
|
||||
`InboundFile` (including remote address and a fresh stream supplier), `FileAcceptance`,
|
||||
`OutboxFileRef`, and the consumer metadata registry. `FileGatewayOutbox` delegates to a registered
|
||||
`FileGatewayOutboxProvider`; without one it reports unavailable and throws
|
||||
`FileGatewayUnavailableException`. INBOX endpoints bind a consumer by `consumer_name`; the built-in
|
||||
`bulk-load` consumer serves `upload/<profile>/` folders and BULK_LOAD endpoints. The outbox
|
||||
provider is registered by the producer in every deployment (it needs only the database and object
|
||||
storage), so other qbits can call `FileGatewayOutbox.put(...)` from web pods too.
|
||||
|
||||
## Endpoints and chroot
|
||||
|
||||
`file_gateway_alias` rows ("Endpoints") mount partner-friendly paths such as `/acme/orders-in`:
|
||||
|
||||
| Kind | Behaviour |
|
||||
|---|---|
|
||||
| `BULK_LOAD` | upload folder that bulk-loads with the endpoint's saved profile |
|
||||
| `INBOX` | upload folder whose files go to the `consumer_name` consumer |
|
||||
| `VIEW`, `REPORT` | one generated file (`file_name`, or `<slug>.<format>`) |
|
||||
| `OUTBOX` | files put by `FileGatewayOutbox`; partners download and delete them |
|
||||
|
||||
Paths are absolute, `[a-z0-9._/-]`, unique, and never collide with app names. An endpoint is
|
||||
listed only when it is active, bound to the account (or shared), and its target is reachable by
|
||||
the user through `/qqq/v1`; endpoints grant nothing. `root_mode` FULL_TREE (default for users)
|
||||
shows the app tree plus endpoints; ENDPOINTS_ONLY (default for service accounts) is the chroot.
|
||||
|
||||
## Outbox
|
||||
|
||||
`put` streams to `file-gateway/outbox/{aliasId}/{uuid}/{fileName}` with a SHA-256 and a
|
||||
`file_gateway_outbox_file` row (READY). The first complete download marks it DOWNLOADED and calls
|
||||
`onDownloaded` on the endpoint's consumer (at-least-once); `rm` marks it REMOVED; retention marks
|
||||
old files EXPIRED and deletes the object.
|
||||
|
||||
## PGP (optional, per account)
|
||||
|
||||
`pgp_decrypt_uploads`: uploads named `*.pgp`/`*.gpg` are decrypted stream to stream into a new
|
||||
object before the consumer runs, with the key and passphrase from the env vars named by
|
||||
`pgpPrivateKeyEnvVarName`/`pgpPassphraseEnvVarName` (in memory only); a bad file is REJECTED.
|
||||
`pgp_encrypt_downloads`: downloads are encrypted on the fly to the account's `pgp_public_key`
|
||||
(validated on save) and listed with `.pgp` appended.
|
||||
|
||||
## App home, actions and retention
|
||||
|
||||
The `file_gateway` app home uses typed widgets only: a KPI row (transfers today, failed jobs in
|
||||
24 hours, active sessions, MB in and out today, locked accounts), and full-width tables for recent
|
||||
failures, top partners (7 days), active sessions and gateway servers (host key fingerprint, port,
|
||||
pod, last seen, from `file_gateway_server` heartbeats). Record actions (standard Review/Result,
|
||||
never in the nav): Unlock account, Disable account, Revoke key, End session, Retry upload. A daily
|
||||
scheduled retention process applies the `*Retention` settings and aborts stale RECEIVING uploads.
|
||||
|
||||
## Integrations
|
||||
|
||||
`FileGatewayIntegrations` lists what a host wires in without this qbit depending on those modules:
|
||||
`esbTableNames()` (accounts, endpoints, upload jobs), `quickSearchTables()` (accounts by username
|
||||
and label, endpoints by name and path, upload jobs by file name) and `apiTableNames()` (all tables,
|
||||
with the admin's rules).
|
||||
|
||||
## Kubernetes
|
||||
|
||||
`docs/k8s/gateway-deployment.yaml` and `docs/k8s/gateway-service.yaml`: the same image as the web
|
||||
Deployment with `FILE_GATEWAY_SFTP_ENABLED=true`, the host key from a Secret, a read-only root
|
||||
filesystem with an in-memory `/tmp`, 2 replicas and a PDB, and a LoadBalancer Service exposing
|
||||
port 22 only (no passive range: v1 is SFTP only) with `externalTrafficPolicy: Local`.
|
||||
|
||||
## Build and acceptance tests
|
||||
|
||||
```bash
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd verify
|
||||
```
|
||||
|
||||
The tests use H2 from the adopter DDL, a strict UI API server, generated in-memory SSH keys and
|
||||
real MINA clients. They exercise permissions, auth failures, lockout, competing listeners,
|
||||
revocation/refresh, pagination/caching, audit, key validation, contingent pickers, API isolation,
|
||||
metadata registration and record serialization. Checkstyle and JaCoCo gates require 70%
|
||||
instruction coverage and 90% class coverage.
|
||||
|
||||
Surefire isolates `user.home` under `target/fg-home` and `java.io.tmpdir` under `target/fg-tmp`.
|
||||
The disk guard creates those directories before tests, compares working-tree and home snapshots,
|
||||
and requires an empty temp directory after each protocol test. Build output under `target/`
|
||||
is excluded from the working-tree snapshot.
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE). Copyright Kingsrook, LLC.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Security Policy
|
||||
|
||||
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
|
||||
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
|
||||
@@ -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,30 +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 ExampleAppQBitProducer()
|
||||
.withConfig(new ExampleAppQBitConfig()
|
||||
.withBackendName("rdbms"))
|
||||
.produce(qInstance, "my-application");
|
||||
```
|
||||
|
||||
Register the configured backend in the host first. If you add table prefixes or multiple QBit instances, update related table references, possible-value-source names, and process/widget names consistently. A table prefix alone does not namespace all metadata.
|
||||
|
||||
[Tables and Relationships](01-tables-and-relationships.md) describes the model; the `processes/` and `widgets/` directories contain the remaining examples.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Tables and Relationships
|
||||
|
||||
Application QBits typically have multiple interconnected tables representing a business domain.
|
||||
|
||||
## Entity Design
|
||||
|
||||
Use `@QMetaDataProducingEntity` for automatic metadata generation:
|
||||
|
||||
```java
|
||||
@QMetaDataProducingEntity(producePossibleValueSource = true)
|
||||
public class Order extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "order";
|
||||
|
||||
@QField(isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = "customer")
|
||||
private Integer customerId;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private String status;
|
||||
}
|
||||
```
|
||||
|
||||
## Relationships
|
||||
|
||||
Define foreign keys using `possibleValueSourceName`:
|
||||
|
||||
```java
|
||||
@QField(isRequired = true, possibleValueSourceName = "exampleEntity")
|
||||
private Integer exampleEntityId;
|
||||
```
|
||||
|
||||
## Table Prefixing
|
||||
|
||||
Application QBits support optional table prefixing:
|
||||
|
||||
```java
|
||||
// Without prefix: order, orderLine
|
||||
// With prefix "sales": sales_order, sales_orderLine
|
||||
|
||||
new MyAppQBitProducer()
|
||||
.withConfig(new MyAppQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withTableNamePrefix("sales"))
|
||||
.produce(qInstance, "sales-app");
|
||||
```
|
||||
|
||||
The producer applies the prefix to table names. When extending this pattern, also update possible-value-source names, relationship targets, process tables, and navigation references; table renaming alone does not provide complete multi-instance support.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Processes
|
||||
|
||||
Start with [ExampleProcessMetaDataProducer](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessMetaDataProducer.java) and [ExampleProcessStep](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessStep.java). The producer defines the process, its table, inputs, and backend step; the step transforms `PENDING` records to `PROCESSED` and returns them in the output.
|
||||
|
||||
In QQQ 4.0, import `MetaDataProducerInterface` from `com.kingsrook.qqq.backend.core.model.metadata`. Define process inputs through `withInputMetaData(new QFunctionInputMetaData().withField(...))`. An `AbstractTransformStep` must implement both `runOnePage(RunBackendStepInput, RunBackendStepOutput)` and `getProcessSummary(RunBackendStepOutput, boolean)`; keep the example's implementations when renaming it.
|
||||
|
||||
To make the process discoverable in navigation, add its registered name to a `QAppSection` through `withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))`, then include the section in a `QAppMetaData` registered with `qInstance.addApp(...)`. Use the actual registered name if you introduce a prefix. Add tests for record changes and failure cases when replacing the example business logic.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Widgets and UI
|
||||
|
||||
The application producer registers a `QAppMetaData` containing a `QAppSection`. The section references metadata names already registered in the instance; it is not registered directly on `QInstance`.
|
||||
|
||||
```java
|
||||
QAppSection section = new QAppSection()
|
||||
.withName("exampleSection")
|
||||
.withLabel("Example Application")
|
||||
.withTables(List.of(ExampleEntity.TABLE_NAME, ExampleChildEntity.TABLE_NAME))
|
||||
.withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))
|
||||
.withWidgets(List.of(ExampleDashboardWidgetMetaDataProducer.NAME));
|
||||
|
||||
qInstance.addApp(new QAppMetaData()
|
||||
.withName("exampleApp")
|
||||
.withLabel("Example Application")
|
||||
.withSection(section));
|
||||
```
|
||||
|
||||
Use [ExampleAppQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleAppQBitProducer.java) as the starting point. Keep these references consistent when enabling/disabling tables or introducing prefixes.
|
||||
|
||||
The widget has two parts: [its metadata producer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetMetaDataProducer.java) declares a statistics widget and its renderer, and [its renderer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetRenderer.java) returns a `RenderWidgetOutput` containing `StatisticsData`. Replace the example count with a query or calculation. Choose a widget type that matches the returned data model.
|
||||
|
||||
For QQQ 4.0, `AbstractWidgetRenderer` is in `com.kingsrook.qqq.backend.core.actions.dashboard.widgets`; `RenderWidgetInput` and `RenderWidgetOutput` are in `com.kingsrook.qqq.backend.core.model.actions.widgets`.
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
# QBit File Gateway: design
|
||||
|
||||
Status: **Approved** (owner, 2026-10-13) · drafted 2026-10-13 · Author: James Maes (drafted with Claude)
|
||||
|
||||
Targets:
|
||||
- **`QRun-IO/qbit-file-gateway`** (Apache-2.0): the SFTP server, the virtual filesystem, accounts
|
||||
and keys, uploads, downloads, endpoints (aliases), the consumer and outbox API, audit tables and
|
||||
the app home.
|
||||
- **QQQ core 4.1.x** (`qqq-middleware-javalin`): three small additions to `UiApiInvoker` (§3). The
|
||||
gateway needs them because it acts as a user who signed in with an SSH key, not through an HTTP
|
||||
request, and because it moves files rather than JSON.
|
||||
- **`QRun-IO/qbit-authentik`**: permissions for automated sessions (§3.4).
|
||||
- **kof22** (`KofTwentyTwo/Website-Backend`, `KofTwentyTwo/cluster-gitops`): adoption, the gateway
|
||||
Deployment and its LoadBalancer IP (§11).
|
||||
|
||||
> **Owner decisions (2026-10-12): all recommendations accepted.** Every open question in this spec
|
||||
> is decided as recommended ("they are approved. we will adjust as needed once we can view them
|
||||
> working").
|
||||
>
|
||||
> **Owner decisions (2026-10-13):**
|
||||
> - **A separate gateway deployment.** It runs the same application code and QInstance as its own
|
||||
> Kubernetes Deployment, with its own LoadBalancer IP and port, and scales independently.
|
||||
> - **Data only through `/qqq/v1`.**
|
||||
> - The gateway reaches application data ONLY through the UI API, as the signed-in user, via
|
||||
> `UiApiInvoker` on the client channel `sftp`.
|
||||
> - It never calls core actions on application data.
|
||||
> - It writes its own `file_gateway_*` tables directly.
|
||||
> - **Auth is SSH public keys per user.**
|
||||
> - Users add keys on the core Preferences page, in a "File transfer" section.
|
||||
> - Service accounts (Authentik service users) also get keys, plus IP allowlists, quotas and
|
||||
> expiry.
|
||||
> - **Scope change: v1 is SFTP ONLY, with SSH-key auth.**
|
||||
> - FTPS is dropped from v1, and there is no password login of any kind (no password and no
|
||||
> keyboard-interactive). Plain FTP is never allowed.
|
||||
> - The auth and protocol layers stay pluggable (§6.5).
|
||||
> - "FTPS (future)" is in §15. It would use revocable app passwords and/or client certificates
|
||||
> plus a passive port range, and is added only if a partner needs it.
|
||||
> - The former phase (d), FTPS, is removed. The k8s example has one SFTP port and no passive
|
||||
> range, and the tests use only the MINA SFTP client.
|
||||
> - **No files or filesystem: just the database and S3.** The owner's words: "there are no files
|
||||
> or filesystem - just the database and s3".
|
||||
> - The SFTP tree is purely virtual, built from metadata and data rows.
|
||||
> - Uploads stream directly to object storage (qbit-object-storage), with database rows.
|
||||
> - Downloads are generated or streamed from export, report or object storage.
|
||||
> - There is NO local disk persistence of any kind: no spool directories and no home
|
||||
> directories.
|
||||
> - Temporary buffering is in memory (bounded). Where a library insists on a file, the gateway
|
||||
> uses a bounded area under the JVM temp directory and deletes it in a `finally` block.
|
||||
> Streaming is preferred everywhere.
|
||||
> - The pod is stateless and replaceable at any time. The host key and configuration come from
|
||||
> secrets and database rows.
|
||||
> - A test asserts that nothing is written outside the JVM temp area, and that the temp area is
|
||||
> empty after each transfer (§12).
|
||||
> - **The consumer and outbox API (coordinator, with qbit-edi).**
|
||||
> - A small allow-listed API package, `com.kingsrook.qbits.filegateway.api` (guardrail G1).
|
||||
> - It defines a consumer SPI for inbound files and an outbox for partners that pull (§9).
|
||||
> - Bulk load is the default consumer.
|
||||
> - Consumers are bound to endpoint folders by data (`consumer_name` on the endpoint row).
|
||||
> - The shape was agreed with the qbit-edi agent on 2026-10-13.
|
||||
|
||||
## Owner request (2026-10-13, verbatim)
|
||||
|
||||
> the sftp/ftps idea is that the server would run a sftp/ftps server itself, and you would log in
|
||||
> via your account or a service account etc. and you could cd into apps, tables, etc. and from
|
||||
> there you can upload files, that would go into a bulk upload profile into that table, and you can
|
||||
> download that table via getting a saved view etc. same with the ability to 'get' a saved report
|
||||
> etc. allowing for complex data flows without any coding, just setting up bulk upload profiles and
|
||||
> views and reports etc.
|
||||
|
||||
## Mandatory standards that shape this design
|
||||
- **Data.** It lives in standard, visible QQQ tables (`file_gateway_*`) with full record screens,
|
||||
secured by permissions. Permissions use the full name (`file_gateway.*`).
|
||||
- **Processes.**
|
||||
- Processes are table or record actions with plain labels, never in the nav.
|
||||
- Record actions use the standard streamed-ETL Review/Result summary.
|
||||
- **Navigation.** One app home owns the data. The nav lists only the main tables; SSH keys and
|
||||
outbox files are child lists.
|
||||
- **API and search.** The API matches the admin. The main tables publish ESB table events and are
|
||||
in Quick Search.
|
||||
- **Notifications and alerts.** Notifications go through core `QAlerts.notify` (the bell) and
|
||||
alerts through `QAlerts.raise`. There is never a dependency on qbit-alerts classes.
|
||||
- **Storage** goes through qbit-object-storage (`com.kingsrook.qbits.objectstorage.api`).
|
||||
- **Display.** Pages are readable at all widths, with no double scrollbars, in light and dark
|
||||
mode. Typed widgets only, never HTML widgets (G5).
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
partner / user SFTP client
|
||||
│ ssh (publickey only), port 22 → 2222
|
||||
▼
|
||||
┌─────────── gateway Deployment (same image, FILE_GATEWAY_SFTP_ENABLED=true, stateless) ───────┐
|
||||
│ Apache MINA SSHD ──► GatewayPublickeyAuthenticator ──► file_gateway_account / _ssh_key │
|
||||
│ │ (lockout, CIDR allowlist, expiry, limits) │
|
||||
│ ▼ │
|
||||
│ SFTP subsystem ──► GatewayFileSystem (NIO provider over the VirtualTree; no disk) │
|
||||
│ │ │ list / stat / open / write / rename / remove │
|
||||
│ │ ▼ │
|
||||
│ │ GatewayUiApiClient ── UiApiInvoker(channel "sftp", invokedBy "file-gateway")│
|
||||
│ │ │ credentials = in-process session minted for the account's user │
|
||||
│ │ ▼ (loopback HTTP to this pod's own QApplicationJavalinServer) │
|
||||
│ │ /qqq/v1: metaData, table query, export, process init/step, download │
|
||||
│ ▼ │
|
||||
│ uploads ──stream──► object storage (sha256, dedupe) ──► UploadJobWorker ──► consumer │
|
||||
│ (bulk-load | edi | …) │
|
||||
│ Audit writer ──► file_gateway_connection_log / _transfer_log (system session, own tables) │
|
||||
└───────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
main web Deployment: same code, FILE_GATEWAY_SFTP_ENABLED=false (admin tables, app home, prefs)
|
||||
```
|
||||
|
||||
- **The gateway pod runs the normal application,** including its `QApplicationJavalinServer`. The
|
||||
invoker calls the pod's own `/qqq/v1` on `127.0.0.1`. The Service exposes only the SFTP port;
|
||||
HTTP serves only loopback calls and kubelet probes.
|
||||
- **Everything the user sees or does is a `/qqq/v1` call as that user.** So the following all
|
||||
apply exactly as in the UI:
|
||||
- permissions;
|
||||
- secured-data masking (enforced in core under the UI API);
|
||||
- audits (`audit.clientChannel = sftp`);
|
||||
- record locks;
|
||||
- table automations.
|
||||
- **The gateway's own bookkeeping is written server-side** under a system session, to its own
|
||||
`file_gateway_*` tables. That covers connection and transfer logs, upload jobs, lockout counters
|
||||
and outbox rows. These tables are not application data, and the SFTP user has no permission on
|
||||
them.
|
||||
- **State lives only in the database and object storage.**
|
||||
- A pod can be killed at any moment.
|
||||
- Open sessions drop; the client reconnects to another pod.
|
||||
- An upload whose stream did not complete stays `RECEIVING`, and the retention job aborts it
|
||||
after 1 hour, deleting its partial object.
|
||||
- Claimed jobs are requeued (§4).
|
||||
|
||||
## 2. The virtual filesystem
|
||||
|
||||
The tree is generated from metadata and data, and filtered by the user's permissions. Nothing
|
||||
exists on any disk.
|
||||
|
||||
```
|
||||
/ apps the user can see (GET /metaData), as folders
|
||||
/<app>/ child apps and the app's tables (by table name)
|
||||
/<app>/<table>/
|
||||
upload/ one folder per saved bulk-load profile the user can see
|
||||
<profile-slug>/ write a file here to bulk-load it with that profile
|
||||
<file>.processing sidecar while queued or running (from the job row)
|
||||
<file>.done | .error sidecar when finished (text summary, from the job row)
|
||||
views/<view-slug>.<csv|xlsx|json> saved views of this table (generated on read)
|
||||
reports/<report-slug>.<csv|xlsx|json> saved reports on this table (generated on read)
|
||||
results/<jobId>-<file>.results.csv per-row results of this account's uploads (object storage)
|
||||
archive/<yyyy-mm-dd>/<jobId>-<file> the original uploaded files (object storage, retention §8)
|
||||
/<endpoint path>/ endpoints (aliases) bound to this account, §2.3
|
||||
```
|
||||
|
||||
### 2.1 Names
|
||||
- **Apps and tables** use their QQQ `name`, which is stable and has no spaces.
|
||||
- **Saved profiles, views and reports** use a slug of their label: lowercase, `[a-z0-9-]`, at
|
||||
most 80 characters.
|
||||
- When labels collide within a folder, both get `-<id>` appended.
|
||||
- Slugs are computed per listing, so a rename shows on the next listing.
|
||||
- **Formats** are `csv`, `xlsx` and `json`, the formats of `/table/{t}/export`. Each view and
|
||||
report is listed once per format enabled for the account (`formats`; default csv and xlsx).
|
||||
|
||||
### 2.2 Visibility rules (all from the user's own UI API calls)
|
||||
| Node | Shown when |
|
||||
|---|---|
|
||||
| app | the app is in the user's `GET /metaData` app tree |
|
||||
| table | the table is in the app and in `metaData.tables` (read permission) |
|
||||
| `upload/` | the table has the bulk-insert process (`<table>.bulkInsert`) in metaData, and at least one saved bulk-load profile is visible (`POST /table/savedBulkLoadProfile/query` filtered by `tableName`) |
|
||||
| `views/` | the user can query `savedView` |
|
||||
| `reports/` | the user can query `savedReport`, has the `renderSavedReport` process, and that process's `storageTableName` input default names an existing storage-capable table |
|
||||
| `results/`, `archive/` | always (they hold only this account's jobs) |
|
||||
|
||||
- **Caching.** Metadata responses are cached in memory per SFTP session for 60 seconds. Saved-object
|
||||
lists are cached for 30 seconds.
|
||||
- **Empty folders.** A folder is listed even when empty, once its parent's rule passes, so
|
||||
partners can see where to put files.
|
||||
- **Hidden paths.** A path the user cannot see answers "no such file", never "permission denied".
|
||||
|
||||
### 2.3 Endpoints (aliases) and chroot
|
||||
A `file_gateway_alias` row gives a partner-friendly path, such as `/acme/orders-in`, that maps to
|
||||
one target:
|
||||
|
||||
| `kind` | Target | Folder behaviour |
|
||||
|---|---|---|
|
||||
| `BULK_LOAD` | table + saved bulk-load profile | an upload folder (§4) |
|
||||
| `INBOX` | `consumer_name` | an upload folder whose files go to a consumer (§9) |
|
||||
| `VIEW` | saved view + format + file name | one generated file |
|
||||
| `REPORT` | saved report + format + file name | one generated file |
|
||||
| `OUTBOX` | outbox files for this endpoint | files put by `FileGatewayOutbox` (§9); partners download and delete them |
|
||||
|
||||
- **Endpoints grant nothing.**
|
||||
- Every endpoint operation is the same `/qqq/v1` call as the user, through the full path.
|
||||
- An endpoint whose target the user cannot reach is not listed.
|
||||
- INBOX and OUTBOX endpoints need the account to be bound to them: either `account_id` matches,
|
||||
or `account_id` is null (shared).
|
||||
- **Root mode.** The account's `root_mode` sets what `/` shows:
|
||||
- `FULL_TREE` (the default for users) shows the app tree plus the endpoints.
|
||||
- `ENDPOINTS_ONLY` (the default for service accounts) shows only the account's endpoints. This
|
||||
is the chroot: `/` lists the endpoints' top-level folders.
|
||||
|
||||
### 2.4 Operations
|
||||
| SFTP operation | Allowed on | Behaviour |
|
||||
|---|---|---|
|
||||
| `ls`, `stat` | everything visible | Generated files report size 0 and the current time. Object-storage files report their stored size. |
|
||||
| `get` | views, reports, results, archive, outbox files | Generated or streamed on read (§5). |
|
||||
| `put` | `upload/<profile>/` and BULK_LOAD/INBOX endpoints | Streams to object storage, then queues a job (§4). |
|
||||
| `rename` | inside one upload folder | Supports clients that upload `x.filepart` or `x.tmp`, then rename. A job is queued on close only for a non-temporary name, or on the rename to one. |
|
||||
| `rm` | sidecars, `results/*` and outbox files | On a sidecar or result it acknowledges the job, which hides its sidecar and results. On an outbox file it marks the file removed. |
|
||||
| `rm` (other paths), `mkdir`, `rmdir`, `setstat`, symlinks | none | Denied. |
|
||||
| overwrite | none | A second put of the same name is a new job, subject to dedupe. |
|
||||
|
||||
Temporary suffixes are `.filepart`, `.tmp`, `.part` and `.partial`, configurable as
|
||||
`temporaryUploadSuffixes`.
|
||||
|
||||
## 3. Core dependencies
|
||||
|
||||
### 3.1 In-process sessions for `UiApiInvoker` (qqq-middleware-javalin)
|
||||
The invoker forwards the caller's HTTP credentials, but an SFTP user has none. The solution is an
|
||||
in-process session token; there is never a service session.
|
||||
|
||||
- **Registering.** `UiApiInProcessSessions.register(QSession session, Duration ttl)` returns a
|
||||
random 256-bit token, held only in this JVM's memory. `revoke(token)` and expiry remove it.
|
||||
- **Sending.** `UiApiCredentials.ofInProcessSession(token)` puts the token in the
|
||||
`X-QQQ-In-Process-Session` header on the loopback request.
|
||||
- **Accepting.** `ExecutorSessionUtils.setupSession` (and the legacy
|
||||
`QJavalinImplementation.setupSession`) accept the header only when both are true:
|
||||
- the request's remote address is a loopback address;
|
||||
- the token is registered and unexpired in this JVM.
|
||||
|
||||
Otherwise the request gets a 401, with no fallback to other credentials.
|
||||
- **Per-request copy.** The registered session is copied per request. The client channel is still
|
||||
set from `X-QQQ-Client`, which the invoker fixes (§6.4).
|
||||
- **Minting.**
|
||||
- The gateway builds the session with the application's authentication module:
|
||||
`createAutomatedSessionForUser(qInstance, account.userId)`. Scheduled saved reports already
|
||||
use this. Under OAuth2 it calls the customizer's `customizeAutomatedSessionForUser`.
|
||||
- A session is minted at SFTP login and re-minted every 5 minutes, so permission changes and
|
||||
revocations apply quickly.
|
||||
- It is revoked at disconnect, with a TTL of 10 minutes.
|
||||
|
||||
### 3.2 Binary responses
|
||||
- `UiApiInvoker.invokeForStream(UiApiRequest)` returns `UiApiStreamResponse`: `AutoCloseable`,
|
||||
with the status, headers and an `InputStream` (`HttpResponse.BodyHandlers.ofInputStream`).
|
||||
- It is used for `POST /table/{t}/export`, `GET /download/{file}` and `GET /reports/{name}`.
|
||||
- Bodies are never buffered whole.
|
||||
|
||||
### 3.3 Multipart uploads
|
||||
- `UiApiRequest.withMultipartFile(fieldName, fileName, contentType, Supplier<InputStream>)`, plus
|
||||
form fields.
|
||||
- It is sent as streamed `multipart/form-data`.
|
||||
- It is used for `POST /processes/{p}/init`, the bulk-load upload step
|
||||
(`ProcessSpecUtilsV1.addUploadedFiles`).
|
||||
|
||||
### Shipping
|
||||
§3.1–3.3 shipped in QRun-IO/qqq#1170 (merged 2026-10-14), first released in QQQ 4.1.0-RC.7, which
|
||||
the qbit pins.
|
||||
|
||||
### 3.4 Permissions for automated sessions (qbit-authentik)
|
||||
`AuthentikGroupPermissionMapper` builds permissions from token claims, and an automated session
|
||||
has no token.
|
||||
|
||||
- **The change.** It implements `customizeAutomatedSessionForUser`:
|
||||
- it looks the user up by id through the Authentik API backend (the cached group graph);
|
||||
- it applies the same group, ancestor, fallback and bootstrap rules;
|
||||
- if Authentik cannot be read, it grants only the static fallbacks.
|
||||
- **Service users** are Authentik users with `type=service_account`.
|
||||
- **Who needs it.** It is a qbit-authentik PR. The gateway works without it under MOCK auth and in
|
||||
tests, but kof22 needs it before going live.
|
||||
|
||||
### 3.5 Preferences
|
||||
The gateway registers `file-transfer` ("File transfer", order 60) with
|
||||
`FileTransferPreferencesProvider`, using core's user-preferences API. Only users with
|
||||
`file_gateway.connect` see it; no admin-app or backing-table permissions are needed.
|
||||
|
||||
- **Read-only connection details:** the account username, plus port and host key fingerprint from
|
||||
the most recent `file_gateway_server` heartbeat. The optional `publicHostName` config supplies the host; when
|
||||
unset, the host field is omitted. Before the first key, username is blank and the keys list
|
||||
explains that adding a key creates the account.
|
||||
- **The `keys` list:** only the caller's unrevoked USER-account keys. Label and expiry are editable
|
||||
through PATCH; fingerprint, key type and last-used time are read-only. Public-key text is never
|
||||
returned in preference values.
|
||||
- **Add key (`addKey`, LIST action):** requires OpenSSH public-key text and a label, with optional
|
||||
expiry. The first successful addition creates a USER account with FULL_TREE and configured
|
||||
account defaults. Its login is derived from the session user ID, normalized to lowercase
|
||||
`[a-z0-9._-]`, at most 64 characters; short IDs receive a `user-` prefix. Collisions receive
|
||||
`-2`, `-3`, etc. Subsequent keys reuse that account. The result names the computed fingerprint.
|
||||
- **Revoke (`revokeKey`, ROW action):** confirms revocation, sets `revoked_at`, and removes the
|
||||
key from the list. New logins fail immediately; existing sessions end on the next gateway poll.
|
||||
- **Persistence:** ordinary table actions retain `SshKeyCustomizer` validation and configured
|
||||
audits under the original user/client channel. Weak and duplicate keys return actionable 400
|
||||
messages. Failed first-key inserts roll back account creation. Mutations lock and recheck
|
||||
ownership; another user's key is not a setting here. Identity comes only from core's request
|
||||
context, never from the request body. Service-account keys remain administrator-managed.
|
||||
|
||||
## 4. Upload
|
||||
1. **Receive.** The SFTP write streams straight to object storage:
|
||||
- The key is `file-gateway/uploads/{accountId}/{yyyy}/{mm}/{dd}/{uuid}/{fileName}`, in the bucket
|
||||
alias `fileGatewayBucketAlias` (default `file-gateway`).
|
||||
- Writes go out as multipart-upload parts of 8 MiB, buffered in memory.
|
||||
- SHA-256 and the size are computed in flight. `max_file_size_mb` and the daily quota are
|
||||
enforced while writing; an overrun aborts the write with a failure status.
|
||||
- Clients write SFTP blocks at increasing offsets. Out-of-order blocks are held in a bounded
|
||||
in-memory window (64 MiB per upload); a gap larger than the window fails the write.
|
||||
- A `file_gateway_upload_job` row is created in `RECEIVING`.
|
||||
2. **Close.**
|
||||
- The multipart upload is completed.
|
||||
- The job becomes `DUPLICATE` (with `duplicate_of_job_id`) when the account has
|
||||
`dedupe_uploads` and a job with the same account, target and sha256 succeeded within
|
||||
`dedupeWindow` (7 days). Nothing runs, and the `.done` sidecar says so.
|
||||
- Otherwise the job becomes `QUEUED`.
|
||||
3. **Scan (optional).** `FileGatewayScannerInterface` (a `QCodeReference` in the config) reads the
|
||||
object as a stream and returns CLEAN, INFECTED or ERROR.
|
||||
- INFECTED: the job becomes `REJECTED`, and the object is copied to `file-gateway/quarantine/`
|
||||
and deleted from the upload key.
|
||||
- ERROR: the scan is retried.
|
||||
4. **PGP (optional, phase e).** When the account has `pgp_decrypt_uploads` and the name ends in
|
||||
`.pgp` or `.gpg`:
|
||||
- the object is decrypted stream to stream into a new object, using the key named by
|
||||
`pgpPrivateKeyEnvVarName`;
|
||||
- the plaintext's sha256 is recorded.
|
||||
5. **Run.** The worker hands the job to the endpoint's consumer (§9). For `upload/<profile>/`
|
||||
folders that is `bulk-load`, which runs the table's bulk-insert process as the user, all through
|
||||
`/qqq/v1`:
|
||||
- `POST /processes/{table}.bulkInsert/init`, multipart, streaming the object (§3.3);
|
||||
- `POST /processes/{p}/{uuid}/step/receiveFileMapping` with `savedBulkLoadProfileId`, which
|
||||
applies the saved mapping;
|
||||
- if value mapping is requested, the job **fails** with "this profile needs value mappings
|
||||
saved in it"; the gateway never guesses;
|
||||
- continue through review, execute and result, polling `/status` for async steps;
|
||||
- read the summary lines and per-row errors from the result.
|
||||
6. **Finish.**
|
||||
- The job ends `SUCCEEDED`, `PARTIAL` (some rows failed) or `FAILED`, recording counts, the
|
||||
summary and `process_uuid`.
|
||||
- A results CSV (row, field, message, plus the summary lines) is written to object storage,
|
||||
straight from memory.
|
||||
- The sidecar becomes `.done` or `.error`.
|
||||
- The original appears in `archive/`, as the same object.
|
||||
7. **Notify.** `QAlerts.notify` goes to the account's user, or to `owner_user_id` for service
|
||||
accounts:
|
||||
- always on FAILED, PARTIAL and REJECTED;
|
||||
- on SUCCEEDED only when `notify_on_success` is set.
|
||||
|
||||
The notification links to the upload-job record.
|
||||
8. **Worker.**
|
||||
- Bounded pool per gateway pod: `uploadWorkerThreads`, default 4.
|
||||
- A worker claims a job by updating QUEUED to RUNNING, setting `claimed_by` (the pod) and
|
||||
`claimed_at`.
|
||||
- A job stuck RUNNING longer than `jobStaleAfter` (30 minutes), for example after a killed pod,
|
||||
is requeued, up to 3 attempts.
|
||||
- The record action "Retry upload" requeues a FAILED or REJECTED job from its stored object.
|
||||
|
||||
## 5. Download
|
||||
Everything is streamed. Nothing is written to disk.
|
||||
|
||||
The gateway itself streams without disk. Core's `RenderSavedReportExecuteStep` stages the
|
||||
rendered report in a JVM temp file and deletes it in try-with-resources before the process step
|
||||
returns. That is the bounded temp area the owner rule allows, so the `emptyDir` temp mount must be
|
||||
sized for the largest report. The no-disk test asserts the temp area is empty after each transfer.
|
||||
|
||||
- **Saved view** (`views/<slug>.<fmt>`):
|
||||
- `POST /table/savedView/query` by table name, as the user;
|
||||
- the filter, visible columns and order are parsed from `viewJson`;
|
||||
- `POST /table/{table}/export` with `format`, `filter` and `fieldNames`, through
|
||||
`invokeForStream`.
|
||||
- **Saved report** (`reports/<slug>.<fmt>`):
|
||||
- `POST /processes/renderSavedReport/init` with `recordIds=<id>` and `reportFormat`;
|
||||
- `GET /download/{file}?storageTableName&storageReference` from the process output, streamed.
|
||||
- **Generating on open.**
|
||||
- The export starts when the client opens the file, and SFTP reads are served from the stream
|
||||
in order.
|
||||
- Clients pipeline reads at increasing offsets. A bounded in-memory read-ahead window (16 MiB per
|
||||
open file) absorbs reordering.
|
||||
- A read before the window (a backward seek) re-opens the stream for objects in storage. For
|
||||
generated files it fails, telling the client to download sequentially.
|
||||
- Every common client (OpenSSH `sftp`, WinSCP, FileZilla, lftp, MINA) reads sequentially.
|
||||
- **Limits:** the account's `max_download_mb_per_day` and `maxGeneratedFileMb` (default 1024),
|
||||
counted as bytes flow.
|
||||
- **Masking.** Secured-data masking applies because core enforces it under the UI API: export and
|
||||
report bytes carry exactly what the user may see.
|
||||
- **Results, archive and outbox** files stream from object storage with ranged reads, so seeks
|
||||
work.
|
||||
- **PGP (phase e).** When the account has `pgp_encrypt_downloads`, the stream is encrypted on the
|
||||
fly to the account's `pgp_public_key`, and `.pgp` is appended to the listed names.
|
||||
|
||||
## 6. Protocol and security
|
||||
### 6.1 SFTP server
|
||||
- **Server.** Apache MINA SSHD (`sshd-core`, `sshd-sftp`), SFTP subsystem only.
|
||||
- Shell, exec, port forwarding, agent forwarding and X11 are disabled.
|
||||
- No `VirtualFileSystemFactory` or native filesystem is ever used. The SFTP subsystem's
|
||||
filesystem is the gateway's NIO provider over the virtual tree.
|
||||
- **Auth: public key only.** `setPasswordAuthenticator(null)`; keyboard-interactive, GSSAPI and
|
||||
host-based auth are disabled.
|
||||
- **Algorithms offered:**
|
||||
- KEX: curve25519, ecdh-nistp256/384/521, and DH group16/18 with sha512;
|
||||
- ciphers: chacha20-poly1305, aes-gcm and aes-ctr;
|
||||
- MACs: hmac-sha2-256/512 (etm).
|
||||
- **Never offered:** CBC, SHA-1 MACs or KEX, or `ssh-rsa` (SHA-1) signatures.
|
||||
- **User keys:** Ed25519, ECDSA, and RSA of at least 3072 bits (rsa-sha2-256/512).
|
||||
- **Host key.**
|
||||
- The OpenSSH private key comes from the env var named by `hostKeyEnvVarName` (default
|
||||
`FILE_GATEWAY_SSH_HOST_KEY`), a Kubernetes Secret. It is parsed in memory and never written to
|
||||
disk.
|
||||
- When it is missing, startup fails. The exception is `allowGeneratedHostKey` (tests and dev
|
||||
only, in memory, with a warning).
|
||||
- The host key fingerprint is shown on the app home.
|
||||
- **Timeouts:** 15 minutes idle; auth within 30 seconds.
|
||||
|
||||
### 6.2 Accounts and authentication
|
||||
- **Login.** The login name is `file_gateway_account.username`.
|
||||
- The offered key must match, by SHA-256 fingerprint, an unrevoked and unexpired
|
||||
`file_gateway_ssh_key` of that account.
|
||||
- The account must be `ACTIVE` and unexpired.
|
||||
- The client IP must be inside `allowed_cidrs` (empty means any; service accounts should set it).
|
||||
- **Lockout.**
|
||||
- 5 failed auths within 15 minutes, per username or per IP, lock that username or IP for 30
|
||||
minutes. All three numbers are configurable.
|
||||
- A lock sets `locked_until` and raises an alert (`filegateway.lockout`, `QAlerts.raise`; core alert-type names use dots, not the table prefix).
|
||||
- The record action "Unlock account" clears it.
|
||||
- Lockout state lives in the database, so it holds across pods.
|
||||
- A per-IP limit on new connections (30 per minute) applies before auth.
|
||||
- **Per-account limits:**
|
||||
- `max_concurrent_sessions` (default 4);
|
||||
- `max_operations_per_minute` (default 600);
|
||||
- `max_file_size_mb` (default 100);
|
||||
- `max_upload_mb_per_day` and `max_download_mb_per_day` (default unlimited).
|
||||
|
||||
An exceeded limit returns an SFTP failure and logs outcome `LIMIT`.
|
||||
- **Revocation.** Every 60 seconds each pod re-reads the accounts that have live sessions. A
|
||||
disabled or expired account, or a revoked key, ends its sessions.
|
||||
|
||||
### 6.3 Who can do what
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `file_gateway.connect` | Sign in over SFTP at all. Checked at login through the minted session's permissions. |
|
||||
| `file_gateway.view` | The app, its home and read access to all `file_gateway_*` tables. |
|
||||
| `file_gateway.admin` | Insert, edit and delete on accounts, keys and endpoints; the record actions; managing other users' keys. |
|
||||
|
||||
- Table permissions use the table names (`file_gateway_account.read`, and so on) under the app
|
||||
permission.
|
||||
- Self-service goes through Preferences only (§3.5).
|
||||
|
||||
### 6.4 Client channel and walls
|
||||
- **Registration.** `ClientChannelsMetaData.ofOrWithNew(qInstance).withChannel("sftp", "SFTP file
|
||||
gateway")`.
|
||||
- **Headers.** Every invoker call sends `X-QQQ-Client: sftp/<sessionId>` and
|
||||
`X-QQQ-Invoked-By: file-gateway`.
|
||||
- **The `sftp` wall.** The channel is denied:
|
||||
- `file_gateway_*`;
|
||||
- the security surfaces of the assistant's default wall: `authentik_*`, `audit*`,
|
||||
`*api_key*`, `*apikey*`, `*credential*`, `*secret*`, `*password*`, `*permission*`, the role
|
||||
tables, `*user_session*` and `webhooks_*`.
|
||||
- **The `assistant` channel** is denied `file_gateway_*`.
|
||||
|
||||
### 6.5 Pluggable layers
|
||||
- `FileGatewayAuthenticatorInterface` turns a protocol credential into an account. v1 ships only
|
||||
the SSH-key authenticator.
|
||||
- `FileGatewayProtocolServer` (start, stop, active sessions) has one v1 implementation:
|
||||
`SftpProtocolServer`.
|
||||
- The virtual tree, UI API client, job pipeline and audit writer are protocol-neutral, so FTPS
|
||||
(§15) would add only a server and an authenticator.
|
||||
|
||||
## 7. Audit
|
||||
- **Connections.** Every sign-in attempt writes `file_gateway_connection_log`. The row is opened
|
||||
at the first authentication attempt (or a pre-auth refusal such as the connection rate limit)
|
||||
and updated at auth and at disconnect. It records the outcome, IP, key fingerprint, SSH client
|
||||
version, byte and operation counts, and channel `sftp`. A TCP connection that never tries to
|
||||
sign in, such as a kubelet health probe or a port scan, leaves no row.
|
||||
- **Operations.** Every list, upload, download, rename and remove writes
|
||||
`file_gateway_transfer_log`. It records the operation, path, object kind and id, bytes, sha256,
|
||||
duration, outcome and message, with channel `sftp/<sessionId>`.
|
||||
- A directory read (`opendir`) is logged once per read; `stat` is not logged.
|
||||
- Denied attempts are logged with outcome `DENIED`.
|
||||
- **Data changes.** Changes made by bulk loads carry `audit.clientChannel = sftp/<sessionId>` from
|
||||
core. Secured-data views are logged in that qbit's own access log.
|
||||
|
||||
## 8. Tables
|
||||
All tables are RDBMS tables in the app's backend, lowercase snake_case, with backend names equal to
|
||||
the table names.
|
||||
|
||||
| Table | Purpose | Key fields |
|
||||
|---|---|---|
|
||||
| `file_gateway_account` (main) | an SFTP login | `username` (unique, `[a-z0-9._-]{3,64}`), `label`, `account_type` (USER, SERVICE), `user_id` (the QQQ user the session acts as), `owner_user_id`, `status` (ACTIVE, DISABLED, LOCKED), `root_mode` (FULL_TREE, ENDPOINTS_ONLY), `allowed_cidrs`, `expires_at`, `formats`, the limits (§6.2), `dedupe_uploads`, `notify_on_success`, `pgp_public_key`, `pgp_decrypt_uploads`, `pgp_encrypt_downloads`, `failed_login_count`, `locked_until`, `last_login_at`, `last_login_ip` |
|
||||
| `file_gateway_ssh_key` (child of account) | public keys | `account_id`, `label`, `public_key` (OpenSSH text), `fingerprint` (unique `SHA256:...`), `key_type`, `key_bits`, `expires_at`, `revoked_at`, `last_used_at`, `last_used_ip` |
|
||||
| `file_gateway_alias` (main, "Endpoints") | partner paths | `name` (unique slug), `path` (unique), `kind` (BULK_LOAD, INBOX, VIEW, REPORT, OUTBOX), `account_id` (null = shared), `table_name`, `saved_bulk_load_profile_id`, `saved_view_id`, `saved_report_id`, `format`, `file_name`, `consumer_name`, `is_active`, `description` |
|
||||
| `file_gateway_upload_job` (main) | one uploaded file | `account_id`, `alias_id`, `connection_log_id`, `remote_address`, `table_name`, `saved_bulk_load_profile_id`, `consumer_name`, `consumer_reference`, `path`, `file_name`, `size_bytes`, `sha256`, `storage_key`, `status` (RECEIVING, QUEUED, RUNNING, SUCCEEDED, PARTIAL, FAILED, REJECTED, DUPLICATE, ABORTED), `duplicate_of_job_id`, `process_uuid`, `inserted_count`, `error_count`, `result_summary`, `results_storage_key`, `error_message`, `attempts`, `next_attempt_at`, `claimed_by`, `claimed_at`, `queued_at`, `started_at`, `finished_at`, `acknowledged_at` |
|
||||
| `file_gateway_outbox_file` (child of endpoint) | files waiting for pickup | `alias_id`, `file_name`, `size_bytes`, `sha256`, `storage_key`, `status` (READY, DOWNLOADED, REMOVED, EXPIRED), `first_downloaded_at`, `download_count`, `removed_at`, `expires_at`, `created_by_consumer` |
|
||||
| `file_gateway_transfer_log` (main, "Transfers") | every operation | `account_id`, `connection_log_id`, `operation` (LIST, UPLOAD, DOWNLOAD, RENAME, REMOVE), `path`, `object_kind` (APP, TABLE, PROFILE, VIEW, REPORT, RESULT, ARCHIVE, ENDPOINT, OUTBOX), `object_id`, `format`, `bytes`, `sha256`, `duration_ms`, `outcome` (OK, DENIED, NOT_FOUND, LIMIT, ERROR), `message`, `client_channel`, `upload_job_id` |
|
||||
| `file_gateway_connection_log` (main, "Connections") | every connection | `account_id`, `username`, `remote_ip`, `client_version`, `key_fingerprint`, `outcome` (CONNECTED, AUTH_FAILED, LOCKED_OUT, IP_DENIED, EXPIRED, DISABLED, LIMIT), `session_id`, `pod_name`, `connected_at`, `last_seen_at`, `disconnected_at`, `disconnect_reason`, `bytes_in`, `bytes_out`, `operation_count`, `client_channel` |
|
||||
| `file_gateway_server` (system, not in nav) | gateway pod heartbeats for the app home | `pod_name` (unique), `host_key_fingerprint`, `port`, `started_at`, `last_seen_at`; rows unseen for a day are deleted by retention |
|
||||
|
||||
- **Child lists:**
|
||||
- an account shows its keys, upload jobs and connections;
|
||||
- an endpoint shows its upload jobs and outbox files;
|
||||
- an upload job shows its transfers.
|
||||
- **Quick search and events.** Accounts (username, label), endpoints (name, path) and upload jobs
|
||||
(file name) are in Quick Search and publish ESB table events. All tables are in the QQQ API.
|
||||
- **Retention.** A daily scheduled job, not in the nav, applies these defaults:
|
||||
- transfer and connection logs: 180 days;
|
||||
- archive objects: 30 days;
|
||||
- outbox files: 30 days;
|
||||
- results: 90 days;
|
||||
- `RECEIVING` jobs older than 1 hour become `ABORTED`, and their objects are deleted.
|
||||
- **Active sessions** are connection rows with no `disconnected_at` and a `last_seen_at` within the
|
||||
last 3 minutes. Pods update `last_seen_at` every minute.
|
||||
- **Record actions** (standard streamed ETL, Review then Result):
|
||||
- "Unlock account";
|
||||
- "Disable account";
|
||||
- "Revoke key";
|
||||
- "Retry upload" (FAILED, REJECTED);
|
||||
- "End session" (on active connections; the owning pod closes it within 60 seconds).
|
||||
- **Key validation on insert.** The key is parsed and checked against the §6.1 minimums. The
|
||||
fingerprint, type and bits are computed server-side. Keys are classified CONFIDENTIAL.
|
||||
|
||||
### App home (`file_gateway`)
|
||||
Typed widgets only, with real data:
|
||||
- **KPI row:** transfers today, failed jobs in the last 24 hours, active sessions, MB in and out
|
||||
today, and locked accounts.
|
||||
- **Recent failures:** a table of upload jobs that were FAILED, PARTIAL or REJECTED in the last 7
|
||||
days, linked to their records.
|
||||
- **Top partners (7 days):** a table of accounts by transfers and MB.
|
||||
- **Active sessions:** a table of username, IP, connected since, operations and pod.
|
||||
- **Server:** the host key fingerprint, the port, and the gateway pods seen in the last 3 minutes.
|
||||
|
||||
## 9. Consumer and outbox API (`com.kingsrook.qbits.filegateway.api`)
|
||||
- The package is published in the `qbit-file-gateway` artifact and allow-listed under G1 for other
|
||||
qbits, like object-storage's `api`.
|
||||
- It holds interfaces and records only, with no runtime and no tables.
|
||||
- Consumers (qbit-edi) depend on it with `<optional>true</optional>`.
|
||||
- The shape was agreed with the qbit-edi agent on 2026-10-13.
|
||||
|
||||
```java
|
||||
public interface FileGatewayConsumer
|
||||
{
|
||||
String getName(); // lowercase [a-z0-9-]+, e.g. "edi"
|
||||
FileAcceptance accept(InboundFile file) throws Exception;
|
||||
default void onDownloaded(OutboxFileRef ref) throws Exception { }
|
||||
}
|
||||
|
||||
public record InboundFile(Integer uploadJobId, String endpointName, String username, String userId,
|
||||
String remoteAddress, String path, String fileName, long sizeBytes, String sha256,
|
||||
Instant receivedAt, String storageBucketAlias, String storageKey,
|
||||
Supplier<InputStream> streamSupplier)
|
||||
{
|
||||
public InputStream openStream() { return (streamSupplier.get()); } // fresh stream from object storage each call
|
||||
}
|
||||
|
||||
public final class FileAcceptance // ACCEPTED | REJECTED | RETRY_LATER
|
||||
{
|
||||
public static FileAcceptance accepted(String consumerReference);
|
||||
public static FileAcceptance rejected(String reason);
|
||||
public static FileAcceptance retryLater(String reason, Duration retryAfter);
|
||||
}
|
||||
|
||||
public record OutboxFileRef(Integer outboxFileId, String endpointName, String path, String fileName,
|
||||
long sizeBytes, String sha256, Instant createdAt) { }
|
||||
|
||||
public final class FileGatewayOutbox
|
||||
{
|
||||
public static boolean isAvailable();
|
||||
public static OutboxFileRef put(String endpointName, String fileName, InputStream content, long sizeBytes)
|
||||
throws FileGatewayUnavailableException, QException;
|
||||
}
|
||||
|
||||
FileGatewayConsumersMetaData.ofOrWithNew(qInstance).withConsumer(new QCodeReference(EdiFileGatewayConsumer.class));
|
||||
```
|
||||
|
||||
- **Binding is by data.**
|
||||
- An INBOX endpoint row names its `consumer_name`.
|
||||
- `upload/<profile>/` folders and BULK_LOAD endpoints use the built-in `bulk-load` consumer.
|
||||
- **`accept` delivery.**
|
||||
- It runs after close, the scan and PGP, at-least-once, on a gateway worker.
|
||||
- QContext holds the account user's session, with channel `sftp`.
|
||||
- Consumers keep `accept` short: store the file and return.
|
||||
- RETRY_LATER, or an exception, retries after 1m, 5m, 15m, 1h and 6h. After 5 attempts the job is
|
||||
FAILED and the user is notified.
|
||||
- An unknown consumer gives REJECTED.
|
||||
- Gateway dedupe (§4) runs first; consumers dedupe on (uploadJobId, sha256).
|
||||
- **Outbox.**
|
||||
- `put` streams the content to object storage, adds a `file_gateway_outbox_file` row, and the
|
||||
file lists in the OUTBOX endpoint's folder.
|
||||
- The first complete download calls `onDownloaded` on the endpoint's `consumer_name`, if one is
|
||||
set, at-least-once.
|
||||
- When the partner removes the file, it is marked REMOVED.
|
||||
- `put` to a missing or inactive endpoint throws `QException`.
|
||||
- The facade dispatches to the implementation that the producer registers. Without the gateway
|
||||
installed, it throws `FileGatewayUnavailableException`.
|
||||
|
||||
## 10. Configuration (`FileGatewayQBitConfig`)
|
||||
- **SFTP server:**
|
||||
- `sftpEnabled` (default false; true only in the gateway deployment, from
|
||||
`FILE_GATEWAY_SFTP_ENABLED`);
|
||||
- `sftpPort` (2222);
|
||||
- `publicHostName` (null; optional public hostname shown in Preferences);
|
||||
- `hostKeyEnvVarName` (`FILE_GATEWAY_SSH_HOST_KEY`);
|
||||
- `allowGeneratedHostKey` (false).
|
||||
- **Storage and buffers:**
|
||||
- `fileGatewayBucketAlias` (`file-gateway`);
|
||||
- `uploadPartSizeMb` (8);
|
||||
- `uploadReorderWindowMb` (64);
|
||||
- `downloadReadAheadMb` (16);
|
||||
- `maxGeneratedFileMb` (1024).
|
||||
- **Upload jobs:**
|
||||
- `uploadWorkerThreads` (4);
|
||||
- `dedupeWindow` (7 days);
|
||||
- `jobStaleAfter` (30 minutes);
|
||||
- `temporaryUploadSuffixes`.
|
||||
- **Lockout and limits.** The lockout and per-IP limits (§6.2), plus the defaults for new
|
||||
accounts.
|
||||
- **Optional:**
|
||||
- `scanner` (a `QCodeReference`);
|
||||
- `pgpPrivateKeyEnvVarName` and `pgpPassphraseEnvVarName`;
|
||||
- retention (§8);
|
||||
- `tableMetaDataCustomizer`.
|
||||
|
||||
## 11. Kubernetes
|
||||
The gateway is a separate, stateless Deployment of the same image. An example is in `docs/k8s/`:
|
||||
`gateway-deployment.yaml` and `gateway-service.yaml`.
|
||||
|
||||
- **Deployment.**
|
||||
- `FILE_GATEWAY_SFTP_ENABLED=true`, plus the app's usual environment.
|
||||
- `FILE_GATEWAY_SSH_HOST_KEY` from a Secret; in kof22, through ESO from Infisical.
|
||||
- 2 replicas, with a PodDisruptionBudget of `minAvailable: 1`.
|
||||
- `readOnlyRootFilesystem: true`. The only writable mount is a small in-memory `emptyDir`
|
||||
(`medium: Memory`, `sizeLimit: 512Mi`, counted in the memory limit) for the JVM temp directory, sized for the largest saved report core stages there. Nothing is persisted.
|
||||
- Requests of 250m CPU and 768Mi memory; a 2Gi memory limit. The in-memory buffers are bounded
|
||||
by the config.
|
||||
- **Ports and probes.**
|
||||
- Container ports: 2222 (sftp) and the app's HTTP port, which serves loopback and probes only.
|
||||
- Readiness: `tcpSocket: 2222` and the app's HTTP health endpoint.
|
||||
- Liveness: HTTP health.
|
||||
- Startup: allows 2 minutes for the QInstance.
|
||||
- **Service.** `type: LoadBalancer`, with these settings:
|
||||
- the annotation `lbipam.cilium.io/ips` (or the older `io.cilium/lb-ipam-ips`, which k8s-galaxy uses): 10.100.22.65 in kof22 (owner, 2026-10-13; .60, .61 and .64 are taken);
|
||||
- **port 22 → 2222 only (no passive range in v1)**;
|
||||
- `externalTrafficPolicy: Local`, so the pod sees the real client IP for allowlists and
|
||||
lockouts.
|
||||
- **Shutdown.** On SIGTERM a pod stops accepting connections and lets sessions drain for up to 60
|
||||
seconds. Then it marks its own connection rows ended and requeues its claimed jobs.
|
||||
- **UniFi.** The WAN 22 (or 2222) port forward to the LB IP is an owner step, in Praetor
|
||||
`unifi/` tofu.
|
||||
|
||||
## 12. Tests (acceptance criteria)
|
||||
All tests run in the qbit's build.
|
||||
|
||||
- **Harness:**
|
||||
- an embedded `QApplicationJavalinServer` with `withStrictUiApiRoutes(true)` (G2) on a random
|
||||
port;
|
||||
- an H2 RDBMS backend and a memory object-storage bucket;
|
||||
- the embedded SFTP server on a random port, with an in-memory generated host key;
|
||||
- the MINA SSHD client (`SftpClient`), with real keys generated per test.
|
||||
- **Listing per permission.**
|
||||
- Two users with different permissions see different apps, tables, and `upload/`, `views/` and
|
||||
`reports/` folders.
|
||||
- A hidden table answers "no such file".
|
||||
- An `ENDPOINTS_ONLY` account sees only its endpoints.
|
||||
- **Upload to bulk load, end to end.**
|
||||
- Putting a CSV into `upload/<profile>/` creates a job, runs the saved profile as the user and
|
||||
inserts the rows.
|
||||
- It writes `.done` and the results file, and archives the original.
|
||||
- A bad row gives `PARTIAL` with per-row errors.
|
||||
- The same file again gives `DUPLICATE`.
|
||||
- A `.filepart` upload followed by a rename queues once.
|
||||
- `QAlerts.notify` is called.
|
||||
- **Downloading a view with masking.**
|
||||
- `views/<slug>.csv` returns the view's filter and columns.
|
||||
- A field the user may not read comes back masked or absent, exactly as the `/qqq/v1` export
|
||||
returns it.
|
||||
- **Getting a report.** `reports/<slug>.csv` runs `renderSavedReport` as the user and streams the
|
||||
result.
|
||||
- **Auth failures.**
|
||||
- These are refused, each with a connection-log outcome: an unknown key, a revoked key, an
|
||||
expired key, an expired or disabled account, and an IP outside the allowlist.
|
||||
- Password and keyboard-interactive are never offered.
|
||||
- **Lockout.** 5 bad attempts lock the account and raise the alert. "Unlock account" clears the
|
||||
lock.
|
||||
- **Audit.**
|
||||
- Connect, list, upload and download each write their rows, with channel `sftp/...`.
|
||||
- Core audits of inserted rows carry `sftp`.
|
||||
- **Limits.** The file-size cap aborts an upload. The session cap refuses the fifth session.
|
||||
- **No disk (owner rule).** Every transfer test runs under a filesystem guard:
|
||||
- `java.io.tmpdir` points at a fresh per-test directory;
|
||||
- a snapshot check lists the working directory, `user.home` and the temp
|
||||
directory before and after each test;
|
||||
- the test asserts that nothing was created outside the temp area, and that the temp area is
|
||||
empty after each transfer completes.
|
||||
- **Consumer API.**
|
||||
- A test consumer bound to an INBOX endpoint receives an `InboundFile` with the correct sha256,
|
||||
`remoteAddress` and stream.
|
||||
- RETRY_LATER retries.
|
||||
- An outbox `put` lists in the OUTBOX folder, and the download calls `onDownloaded`.
|
||||
- **Stateless restart.** Stop the server mid-session and start a new one on the same database and
|
||||
bucket. Jobs requeue, lockouts persist, and the client reconnects and sees the same tree.
|
||||
- **Contract.** The invoker is only ever called with spec'd operations, asserted through
|
||||
`UiApiInvoker.findOperation`. The qbit registers no `/qqq/v1` extension, so G3 does not apply.
|
||||
- **Screenshots.** The app home and the account, upload-job and endpoint records, in light and
|
||||
dark, at 390 and 1440 px.
|
||||
|
||||
## 13. Guardrails (ARCHITECTURE-REVIEW-2026-10-13)
|
||||
- **G1.** The only qbit dependency is `qbit-object-storage` (its `api` package). There is no
|
||||
`Class.forName` of another qbit. The gateway's own `api` package is the published contract.
|
||||
- **G2.** Server tests run in strict mode.
|
||||
- **G3.** No extension is registered: the gateway is a UI API client.
|
||||
- **G4.** No stand-ins remain: the core baseline is a release, and the preferences section uses
|
||||
core's API from QRun-IO/qqq#1162.
|
||||
- **G5.** No HTML widgets.
|
||||
- **G7.** Pinned to QQQ 4.1.0-RC.7; never a SNAPSHOT.
|
||||
|
||||
## 14. Phases
|
||||
- **(a)** The SFTP server, the virtual filesystem, and auth:
|
||||
- keys, accounts, lockout, CIDR, expiry and limits;
|
||||
- listing;
|
||||
- connection and transfer logs;
|
||||
- the tables;
|
||||
- the `api` package (types only);
|
||||
- the core PR (§3.1–3.3).
|
||||
- **(b)** Upload to bulk load:
|
||||
- streaming to object storage, sha256 and dedupe;
|
||||
- the job worker, sidecars, results and archive;
|
||||
- notifications;
|
||||
- the consumer pipeline and the scan hook.
|
||||
- **(c)** Downloads of saved views and saved reports, with read-ahead and download limits.
|
||||
- **(d)** *Removed* (FTPS; see §15).
|
||||
- **(e)** The rest, then the drafts:
|
||||
- endpoints (aliases) and chroot;
|
||||
- the outbox;
|
||||
- PGP;
|
||||
- the Preferences section;
|
||||
- the app home;
|
||||
- retention;
|
||||
- then the kof22 adoption (draft) and the cluster-gitops draft.
|
||||
|
||||
## 15. FTPS (future, not in v1)
|
||||
FTPS is added only if a partner needs it. It would be a second `FileGatewayProtocolServer`, using
|
||||
Apache FtpServer, over the same virtual tree, UI API client, jobs and audit:
|
||||
- **TLS.** Explicit (AUTH TLS, port 21) and/or implicit (990), TLS 1.2 or later, and never plain
|
||||
FTP.
|
||||
- **Authentication.** Either or both of:
|
||||
- revocable per-account **app passwords**: hashed, shown once, with an expiry;
|
||||
- **client certificates**: the certificate's fingerprint pinned on the account.
|
||||
- **Networking.** A **passive port range** (for example 30000–30049) on the LoadBalancer, with
|
||||
`externalTrafficPolicy: Local` and the passive address set to the LB IP.
|
||||
- **Tests.** Protocol tests with the Commons Net `FTPSClient`.
|
||||
|
||||
## 16. Owner steps
|
||||
- The LoadBalancer IP is 10.100.22.65 (decided 2026-10-13); reserve it in the cluster-gitops PR.
|
||||
- Create the host key secret (`FILE_GATEWAY_SSH_HOST_KEY`) in Infisical.
|
||||
- Add the UniFi port forward.
|
||||
- Grant `file_gateway.connect` to the roles that may use SFTP, and `file_gateway.admin` to admins.
|
||||
- Merge the qbit-authentik automated-session PR (§3.4) before go-live.
|
||||
@@ -0,0 +1,191 @@
|
||||
-- File Gateway schema. PostgreSQL and H2. Timestamps are UTC, matching QQQ RDBMS binding.
|
||||
|
||||
CREATE TABLE file_gateway_account (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
username VARCHAR(1024) NOT NULL UNIQUE,
|
||||
label VARCHAR(1024),
|
||||
account_type VARCHAR(1024),
|
||||
user_id VARCHAR(1024),
|
||||
owner_user_id VARCHAR(1024),
|
||||
status VARCHAR(1024),
|
||||
root_mode VARCHAR(1024),
|
||||
allowed_cidrs TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
formats VARCHAR(1024),
|
||||
max_concurrent_sessions INTEGER,
|
||||
max_operations_per_minute INTEGER,
|
||||
max_file_size_mb INTEGER,
|
||||
max_upload_mb_per_day INTEGER,
|
||||
max_download_mb_per_day INTEGER,
|
||||
dedupe_uploads BOOLEAN,
|
||||
notify_on_success BOOLEAN,
|
||||
pgp_public_key TEXT,
|
||||
pgp_decrypt_uploads BOOLEAN,
|
||||
pgp_encrypt_downloads BOOLEAN,
|
||||
operation_window_at TIMESTAMP,
|
||||
operation_window_count INTEGER,
|
||||
failed_login_count INTEGER,
|
||||
failed_login_window_at TIMESTAMP,
|
||||
locked_until TIMESTAMP,
|
||||
last_login_at TIMESTAMP,
|
||||
last_login_ip VARCHAR(1024)
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_ssh_key (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
account_id INTEGER,
|
||||
label VARCHAR(1024),
|
||||
public_key TEXT,
|
||||
fingerprint VARCHAR(1024) NOT NULL UNIQUE,
|
||||
key_type VARCHAR(1024),
|
||||
key_bits INTEGER,
|
||||
expires_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
last_used_ip VARCHAR(1024)
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_alias (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
name VARCHAR(1024) NOT NULL UNIQUE,
|
||||
path VARCHAR(1024) NOT NULL UNIQUE,
|
||||
kind VARCHAR(1024),
|
||||
account_id INTEGER,
|
||||
table_name VARCHAR(1024),
|
||||
saved_bulk_load_profile_id INTEGER,
|
||||
saved_view_id INTEGER,
|
||||
saved_report_id INTEGER,
|
||||
format VARCHAR(1024),
|
||||
file_name VARCHAR(1024),
|
||||
consumer_name VARCHAR(1024),
|
||||
is_active BOOLEAN,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_upload_job (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
account_id INTEGER,
|
||||
alias_id INTEGER,
|
||||
connection_log_id INTEGER,
|
||||
remote_address VARCHAR(1024),
|
||||
table_name VARCHAR(1024),
|
||||
saved_bulk_load_profile_id INTEGER,
|
||||
consumer_name VARCHAR(1024),
|
||||
consumer_reference VARCHAR(1024),
|
||||
path VARCHAR(1024),
|
||||
file_name VARCHAR(1024),
|
||||
size_bytes BIGINT,
|
||||
sha256 VARCHAR(1024),
|
||||
storage_key VARCHAR(1024),
|
||||
status VARCHAR(1024),
|
||||
duplicate_of_job_id INTEGER,
|
||||
process_uuid VARCHAR(1024),
|
||||
inserted_count INTEGER,
|
||||
error_count INTEGER,
|
||||
result_summary TEXT,
|
||||
results_storage_key VARCHAR(1024),
|
||||
results_size_bytes BIGINT,
|
||||
plaintext_storage_key VARCHAR(1024),
|
||||
plaintext_sha256 VARCHAR(1024),
|
||||
plaintext_size_bytes BIGINT,
|
||||
error_message TEXT,
|
||||
attempts INTEGER,
|
||||
next_attempt_at TIMESTAMP,
|
||||
claimed_by VARCHAR(1024),
|
||||
claimed_at TIMESTAMP,
|
||||
queued_at TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
acknowledged_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_outbox_file (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
alias_id INTEGER,
|
||||
file_name VARCHAR(1024),
|
||||
size_bytes BIGINT,
|
||||
sha256 VARCHAR(1024),
|
||||
storage_key VARCHAR(1024),
|
||||
status VARCHAR(1024),
|
||||
first_downloaded_at TIMESTAMP,
|
||||
download_count INTEGER,
|
||||
on_downloaded_pending BOOLEAN DEFAULT FALSE,
|
||||
removed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
created_by_consumer VARCHAR(1024)
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_transfer_log (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
account_id INTEGER,
|
||||
connection_log_id INTEGER,
|
||||
operation VARCHAR(1024),
|
||||
path VARCHAR(1024),
|
||||
object_kind VARCHAR(1024),
|
||||
object_id VARCHAR(1024),
|
||||
format VARCHAR(1024),
|
||||
bytes BIGINT,
|
||||
sha256 VARCHAR(1024),
|
||||
duration_ms BIGINT,
|
||||
outcome VARCHAR(1024),
|
||||
message TEXT,
|
||||
client_channel VARCHAR(1024),
|
||||
upload_job_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_connection_log (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
account_id INTEGER,
|
||||
username VARCHAR(1024),
|
||||
remote_ip VARCHAR(1024),
|
||||
client_version VARCHAR(1024),
|
||||
key_fingerprint VARCHAR(1024),
|
||||
outcome VARCHAR(1024),
|
||||
session_id VARCHAR(1024),
|
||||
pod_name VARCHAR(1024),
|
||||
connected_at TIMESTAMP,
|
||||
last_seen_at TIMESTAMP,
|
||||
disconnected_at TIMESTAMP,
|
||||
end_requested_at TIMESTAMP,
|
||||
disconnect_reason TEXT,
|
||||
bytes_in BIGINT,
|
||||
bytes_out BIGINT,
|
||||
operation_count BIGINT,
|
||||
client_channel VARCHAR(1024)
|
||||
);
|
||||
|
||||
CREATE TABLE file_gateway_server (
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
create_date TIMESTAMP,
|
||||
modify_date TIMESTAMP,
|
||||
pod_name VARCHAR(1024) NOT NULL UNIQUE,
|
||||
host_key_fingerprint VARCHAR(1024),
|
||||
port INTEGER,
|
||||
started_at TIMESTAMP,
|
||||
last_seen_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for the job queue, dedupe, live sessions, home queries and retention.
|
||||
CREATE INDEX file_gateway_transfer_log_account_date ON file_gateway_transfer_log (account_id, create_date);
|
||||
CREATE INDEX file_gateway_transfer_log_date ON file_gateway_transfer_log (create_date);
|
||||
CREATE INDEX file_gateway_connection_log_account_live ON file_gateway_connection_log (account_id, disconnected_at);
|
||||
CREATE INDEX file_gateway_connection_log_date ON file_gateway_connection_log (create_date);
|
||||
CREATE INDEX file_gateway_upload_job_status_next ON file_gateway_upload_job (status, next_attempt_at);
|
||||
CREATE INDEX file_gateway_upload_job_dedupe ON file_gateway_upload_job (account_id, sha256);
|
||||
CREATE INDEX file_gateway_upload_job_date ON file_gateway_upload_job (create_date);
|
||||
CREATE INDEX file_gateway_outbox_file_alias_status ON file_gateway_outbox_file (alias_id, status);
|
||||
CREATE INDEX file_gateway_ssh_key_account ON file_gateway_ssh_key (account_id);
|
||||
@@ -0,0 +1,91 @@
|
||||
# qbit-file-gateway: the SFTP gateway Deployment (docs/DESIGN.md section 11).
|
||||
# Same image and QInstance as the application's web Deployment; only FILE_GATEWAY_SFTP_ENABLED differs.
|
||||
# Stateless: no volumes except an in-memory JVM temp area. State lives in the database and object storage.
|
||||
# Replace every <PLACEHOLDER> and copy the application's usual environment (database, object storage, auth).
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: <APP>-file-gateway
|
||||
labels:
|
||||
app.kubernetes.io/name: <APP>-file-gateway
|
||||
app.kubernetes.io/component: sftp-gateway
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: <APP>-file-gateway
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: <APP>-file-gateway
|
||||
app.kubernetes.io/component: sftp-gateway
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 75 # SIGTERM: stop accepting, drain sessions up to 60 s, requeue claimed jobs
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: app
|
||||
image: <APP_IMAGE> # the same image as the web Deployment
|
||||
env:
|
||||
- name: FILE_GATEWAY_SFTP_ENABLED
|
||||
value: "true"
|
||||
- name: FILE_GATEWAY_SSH_HOST_KEY # OpenSSH private key text; parsed in memory, never written to disk
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: file-gateway-host-key
|
||||
key: ssh_host_key
|
||||
- name: JAVA_TOOL_OPTIONS
|
||||
value: "-Djava.io.tmpdir=/tmp"
|
||||
# ... the application's usual environment (database, object storage, authentication) ...
|
||||
ports:
|
||||
- name: sftp
|
||||
containerPort: 2222
|
||||
- name: http # loopback /qqq/v1 calls and kubelet probes only; not in the Service
|
||||
containerPort: 8000
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: sftp
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: <APP_HEALTH_PATH>
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: <APP_HEALTH_PATH>
|
||||
port: http
|
||||
periodSeconds: 5
|
||||
failureThreshold: 24 # up to 2 minutes for the QInstance
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 768Mi
|
||||
limits:
|
||||
memory: 2Gi # includes the in-memory /tmp below and the bounded transfer buffers
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp # JVM temp only (core stages a rendered report here and deletes it)
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 512Mi
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: <APP>-file-gateway
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: <APP>-file-gateway
|
||||
@@ -0,0 +1,20 @@
|
||||
# qbit-file-gateway: the SFTP LoadBalancer Service (docs/DESIGN.md section 11).
|
||||
# One port only: v1 is SFTP only, so there is no FTPS passive port range (see DESIGN.md section 15).
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: <APP>-file-gateway
|
||||
annotations:
|
||||
# Cilium LB-IPAM: a fixed address (kof22 on k8s-galaxy: 10.100.22.65, owner decision 2026-10-13).
|
||||
# Cilium 1.15+ reads lbipam.cilium.io/ips; older clusters (and k8s-galaxy manifests) use io.cilium/lb-ipam-ips.
|
||||
lbipam.cilium.io/ips: "10.100.22.65"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local # keep the client IP: IP allowlists and lockouts need it
|
||||
selector:
|
||||
app.kubernetes.io/name: <APP>-file-gateway
|
||||
ports:
|
||||
- name: sftp
|
||||
port: 22
|
||||
targetPort: sftp
|
||||
protocol: TCP
|
||||
@@ -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,37 +1,135 @@
|
||||
<?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>
|
||||
<!--
|
||||
~ QQQ - Low-code Application Framework for Engineers.
|
||||
~ Copyright (C) 2021-2026. 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.
|
||||
-->
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-example-app</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Example Application</name>
|
||||
<description>Template for Application QBits - complete mini-applications with UI navigation</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>
|
||||
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
|
||||
<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>
|
||||
<h2.version>2.2.224</h2.version>
|
||||
<sshd.version>2.16.0</sshd.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-file-gateway</artifactId>
|
||||
<version>${revision}</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit File Gateway</name>
|
||||
<description>QBit that serves QQQ applications as an SFTP folder tree: upload files into saved bulk-load profiles, download saved views and saved reports, with per-user SSH keys and a full transfer audit</description>
|
||||
<url>https://github.com/QRun-IO/qbit-file-gateway</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-file-gateway.git</connection>
|
||||
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-file-gateway.git</developerConnection>
|
||||
<url>https://github.com/QRun-IO/qbit-file-gateway</url>
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<!-- QQQ Backend Core -->
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpg-jdk18on</artifactId>
|
||||
<version>1.84</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-object-storage</artifactId>
|
||||
<version>0.1.0-RC.10</version>
|
||||
</dependency>
|
||||
<!-- MINA's Ed25519 key decoder and signer. -->
|
||||
<dependency>
|
||||
<groupId>net.i2p.crypto</groupId>
|
||||
<artifactId>eddsa</artifactId>
|
||||
<version>0.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.sshd</groupId>
|
||||
<artifactId>sshd-core</artifactId>
|
||||
<version>${sshd.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.sshd</groupId>
|
||||
<artifactId>sshd-sftp</artifactId>
|
||||
<version>${sshd.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Testing -->
|
||||
<!-- The memory backend is included in qqq-backend-core. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.38</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>${h2.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -40,51 +138,121 @@
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.2.224</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<java.io.tmpdir>${project.build.directory}/fg-tmp</java.io.tmpdir>
|
||||
<user.home>${project.build.directory}/fg-home</user.home>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</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.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.10</version>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<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>
|
||||
|
||||
<profiles>
|
||||
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
|
||||
<profile>
|
||||
<id>qqq-snapshot</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<!-- QQQ 4.1.0-RC.7: the first released core with the UiApiInvoker in-process sessions, streams and multipart (QRun-IO/qqq#1170). Never a SNAPSHOT (guardrail G7). -->
|
||||
<qqq.snapshot.version>4.1.0-RC.26</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>
|
||||
@@ -1,164 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Configuration class for the Example Application QBit.
|
||||
**
|
||||
** Application QBits support:
|
||||
** - Backend configuration for data storage
|
||||
** - Optional table prefixing for multi-instance deployment
|
||||
** - Feature toggles for optional modules
|
||||
** - Security key prefix for permission scoping
|
||||
** - Integration hooks for other QBits
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
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.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
public class ExampleAppQBitConfig implements QBitConfig
|
||||
{
|
||||
private String backendName;
|
||||
private String tableNamePrefix;
|
||||
private Boolean enableChildModule = true;
|
||||
private String securityKeyPrefix;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Integration hooks - allow host app to inject custom behavior //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
private QCodeReference onEntityCreatedHandler;
|
||||
private QCodeReference customValidationProvider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the configuration before the QBit is produced.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
if(!StringUtils.hasContent(backendName))
|
||||
{
|
||||
errors.add("backendName is required for ExampleAppQBit");
|
||||
}
|
||||
|
||||
if(qInstance.getBackend(backendName) == null)
|
||||
{
|
||||
errors.add("Backend not found: " + backendName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply prefix to a table name if configured.
|
||||
*******************************************************************************/
|
||||
public String applyPrefix(String tableName)
|
||||
{
|
||||
if(StringUtils.hasContent(tableNamePrefix))
|
||||
{
|
||||
return tableNamePrefix + "_" + tableName;
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the security key for permission checks.
|
||||
*******************************************************************************/
|
||||
public String getSecurityKey(String baseName)
|
||||
{
|
||||
if(StringUtils.hasContent(securityKeyPrefix))
|
||||
{
|
||||
return securityKeyPrefix + "." + baseName;
|
||||
}
|
||||
return baseName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Getters and fluent setters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public String getBackendName()
|
||||
{
|
||||
return backendName;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public String getTableNamePrefix()
|
||||
{
|
||||
return tableNamePrefix;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withTableNamePrefix(String tableNamePrefix)
|
||||
{
|
||||
this.tableNamePrefix = tableNamePrefix;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public Boolean getEnableChildModule()
|
||||
{
|
||||
return enableChildModule;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withEnableChildModule(Boolean enableChildModule)
|
||||
{
|
||||
this.enableChildModule = enableChildModule;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public String getSecurityKeyPrefix()
|
||||
{
|
||||
return securityKeyPrefix;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withSecurityKeyPrefix(String securityKeyPrefix)
|
||||
{
|
||||
this.securityKeyPrefix = securityKeyPrefix;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public QCodeReference getOnEntityCreatedHandler()
|
||||
{
|
||||
return onEntityCreatedHandler;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withOnEntityCreatedHandler(QCodeReference onEntityCreatedHandler)
|
||||
{
|
||||
this.onEntityCreatedHandler = onEntityCreatedHandler;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public QCodeReference getCustomValidationProvider()
|
||||
{
|
||||
return customValidationProvider;
|
||||
}
|
||||
|
||||
|
||||
public ExampleAppQBitConfig withCustomValidationProvider(QCodeReference customValidationProvider)
|
||||
{
|
||||
this.customValidationProvider = customValidationProvider;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Producer for the Example Application QBit.
|
||||
**
|
||||
** Application QBits produce:
|
||||
** - QBitMetaData for identification
|
||||
** - QAppSection for UI navigation (REQUIRED)
|
||||
** - Tables, processes, widgets, and other metadata
|
||||
** - Security configuration
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerOutput;
|
||||
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.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qbits.example.model.ExampleEntity;
|
||||
import com.kingsrook.qbits.example.model.ExampleChildEntity;
|
||||
|
||||
|
||||
public class ExampleAppQBitProducer implements QBitProducer
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-example-app";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private ExampleAppQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the QBit metadata into the QInstance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance qInstance, String namespace) throws QException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Validate configuration //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(qInstance, errors);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw new QException("Configuration errors: " + String.join(", ", errors));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Create and register QBit identity //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
QBitMetaData qBitMetaData = new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(namespace)
|
||||
.withConfig(config);
|
||||
qInstance.addQBit(qBitMetaData);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Discover and produce all component metadata //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
List<MetaDataProducerInterface<?>> producers =
|
||||
MetaDataProducerHelper.findProducers(getClass().getPackageName());
|
||||
|
||||
for(MetaDataProducerInterface<?> producer : producers)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Skip disabled components //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(!isProducerEnabled(producer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MetaDataProducerOutput output = producer.produce(qInstance);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Apply table prefix if configured //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(output instanceof QTableMetaData table)
|
||||
{
|
||||
String prefixedName = config.applyPrefix(table.getName());
|
||||
table.setName(prefixedName);
|
||||
table.setBackendName(config.getBackendName());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Mark scope for all SourceQBitAware outputs //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(output instanceof SourceQBitAware sqa)
|
||||
{
|
||||
sqa.setSourceQBitName(qBitMetaData.getName());
|
||||
}
|
||||
|
||||
output.addSelfToInstance(qInstance);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Register QAppMetaData for UI navigation (REQUIRED for app QBits) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
qInstance.addApp(produceApp(config, qBitMetaData.getName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the QAppMetaData for UI navigation.
|
||||
**
|
||||
** This is REQUIRED for Application QBits - it defines how users navigate
|
||||
** to the tables, processes, and widgets in the UI.
|
||||
*******************************************************************************/
|
||||
private QAppMetaData produceApp(ExampleAppQBitConfig config, String qbitName)
|
||||
{
|
||||
List<String> tables = new ArrayList<>();
|
||||
tables.add(config.applyPrefix(ExampleEntity.TABLE_NAME));
|
||||
|
||||
if(Boolean.TRUE.equals(config.getEnableChildModule()))
|
||||
{
|
||||
tables.add(config.applyPrefix(ExampleChildEntity.TABLE_NAME));
|
||||
}
|
||||
|
||||
QAppSection section = new QAppSection()
|
||||
.withName(config.applyPrefix("exampleSection"))
|
||||
.withLabel("Example Application")
|
||||
.withIcon(new QIcon().withName("dashboard"))
|
||||
.withTables(tables);
|
||||
|
||||
return new QAppMetaData()
|
||||
.withName(config.applyPrefix("exampleApp"))
|
||||
.withLabel("Example Application")
|
||||
.withIcon(new QIcon().withName("dashboard"))
|
||||
.withSection(section);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check if a producer should be enabled based on configuration.
|
||||
*******************************************************************************/
|
||||
private boolean isProducerEnabled(MetaDataProducerInterface<?> producer)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Skip child entity if module disabled //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!Boolean.TRUE.equals(config.getEnableChildModule()))
|
||||
{
|
||||
if(producer.getClass().getSimpleName().contains("ExampleChildEntity"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Fluent setters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public ExampleAppQBitProducer withConfig(ExampleAppQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example child entity demonstrating parent-child relationships.
|
||||
**
|
||||
** This entity shows:
|
||||
** - Foreign key relationship to parent entity
|
||||
** - Conditional inclusion via config toggle
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
|
||||
|
||||
@QMetaDataProducingEntity(producePossibleValueSource = true)
|
||||
public class ExampleChildEntity extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "exampleChildEntity";
|
||||
|
||||
@QField(isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, possibleValueSourceName = "exampleEntity")
|
||||
private Integer exampleEntityId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField(maxLength = 500)
|
||||
private String description;
|
||||
|
||||
@QField
|
||||
private Integer sortOrder;
|
||||
|
||||
@QField
|
||||
private Boolean isActive = true;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Fluent setters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public ExampleChildEntity withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withExampleEntityId(Integer exampleEntityId)
|
||||
{
|
||||
this.exampleEntityId = exampleEntityId;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withSortOrder(Integer sortOrder)
|
||||
{
|
||||
this.sortOrder = sortOrder;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withIsActive(Boolean isActive)
|
||||
{
|
||||
this.isActive = isActive;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleChildEntity withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Getters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public Integer getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public Integer getExampleEntityId()
|
||||
{
|
||||
return exampleEntityId;
|
||||
}
|
||||
|
||||
|
||||
public String getName()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
public String getDescription()
|
||||
{
|
||||
return description;
|
||||
}
|
||||
|
||||
|
||||
public Integer getSortOrder()
|
||||
{
|
||||
return sortOrder;
|
||||
}
|
||||
|
||||
|
||||
public Boolean getIsActive()
|
||||
{
|
||||
return isActive;
|
||||
}
|
||||
|
||||
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return createDate;
|
||||
}
|
||||
|
||||
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return modifyDate;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example entity for an Application QBit.
|
||||
**
|
||||
** This entity demonstrates:
|
||||
** - Standard QQQ entity patterns
|
||||
** - PossibleValueSource generation
|
||||
** - Fields with various types and constraints
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
|
||||
|
||||
|
||||
@QMetaDataProducingEntity(producePossibleValueSource = true)
|
||||
public class ExampleEntity extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "exampleEntity";
|
||||
|
||||
@QField(isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 100)
|
||||
private String name;
|
||||
|
||||
@QField(maxLength = 500)
|
||||
private String description;
|
||||
|
||||
@QField(label = "Status")
|
||||
private String status;
|
||||
|
||||
@QField
|
||||
private Boolean isActive = true;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Fluent setters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public ExampleEntity withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withIsActive(Boolean isActive)
|
||||
{
|
||||
this.isActive = isActive;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public ExampleEntity withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// Getters //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public Integer getId()
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public String getName()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
|
||||
public String getDescription()
|
||||
{
|
||||
return description;
|
||||
}
|
||||
|
||||
|
||||
public String getStatus()
|
||||
{
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
public Boolean getIsActive()
|
||||
{
|
||||
return isActive;
|
||||
}
|
||||
|
||||
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return createDate;
|
||||
}
|
||||
|
||||
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return modifyDate;
|
||||
}
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** MetaData producer for the example process.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.processes;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
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.MetaDataProducerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qbits.example.model.ExampleEntity;
|
||||
|
||||
|
||||
public class ExampleProcessMetaDataProducer implements MetaDataProducerInterface<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "exampleProcess";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the process metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Example Process")
|
||||
.withTableName(ExampleEntity.TABLE_NAME)
|
||||
.withStepList(List.of(
|
||||
new QBackendStepMetaData()
|
||||
.withName("process")
|
||||
.withCode(new QCodeReference(ExampleProcessStep.class))
|
||||
.withInputData(new QFunctionInputMetaData()
|
||||
.withField(new QFieldMetaData("confirmAction", QFieldType.BOOLEAN)
|
||||
.withLabel("Confirm Action")
|
||||
.withDefaultValue(false)))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Example process step demonstrating business logic implementation.
|
||||
**
|
||||
** This shows:
|
||||
** - Standard process step patterns
|
||||
** - Input/output handling
|
||||
** - Logging conventions
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.processes;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
public class ExampleProcessStep extends AbstractTransformStep
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ExampleProcessStep.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the process step.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
List<QRecord> records = input.getRecords();
|
||||
LOG.info("Processing records", logPair("count", records.size()));
|
||||
|
||||
for(QRecord record : records)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// Example business logic - update status //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
String currentStatus = record.getValueString("status");
|
||||
if("PENDING".equals(currentStatus))
|
||||
{
|
||||
record.setValue("status", "PROCESSED");
|
||||
}
|
||||
|
||||
output.addRecord(record);
|
||||
}
|
||||
|
||||
LOG.info("Processing complete", logPair("processed", records.size()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return the process summary. Override this method in subclasses to provide
|
||||
** meaningful summary lines for the result screen.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>());
|
||||
}
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** MetaData producer for an example dashboard widget.
|
||||
**
|
||||
** Widgets provide dashboard components for Application QBits.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
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.MetaDataProducerInterface;
|
||||
|
||||
|
||||
public class ExampleDashboardWidgetMetaDataProducer implements MetaDataProducerInterface<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "exampleDashboardWidget";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Produce the widget metadata.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Example Dashboard")
|
||||
.withType(WidgetType.STATISTICS.getType())
|
||||
.withCodeReference(new QCodeReference(ExampleDashboardWidgetRenderer.class));
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*******************************************************************************
|
||||
** Renderer for the example dashboard widget.
|
||||
**
|
||||
** This class produces the data displayed in the widget.
|
||||
*******************************************************************************/
|
||||
package com.kingsrook.qbits.example.widgets;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.StatisticsData;
|
||||
|
||||
|
||||
public class ExampleDashboardWidgetRenderer extends AbstractWidgetRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render the widget data.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Add chart data, counts, or other dashboard information here //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
StatisticsData data = new StatisticsData()
|
||||
.withCount(0)
|
||||
.withCountContext("Total items");
|
||||
|
||||
return (new RenderWidgetOutput(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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.filegateway;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a host wires into its ESB, Quick Search and API, without this qbit
|
||||
** depending on those modules (the same opt-in hooks as qbit-sql-workbench).
|
||||
**
|
||||
** <ul>
|
||||
** <li>{@link #esbTableNames()}: publish these tables' inserts, updates and
|
||||
** deletes to an ESB topic (the main tables).</li>
|
||||
** <li>{@link #quickSearchTables()}: index these in Quick Search, with field
|
||||
** weights.</li>
|
||||
** <li>{@link #apiTableNames()}: expose these in the host's QQQ API. The API
|
||||
** gets exactly the admin's rules: the same permissions, customizers and
|
||||
** read-only tables.</li>
|
||||
** </ul>
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayIntegrations
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A table for Quick Search: field weights, the timestamp for incremental
|
||||
** indexing, and the record label.
|
||||
*******************************************************************************/
|
||||
public record SearchableTable(String tableName, Map<String, Integer> fieldWeights, String basepullTimestamp, String recordLabelFormat, List<String> recordLabelFields)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FileGatewayIntegrations()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables to publish on the ESB.
|
||||
*******************************************************************************/
|
||||
public static List<String> esbTableNames()
|
||||
{
|
||||
return (List.of(FileGatewayTables.ACCOUNT, FileGatewayTables.ALIAS, FileGatewayTables.UPLOAD_JOB));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for the host's QQQ API.
|
||||
*******************************************************************************/
|
||||
public static List<String> apiTableNames()
|
||||
{
|
||||
return (FileGatewayTables.ALL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables for Quick Search.
|
||||
*******************************************************************************/
|
||||
public static List<SearchableTable> quickSearchTables()
|
||||
{
|
||||
return (List.of(
|
||||
new SearchableTable(FileGatewayTables.ACCOUNT, Map.of("username", 10, "label", 6), "modifyDate", "%s", List.of("username")),
|
||||
new SearchableTable(FileGatewayTables.ALIAS, Map.of("name", 10, "path", 6), "modifyDate", "%s", List.of("name")),
|
||||
new SearchableTable(FileGatewayTables.UPLOAD_JOB, Map.of("fileName", 10), "modifyDate", "%s", List.of("fileName"))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
/*
|
||||
* 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.filegateway;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration shared by the admin application and gateway deployment.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayQBitConfig implements QBitConfig
|
||||
{
|
||||
private String schedulerName = null;
|
||||
private Duration serverRetention = Duration.ofDays(1);
|
||||
private String backendName = "rdbms";
|
||||
private boolean sftpEnabled = false;
|
||||
private int sftpPort = 2222;
|
||||
private String publicHostName = null;
|
||||
private String hostKeyEnvVarName = "FILE_GATEWAY_SSH_HOST_KEY";
|
||||
private boolean allowGeneratedHostKey = false;
|
||||
private String fileGatewayBucketAlias = "file-gateway";
|
||||
private int uploadPartSizeMb = 8;
|
||||
private int uploadReorderWindowMb = 64;
|
||||
private int downloadReadAheadMb = 16;
|
||||
private int maxGeneratedFileMb = 1024;
|
||||
private int uploadWorkerThreads = 4;
|
||||
private Duration dedupeWindow = Duration.ofDays(7);
|
||||
private Duration jobStaleAfter = Duration.ofMinutes(30);
|
||||
private List<String> temporaryUploadSuffixes = List.of(".filepart", ".tmp", ".part", ".partial");
|
||||
private int lockoutThreshold = 5;
|
||||
private Duration lockoutWindow = Duration.ofMinutes(15);
|
||||
private Duration lockoutDuration = Duration.ofMinutes(30);
|
||||
private int maxConnectionsPerIpPerMinute = 30;
|
||||
private int defaultMaxConcurrentSessions = 4;
|
||||
private int defaultMaxOperationsPerMinute = 600;
|
||||
private int defaultMaxFileSizeMb = 100;
|
||||
private Integer defaultMaxUploadMbPerDay = null;
|
||||
private Integer defaultMaxDownloadMbPerDay = null;
|
||||
private QCodeReference scanner = null;
|
||||
private String pgpPrivateKeyEnvVarName = null;
|
||||
private String pgpPassphraseEnvVarName = null;
|
||||
private Duration logRetention = Duration.ofDays(180);
|
||||
private Duration archiveRetention = Duration.ofDays(30);
|
||||
private Duration outboxRetention = Duration.ofDays(30);
|
||||
private Duration resultsRetention = Duration.ofDays(90);
|
||||
private Duration receivingStaleAfter = Duration.ofHours(1);
|
||||
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer = null;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName.
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sftpEnabled.
|
||||
*******************************************************************************/
|
||||
public boolean getSftpEnabled()
|
||||
{
|
||||
return (sftpEnabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sftpEnabled.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withSftpEnabled(boolean sftpEnabled)
|
||||
{
|
||||
this.sftpEnabled = sftpEnabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sftpPort.
|
||||
*******************************************************************************/
|
||||
public int getSftpPort()
|
||||
{
|
||||
return (sftpPort);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Public SFTP hostname advertised in Preferences, when configured.
|
||||
*******************************************************************************/
|
||||
public String getPublicHostName()
|
||||
{
|
||||
return (publicHostName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the public SFTP hostname; null omits it from Preferences.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withPublicHostName(String publicHostName)
|
||||
{
|
||||
this.publicHostName = publicHostName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sftpPort.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withSftpPort(int sftpPort)
|
||||
{
|
||||
this.sftpPort = sftpPort;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hostKeyEnvVarName.
|
||||
*******************************************************************************/
|
||||
public String getHostKeyEnvVarName()
|
||||
{
|
||||
return (hostKeyEnvVarName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hostKeyEnvVarName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withHostKeyEnvVarName(String hostKeyEnvVarName)
|
||||
{
|
||||
this.hostKeyEnvVarName = hostKeyEnvVarName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowGeneratedHostKey.
|
||||
*******************************************************************************/
|
||||
public boolean getAllowGeneratedHostKey()
|
||||
{
|
||||
return (allowGeneratedHostKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowGeneratedHostKey.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withAllowGeneratedHostKey(boolean allowGeneratedHostKey)
|
||||
{
|
||||
this.allowGeneratedHostKey = allowGeneratedHostKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileGatewayBucketAlias.
|
||||
*******************************************************************************/
|
||||
public String getFileGatewayBucketAlias()
|
||||
{
|
||||
return (fileGatewayBucketAlias);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileGatewayBucketAlias.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withFileGatewayBucketAlias(String fileGatewayBucketAlias)
|
||||
{
|
||||
this.fileGatewayBucketAlias = fileGatewayBucketAlias;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uploadPartSizeMb.
|
||||
*******************************************************************************/
|
||||
public int getUploadPartSizeMb()
|
||||
{
|
||||
return (uploadPartSizeMb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uploadPartSizeMb.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withUploadPartSizeMb(int uploadPartSizeMb)
|
||||
{
|
||||
this.uploadPartSizeMb = uploadPartSizeMb;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uploadReorderWindowMb.
|
||||
*******************************************************************************/
|
||||
public int getUploadReorderWindowMb()
|
||||
{
|
||||
return (uploadReorderWindowMb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uploadReorderWindowMb.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withUploadReorderWindowMb(int uploadReorderWindowMb)
|
||||
{
|
||||
this.uploadReorderWindowMb = uploadReorderWindowMb;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for downloadReadAheadMb.
|
||||
*******************************************************************************/
|
||||
public int getDownloadReadAheadMb()
|
||||
{
|
||||
return (downloadReadAheadMb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for downloadReadAheadMb.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDownloadReadAheadMb(int downloadReadAheadMb)
|
||||
{
|
||||
this.downloadReadAheadMb = downloadReadAheadMb;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxGeneratedFileMb.
|
||||
*******************************************************************************/
|
||||
public int getMaxGeneratedFileMb()
|
||||
{
|
||||
return (maxGeneratedFileMb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxGeneratedFileMb.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withMaxGeneratedFileMb(int maxGeneratedFileMb)
|
||||
{
|
||||
this.maxGeneratedFileMb = maxGeneratedFileMb;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uploadWorkerThreads.
|
||||
*******************************************************************************/
|
||||
public int getUploadWorkerThreads()
|
||||
{
|
||||
return (uploadWorkerThreads);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uploadWorkerThreads.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withUploadWorkerThreads(int uploadWorkerThreads)
|
||||
{
|
||||
this.uploadWorkerThreads = uploadWorkerThreads;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dedupeWindow.
|
||||
*******************************************************************************/
|
||||
public Duration getDedupeWindow()
|
||||
{
|
||||
return (dedupeWindow);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for dedupeWindow.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDedupeWindow(Duration dedupeWindow)
|
||||
{
|
||||
this.dedupeWindow = dedupeWindow;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for jobStaleAfter.
|
||||
*******************************************************************************/
|
||||
public Duration getJobStaleAfter()
|
||||
{
|
||||
return (jobStaleAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jobStaleAfter.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withJobStaleAfter(Duration jobStaleAfter)
|
||||
{
|
||||
this.jobStaleAfter = jobStaleAfter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for temporaryUploadSuffixes.
|
||||
*******************************************************************************/
|
||||
public List<String> getTemporaryUploadSuffixes()
|
||||
{
|
||||
return (temporaryUploadSuffixes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for temporaryUploadSuffixes.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withTemporaryUploadSuffixes(List<String> temporaryUploadSuffixes)
|
||||
{
|
||||
this.temporaryUploadSuffixes = temporaryUploadSuffixes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lockoutThreshold.
|
||||
*******************************************************************************/
|
||||
public int getLockoutThreshold()
|
||||
{
|
||||
return (lockoutThreshold);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lockoutThreshold.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withLockoutThreshold(int lockoutThreshold)
|
||||
{
|
||||
this.lockoutThreshold = lockoutThreshold;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lockoutWindow.
|
||||
*******************************************************************************/
|
||||
public Duration getLockoutWindow()
|
||||
{
|
||||
return (lockoutWindow);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lockoutWindow.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withLockoutWindow(Duration lockoutWindow)
|
||||
{
|
||||
this.lockoutWindow = lockoutWindow;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lockoutDuration.
|
||||
*******************************************************************************/
|
||||
public Duration getLockoutDuration()
|
||||
{
|
||||
return (lockoutDuration);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lockoutDuration.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withLockoutDuration(Duration lockoutDuration)
|
||||
{
|
||||
this.lockoutDuration = lockoutDuration;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxConnectionsPerIpPerMinute.
|
||||
*******************************************************************************/
|
||||
public int getMaxConnectionsPerIpPerMinute()
|
||||
{
|
||||
return (maxConnectionsPerIpPerMinute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxConnectionsPerIpPerMinute.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withMaxConnectionsPerIpPerMinute(int maxConnectionsPerIpPerMinute)
|
||||
{
|
||||
this.maxConnectionsPerIpPerMinute = maxConnectionsPerIpPerMinute;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxConcurrentSessions.
|
||||
*******************************************************************************/
|
||||
public int getDefaultMaxConcurrentSessions()
|
||||
{
|
||||
return (defaultMaxConcurrentSessions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxConcurrentSessions.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDefaultMaxConcurrentSessions(int defaultMaxConcurrentSessions)
|
||||
{
|
||||
this.defaultMaxConcurrentSessions = defaultMaxConcurrentSessions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxOperationsPerMinute.
|
||||
*******************************************************************************/
|
||||
public int getDefaultMaxOperationsPerMinute()
|
||||
{
|
||||
return (defaultMaxOperationsPerMinute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxOperationsPerMinute.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDefaultMaxOperationsPerMinute(int defaultMaxOperationsPerMinute)
|
||||
{
|
||||
this.defaultMaxOperationsPerMinute = defaultMaxOperationsPerMinute;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxFileSizeMb.
|
||||
*******************************************************************************/
|
||||
public int getDefaultMaxFileSizeMb()
|
||||
{
|
||||
return (defaultMaxFileSizeMb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxFileSizeMb.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDefaultMaxFileSizeMb(int defaultMaxFileSizeMb)
|
||||
{
|
||||
this.defaultMaxFileSizeMb = defaultMaxFileSizeMb;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxUploadMbPerDay.
|
||||
*******************************************************************************/
|
||||
public Integer getDefaultMaxUploadMbPerDay()
|
||||
{
|
||||
return (defaultMaxUploadMbPerDay);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxUploadMbPerDay.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDefaultMaxUploadMbPerDay(Integer defaultMaxUploadMbPerDay)
|
||||
{
|
||||
this.defaultMaxUploadMbPerDay = defaultMaxUploadMbPerDay;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultMaxDownloadMbPerDay.
|
||||
*******************************************************************************/
|
||||
public Integer getDefaultMaxDownloadMbPerDay()
|
||||
{
|
||||
return (defaultMaxDownloadMbPerDay);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultMaxDownloadMbPerDay.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withDefaultMaxDownloadMbPerDay(Integer defaultMaxDownloadMbPerDay)
|
||||
{
|
||||
this.defaultMaxDownloadMbPerDay = defaultMaxDownloadMbPerDay;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scanner.
|
||||
*******************************************************************************/
|
||||
public QCodeReference getScanner()
|
||||
{
|
||||
return (scanner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scanner.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withScanner(QCodeReference scanner)
|
||||
{
|
||||
this.scanner = scanner;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pgpPrivateKeyEnvVarName.
|
||||
*******************************************************************************/
|
||||
public String getPgpPrivateKeyEnvVarName()
|
||||
{
|
||||
return (pgpPrivateKeyEnvVarName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pgpPrivateKeyEnvVarName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withPgpPrivateKeyEnvVarName(String pgpPrivateKeyEnvVarName)
|
||||
{
|
||||
this.pgpPrivateKeyEnvVarName = pgpPrivateKeyEnvVarName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for pgpPassphraseEnvVarName.
|
||||
*******************************************************************************/
|
||||
public String getPgpPassphraseEnvVarName()
|
||||
{
|
||||
return (pgpPassphraseEnvVarName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for pgpPassphraseEnvVarName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withPgpPassphraseEnvVarName(String pgpPassphraseEnvVarName)
|
||||
{
|
||||
this.pgpPassphraseEnvVarName = pgpPassphraseEnvVarName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for logRetention.
|
||||
*******************************************************************************/
|
||||
public Duration getLogRetention()
|
||||
{
|
||||
return (logRetention);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for logRetention.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withLogRetention(Duration logRetention)
|
||||
{
|
||||
this.logRetention = logRetention;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for archiveRetention.
|
||||
*******************************************************************************/
|
||||
public Duration getArchiveRetention()
|
||||
{
|
||||
return (archiveRetention);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for archiveRetention.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withArchiveRetention(Duration archiveRetention)
|
||||
{
|
||||
this.archiveRetention = archiveRetention;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outboxRetention.
|
||||
*******************************************************************************/
|
||||
public Duration getOutboxRetention()
|
||||
{
|
||||
return (outboxRetention);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outboxRetention.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withOutboxRetention(Duration outboxRetention)
|
||||
{
|
||||
this.outboxRetention = outboxRetention;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for resultsRetention.
|
||||
*******************************************************************************/
|
||||
public Duration getResultsRetention()
|
||||
{
|
||||
return (resultsRetention);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for resultsRetention.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withResultsRetention(Duration resultsRetention)
|
||||
{
|
||||
this.resultsRetention = resultsRetention;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for receivingStaleAfter.
|
||||
*******************************************************************************/
|
||||
public Duration getReceivingStaleAfter()
|
||||
{
|
||||
return (receivingStaleAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for receivingStaleAfter.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withReceivingStaleAfter(Duration receivingStaleAfter)
|
||||
{
|
||||
this.receivingStaleAfter = receivingStaleAfter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
|
||||
{
|
||||
return (tableMetaDataCustomizer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableMetaDataCustomizer.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
|
||||
{
|
||||
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public String getSchedulerName()
|
||||
{
|
||||
return (schedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for schedulerName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withSchedulerName(String schedulerName)
|
||||
{
|
||||
this.schedulerName = schedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for serverRetention.
|
||||
*******************************************************************************/
|
||||
public Duration getServerRetention()
|
||||
{
|
||||
return (serverRetention);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for serverRetention.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitConfig withServerRetention(Duration serverRetention)
|
||||
{
|
||||
this.serverRetention = serverRetention;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* 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.filegateway;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayProcesses;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
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.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point for the file-gateway metadata; runtime startup belongs to the host.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayQBitProducer implements QBitMetaDataProducer<FileGatewayQBitConfig>
|
||||
{
|
||||
/** Core possible-value sources a host may or may not register. */
|
||||
static final java.util.Set<String> OPTIONAL_HOST_SOURCES = java.util.Set.of("savedView", "savedReport", "savedBulkLoadProfile");
|
||||
|
||||
/*******************************************************************************
|
||||
** The sftp wall owns its list (core's qbit prefixes are transitional and the
|
||||
** assistant may now read audits): partners never reach identity, audits,
|
||||
** webhooks or this qbit's own tables, read or write. Core's generic secret,
|
||||
** credential, permission and session patterns are added on top.
|
||||
*******************************************************************************/
|
||||
public static final java.util.List<String> SFTP_DENIED_PATTERNS = java.util.List.of(
|
||||
"file_gateway_*", "audit*", "authentik_*", "webhooks_*", "worm_audit_*", "secured_data_*", "assistant_*");
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-file-gateway";
|
||||
public static final String VERSION = "0.1.0";
|
||||
|
||||
private FileGatewayQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitProducer withQBitConfig(FileGatewayQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the config.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileGatewayQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the namespace.
|
||||
*******************************************************************************/
|
||||
public FileGatewayQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for the namespace.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity and configuration of this qbit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Package-scanned producers have no config: skip them.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register tables, child lists, permissions, channel walls and the application.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(!isEnabled())
|
||||
{
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
QBitMetaData qBit = getQBitMetaData();
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput().with(qBit);
|
||||
FileGatewayTables.possibleValueSources().forEach(output::add);
|
||||
QAppMetaData app = new QAppMetaData().withName("file_gateway").withLabel("File Gateway")
|
||||
.withIcon(new QIcon("folder_shared")).withPermissionRules(FileGatewayTables.permissionRules("file_gateway"));
|
||||
for(QTableMetaData table : FileGatewayTables.tables(qBitConfig))
|
||||
{
|
||||
if(qBitConfig.getTableMetaDataCustomizer() != null)
|
||||
{
|
||||
table = qBitConfig.getTableMetaDataCustomizer().customizeMetaData(qInstance, table);
|
||||
}
|
||||
table.setSourceQBitName(qBit.getName());
|
||||
dropMissingPickers(qInstance, table);
|
||||
output.add(table);
|
||||
if(FileGatewayTables.MAIN.contains(table.getName()))
|
||||
{
|
||||
app.withChild(table);
|
||||
}
|
||||
}
|
||||
app.withSection(new QAppSection().withName("configuration").withLabel("Configuration")
|
||||
.withTables(List.of(FileGatewayTables.ACCOUNT, FileGatewayTables.ALIAS)));
|
||||
app.withSection(new QAppSection().withName("activity").withLabel("Activity")
|
||||
.withTables(List.of(FileGatewayTables.UPLOAD_JOB, FileGatewayTables.TRANSFER_LOG, FileGatewayTables.CONNECTION_LOG)));
|
||||
FileGatewayTables.joins().forEach(output::add);
|
||||
FileGatewayTables.childWidgets().forEach(output::add);
|
||||
var home = com.kingsrook.qbits.filegateway.metadata.FileGatewayHome.widgets();
|
||||
home.forEach(output::add);
|
||||
app.withWidgets(home.stream().map(widget -> widget.getName()).toList());
|
||||
output.add(app);
|
||||
FileGatewayProcesses.processes().forEach(output::add);
|
||||
output.add(com.kingsrook.qbits.filegateway.maintenance.GatewayMaintenance.metadata(qBitConfig));
|
||||
output.add(new QAlertTypeMetaData().withName("filegateway.lockout").withLabel("File Gateway Lockout")
|
||||
.withSourceQBitName(qBit.getName()).withSubjectTableName(FileGatewayTables.ACCOUNT)
|
||||
.withVisibilityPermission(FileGatewayPermissions.VIEW));
|
||||
output.addSelfToInstance(qInstance);
|
||||
com.kingsrook.qbits.filegateway.preferences.FileTransferPreferencesProvider.register(qInstance, qBitConfig);
|
||||
ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance)
|
||||
.withChannel("sftp", "SFTP file gateway").withDeniedPattern("sftp", "file_gateway_*")
|
||||
.withDeniedPattern("assistant", "file_gateway_*");
|
||||
for(String pattern : SFTP_DENIED_PATTERNS)
|
||||
{
|
||||
channels.withDeniedPattern("sftp", pattern);
|
||||
}
|
||||
for(String pattern : ClientChannelsMetaData.ASSISTANT_GENERIC_DENIED_PATTERNS)
|
||||
{
|
||||
channels.withDeniedPattern("sftp", pattern);
|
||||
}
|
||||
com.kingsrook.qbits.filegateway.api.FileGatewayOutbox.setProvider(new com.kingsrook.qbits.filegateway.outbox.OutboxService(qInstance,
|
||||
new com.kingsrook.qbits.filegateway.storage.ObjectStorageGatewayStore(qInstance, qBitConfig.getFileGatewayBucketAlias()), qBitConfig, java.time.Clock.systemUTC()));
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved views, saved reports and saved bulk-load profiles are optional in a
|
||||
** host. A picker whose possible-value source the host has not registered (yet)
|
||||
** stays a plain id field, so the instance validates; register those core
|
||||
** metadata providers before this producer to get the pickers.
|
||||
*******************************************************************************/
|
||||
static void dropMissingPickers(QInstance qInstance, QTableMetaData table)
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String source = field.getPossibleValueSourceName();
|
||||
if(source != null && OPTIONAL_HOST_SOURCES.contains(source) && qInstance.getPossibleValueSource(source) == null)
|
||||
{
|
||||
field.setPossibleValueSourceName(null);
|
||||
field.setPossibleValueSourceFilter(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.filegateway;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.function.Function;
|
||||
import com.kingsrook.qbits.filegateway.sftp.SftpProtocolServer;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
import com.kingsrook.qbits.filegateway.storage.ObjectStorageGatewayStore;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobWorker;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
import org.apache.sshd.common.NamedResource;
|
||||
import org.apache.sshd.common.config.keys.KeyUtils;
|
||||
import org.apache.sshd.common.util.security.SecurityUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-started SFTP runtime. Host private keys are parsed in memory and never persisted.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayRuntime implements AutoCloseable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(FileGatewayRuntime.class);
|
||||
private final UiApiInvoker invoker;
|
||||
private final SftpProtocolServer protocol;
|
||||
private final UploadJobWorker worker;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the host UI API server to the SFTP channel.
|
||||
*******************************************************************************/
|
||||
private FileGatewayRuntime(QInstance instance, QApplicationJavalinServer server, FileGatewayQBitConfig config, KeyPair key, Clock clock, Duration interval, String podName, GatewayObjectStore store)
|
||||
{
|
||||
invoker = UiApiInvoker.forServer(server, "sftp");
|
||||
protocol = new SftpProtocolServer(instance, config, invoker, key, clock, interval, podName, store);
|
||||
worker = new UploadJobWorker(instance, invoker, config, store, clock, podName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start only when the registered qbit configuration enables SFTP.
|
||||
*******************************************************************************/
|
||||
public static FileGatewayRuntime start(QInstance instance, QApplicationJavalinServer server) throws Exception
|
||||
{
|
||||
return (start(instance, server, System::getenv));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start with an injectable environment reader.
|
||||
*******************************************************************************/
|
||||
public static FileGatewayRuntime start(QInstance instance, QApplicationJavalinServer server, Function<String, String> environment) throws Exception
|
||||
{
|
||||
var qbit = instance.getQBits().get(FileGatewayQBitProducer.GROUP_ID + ":" + FileGatewayQBitProducer.ARTIFACT_ID);
|
||||
if(qbit == null || !(qbit.getConfig() instanceof FileGatewayQBitConfig config) || !config.getSftpEnabled())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
FileGatewayRuntime runtime = new FileGatewayRuntime(instance, server, config, hostKey(config, environment), Clock.systemUTC(), Duration.ofSeconds(60), environment.apply("HOSTNAME"), new ObjectStorageGatewayStore(instance, config.getFileGatewayBucketAlias()));
|
||||
runtime.worker.withEnvironment(environment);
|
||||
runtime.protocol.start();
|
||||
runtime.worker.start(Duration.ofSeconds(60));
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start with an explicit ephemeral port, in-memory host key and controllable clock.
|
||||
*******************************************************************************/
|
||||
public static FileGatewayRuntime startForTest(QInstance instance, QApplicationJavalinServer server, FileGatewayQBitConfig config, KeyPair key, Clock clock, Duration pollInterval) throws IOException
|
||||
{
|
||||
return (startForTest(instance, server, config, key, clock, pollInterval, new ObjectStorageGatewayStore(instance, config.getFileGatewayBucketAlias())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transfer tests supply an in-memory store with no production fallback.
|
||||
*******************************************************************************/
|
||||
public static FileGatewayRuntime startForTest(QInstance instance, QApplicationJavalinServer server, FileGatewayQBitConfig config, KeyPair key, Clock clock, Duration pollInterval, GatewayObjectStore store) throws IOException
|
||||
{
|
||||
FileGatewayRuntime runtime = new FileGatewayRuntime(instance, server, config, key, clock, pollInterval, "test", store);
|
||||
runtime.protocol.start();
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read an OpenSSH private key directly from memory.
|
||||
*******************************************************************************/
|
||||
public static KeyPair hostKey(FileGatewayQBitConfig config, Function<String, String> environment) throws Exception
|
||||
{
|
||||
String pem = environment.apply(config.getHostKeyEnvVarName());
|
||||
if(pem == null || pem.isBlank())
|
||||
{
|
||||
if(!config.getAllowGeneratedHostKey())
|
||||
{
|
||||
throw (new IllegalStateException("SFTP host key environment variable is missing: " + config.getHostKeyEnvVarName()));
|
||||
}
|
||||
LOG.warn("Using a generated in-memory SFTP host key; do not use in production");
|
||||
return (KeyUtils.generateKeyPair("ecdsa-sha2-nistp256", 256));
|
||||
}
|
||||
try(var stream = new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)))
|
||||
{
|
||||
var keys = SecurityUtils.loadKeyPairIdentities(null, NamedResource.ofName("environment-host-key"), stream, null).iterator();
|
||||
if(!keys.hasNext())
|
||||
{
|
||||
throw (new IllegalArgumentException("No SFTP host key in environment variable"));
|
||||
}
|
||||
return (keys.next());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop the SFTP runtime.
|
||||
*******************************************************************************/
|
||||
public void stop() throws IOException
|
||||
{
|
||||
protocol.stop();
|
||||
try
|
||||
{
|
||||
worker.close();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
|
||||
{
|
||||
throw (new IOException("Unable to release upload jobs", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close this runtime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
stop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access the protocol listener.
|
||||
*******************************************************************************/
|
||||
public SftpProtocolServer getProtocol()
|
||||
{
|
||||
return (protocol);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fixed sftp-channel invoker.
|
||||
*******************************************************************************/
|
||||
public UiApiInvoker getInvoker()
|
||||
{
|
||||
return (invoker);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Deterministic worker hook for hosts and integration tests.
|
||||
*******************************************************************************/
|
||||
public UploadJobWorker getWorker()
|
||||
{
|
||||
return (worker);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consumer decision, including its reference or reason and retry delay.
|
||||
*******************************************************************************/
|
||||
public final class FileAcceptance
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delivery decisions.
|
||||
*******************************************************************************/
|
||||
public enum Status
|
||||
{
|
||||
ACCEPTED, REJECTED, RETRY_LATER
|
||||
}
|
||||
|
||||
private final Status status;
|
||||
private final String consumerReference;
|
||||
private final String reason;
|
||||
private final Duration retryAfter;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an immutable decision.
|
||||
*******************************************************************************/
|
||||
private FileAcceptance(Status status, String consumerReference, String reason, Duration retryAfter)
|
||||
{
|
||||
this.status = status;
|
||||
this.consumerReference = consumerReference;
|
||||
this.reason = reason;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept the delivery.
|
||||
*******************************************************************************/
|
||||
public static FileAcceptance accepted(String consumerReference)
|
||||
{
|
||||
return (new FileAcceptance(Status.ACCEPTED, consumerReference, null, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject the delivery.
|
||||
*******************************************************************************/
|
||||
public static FileAcceptance rejected(String reason)
|
||||
{
|
||||
return (new FileAcceptance(Status.REJECTED, null, reason, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request a retry.
|
||||
*******************************************************************************/
|
||||
public static FileAcceptance retryLater(String reason, Duration retryAfter)
|
||||
{
|
||||
return (new FileAcceptance(Status.RETRY_LATER, null, reason, retryAfter));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status.
|
||||
*******************************************************************************/
|
||||
public Status getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consumerReference.
|
||||
*******************************************************************************/
|
||||
public String getConsumerReference()
|
||||
{
|
||||
return (consumerReference);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for reason.
|
||||
*******************************************************************************/
|
||||
public String getReason()
|
||||
{
|
||||
return (reason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for retryAfter.
|
||||
*******************************************************************************/
|
||||
public Duration getRetryAfter()
|
||||
{
|
||||
return (retryAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consumer SPI for at-least-once inbound delivery and download notification.
|
||||
*******************************************************************************/
|
||||
public interface FileGatewayConsumer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable lowercase consumer name.
|
||||
*******************************************************************************/
|
||||
String getName();
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept, reject or request a later retry.
|
||||
*******************************************************************************/
|
||||
FileAcceptance accept(InboundFile file) throws Exception;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Called after the first complete download.
|
||||
*******************************************************************************/
|
||||
default void onDownloaded(OutboxFileRef ref) throws Exception
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-declared consumer implementations; contains no gateway runtime dependencies.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayConsumersMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
public static final String NAME = "fileGatewayConsumers";
|
||||
private final List<QCodeReference> consumers = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata registry name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get or install the registry.
|
||||
*******************************************************************************/
|
||||
public static FileGatewayConsumersMetaData ofOrWithNew(QInstance instance)
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.ofOrWithNew(instance, NAME, FileGatewayConsumersMetaData::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register a consumer implementation.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConsumersMetaData withConsumer(QCodeReference consumer)
|
||||
{
|
||||
consumers.add(consumer);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered consumer implementations.
|
||||
*******************************************************************************/
|
||||
public List<QCodeReference> getConsumers()
|
||||
{
|
||||
return (List.copyOf(consumers));
|
||||
}
|
||||
}
|
||||
@@ -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.filegateway.api;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional outbox facade; the host runtime owns provider registration.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayOutbox
|
||||
{
|
||||
private static volatile FileGatewayOutboxProvider provider;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Static facade.
|
||||
*******************************************************************************/
|
||||
private FileGatewayOutbox()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install or clear the runtime provider.
|
||||
*******************************************************************************/
|
||||
public static void setProvider(FileGatewayOutboxProvider provider)
|
||||
{
|
||||
FileGatewayOutbox.provider = provider;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the runtime supports outbox writes.
|
||||
*******************************************************************************/
|
||||
public static boolean isAvailable()
|
||||
{
|
||||
return (provider != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate to the installed provider.
|
||||
*******************************************************************************/
|
||||
public static OutboxFileRef put(String endpointName, String fileName, InputStream content, long sizeBytes) throws FileGatewayUnavailableException, QException
|
||||
{
|
||||
FileGatewayOutboxProvider current = provider;
|
||||
if(current == null)
|
||||
{
|
||||
throw (new FileGatewayUnavailableException());
|
||||
}
|
||||
return (current.put(endpointName, fileName, content, sizeBytes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runtime implementation registered with the outbox facade.
|
||||
*******************************************************************************/
|
||||
public interface FileGatewayOutboxProvider
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stream content into the endpoint outbox.
|
||||
*******************************************************************************/
|
||||
OutboxFileRef put(String endpointName, String fileName, InputStream content, long sizeBytes) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.api;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional streaming scanner invoked before delivery to a consumer.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface FileGatewayScannerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** ERROR is transient; INFECTED quarantines the object without delivery.
|
||||
*******************************************************************************/
|
||||
enum Result
|
||||
{
|
||||
CLEAN, INFECTED, ERROR
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect the file with fresh streams; implementations must close opened streams.
|
||||
*******************************************************************************/
|
||||
Result scan(InboundFile file) throws Exception;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The outbox runtime has not been installed.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayUnavailableException extends QException
|
||||
{
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create the unavailable error.
|
||||
*******************************************************************************/
|
||||
public FileGatewayUnavailableException()
|
||||
{
|
||||
super("File gateway outbox is unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An inbound object; each openStream call supplies a fresh stream.
|
||||
*******************************************************************************/
|
||||
public record InboundFile(Integer uploadJobId, String endpointName, String username, String userId,
|
||||
String remoteAddress, String path, String fileName, long sizeBytes, String sha256,
|
||||
Instant receivedAt, String storageBucketAlias, String storageKey, Supplier<InputStream> streamSupplier)
|
||||
{
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a fresh object-storage stream.
|
||||
*******************************************************************************/
|
||||
public InputStream openStream()
|
||||
{
|
||||
return (streamSupplier.get());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.filegateway.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identity and metadata of a file waiting for partner pickup.
|
||||
*******************************************************************************/
|
||||
public record OutboxFileRef(Integer outboxFileId, String endpointName, String path, String fileName,
|
||||
long sizeBytes, String sha256, Instant createdAt)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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.filegateway.maintenance;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitProducer;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.outbox.OutboxService;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
import com.kingsrook.qbits.filegateway.storage.ObjectStorageGatewayStore;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
|
||||
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.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.QFilterCriteria;
|
||||
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.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Idempotent daily cleanup and at-least-once outbox callback retries.
|
||||
*******************************************************************************/
|
||||
public class GatewayMaintenance implements BackendStep
|
||||
{
|
||||
public static final String NAME = "file_gateway_retention";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hidden host-scheduled process; no navigation or user action is registered.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData metadata(FileGatewayQBitConfig config)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("File gateway retention").withIsHidden(true)
|
||||
.withPermissionRules(FileGatewayTables.permissionRules(NAME))
|
||||
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(GatewayMaintenance.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(86400).withInitialDelaySeconds(60));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve registered host configuration without starting the protocol runtime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
QInstance instance = QContext.getQInstance();
|
||||
FileGatewayQBitConfig config = (FileGatewayQBitConfig) instance.getQBits().get(FileGatewayQBitProducer.GROUP_ID + ":" + FileGatewayQBitProducer.ARTIFACT_ID).getConfig();
|
||||
maintain(instance, config, new ObjectStorageGatewayStore(instance, config.getFileGatewayBucketAlias()), Clock.systemUTC());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cursor batches remain correct while rows are deleted, and bound memory use.
|
||||
*******************************************************************************/
|
||||
public static void maintain(QInstance instance, FileGatewayQBitConfig config, GatewayObjectStore store, Clock clock) throws QException
|
||||
{
|
||||
AuditWriter audit = new AuditWriter(instance);
|
||||
OutboxService outbox = new OutboxService(instance, store, config, clock);
|
||||
for(String table : List.of(FileGatewayTables.UPLOAD_JOB, FileGatewayTables.OUTBOX_FILE, FileGatewayTables.TRANSFER_LOG, FileGatewayTables.CONNECTION_LOG, FileGatewayTables.SERVER))
|
||||
{
|
||||
int last = 0;
|
||||
while(true)
|
||||
{
|
||||
var rows = audit.query(table, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, last))
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(250));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
last = row.getValueInteger("id");
|
||||
try
|
||||
{
|
||||
if(FileGatewayTables.UPLOAD_JOB.equals(table))
|
||||
{
|
||||
audit.withAccountLock("id", row.getValue("accountId"), () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
cleanUpload(audit, store, audit.query(table, UploadJobs.equal("id", row.getValue("id"))).getFirst(), config, clock.instant());
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("Upload retention storage failure", e));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
else if(FileGatewayTables.OUTBOX_FILE.equals(table))
|
||||
{
|
||||
outbox.notifyDownloaded(row);
|
||||
if("REMOVED".equals(row.getValueString("status")) || older(row, "createDate", config.getOutboxRetention(), clock.instant())
|
||||
|| (row.getValueInstant("expiresAt") != null && !row.getValueInstant("expiresAt").isAfter(clock.instant())))
|
||||
{
|
||||
deleteObject(store, row, "storageKey");
|
||||
audit.update(table, new QRecord().withValue("id", row.getValue("id")).withValue("status", "EXPIRED").withValue("storageKey", null));
|
||||
}
|
||||
}
|
||||
else if(FileGatewayTables.SERVER.equals(table))
|
||||
{
|
||||
if(older(row, "lastSeenAt", config.getServerRetention(), clock.instant()))
|
||||
{
|
||||
audit.delete(table, row.getValue("id"));
|
||||
}
|
||||
}
|
||||
else if(older(row, "createDate", config.getLogRetention(), clock.instant())
|
||||
&& (!FileGatewayTables.CONNECTION_LOG.equals(table) || older(row, "lastSeenAt", Duration.ofMinutes(3), clock.instant())))
|
||||
{
|
||||
audit.delete(table, row.getValue("id"));
|
||||
}
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("File gateway retention storage failure", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never delete an object owned by an active worker; retain rows for the audit trail.
|
||||
*******************************************************************************/
|
||||
private static void cleanUpload(AuditWriter audit, GatewayObjectStore store, QRecord row, FileGatewayQBitConfig config, Instant now) throws IOException, QException
|
||||
{
|
||||
String status = row.getValueString("status");
|
||||
QRecord changes = new QRecord().withValue("id", row.getValue("id"));
|
||||
if("RECEIVING".equals(status) && older(row, "createDate", config.getReceivingStaleAfter(), now))
|
||||
{
|
||||
deleteObject(store, row, "storageKey");
|
||||
changes.withValue("status", "ABORTED").withValue("storageKey", null).withValue("finishedAt", now).withValue("errorMessage", "Incomplete upload expired");
|
||||
}
|
||||
else if(!List.of("RECEIVING", "RUNNING", "QUEUED").contains(status))
|
||||
{
|
||||
if(older(row, "createDate", config.getArchiveRetention(), now))
|
||||
{
|
||||
deleteObject(store, row, "storageKey");
|
||||
deleteObject(store, row, "plaintextStorageKey");
|
||||
changes.withValue("storageKey", null).withValue("plaintextStorageKey", null);
|
||||
}
|
||||
if(older(row, "createDate", config.getResultsRetention(), now))
|
||||
{
|
||||
deleteObject(store, row, "resultsStorageKey");
|
||||
changes.withValue("resultsStorageKey", null).withValue("resultsSizeBytes", null);
|
||||
}
|
||||
}
|
||||
if(changes.getValues().size() > 1)
|
||||
{
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, changes);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage deletes precede database clearing so transient errors can be retried.
|
||||
*******************************************************************************/
|
||||
private static void deleteObject(GatewayObjectStore store, QRecord row, String field) throws IOException
|
||||
{
|
||||
if(row.getValueString(field) != null)
|
||||
{
|
||||
store.delete(row.getValueString(field));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null timestamps are not evidence that a record is old enough to remove.
|
||||
*******************************************************************************/
|
||||
private static boolean older(QRecord row, String field, Duration age, Instant now)
|
||||
{
|
||||
return (row.getValueInstant(field) != null && !row.getValueInstant(field).plus(age).isAfter(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.filegateway.metadata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject account names that cannot be used as stable SSH login names.
|
||||
*******************************************************************************/
|
||||
public class AccountCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Partial updates preserve the current username; supplied names must be valid.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValues().containsKey("username") && (record.getValueString("username") == null || !record.getValueString("username").matches("[a-z0-9._-]{3,64}")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Username must be 3 to 64 lowercase letters, digits, dots, underscores or hyphens"));
|
||||
}
|
||||
if(oldRecords.isEmpty() && record.getValue("rootMode") == null)
|
||||
{
|
||||
record.setValue("rootMode", "SERVICE".equals(record.getValueString("accountType")) ? "ENDPOINTS_ONLY" : "FULL_TREE");
|
||||
}
|
||||
String pgpKey = record.getValueString("pgpPublicKey");
|
||||
if(pgpKey != null && !pgpKey.isBlank())
|
||||
{
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.filegateway.pgp.PgpStreams.encryptionKey(pgpKey);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("PGP public key must parse and contain a valid encryption subkey"));
|
||||
}
|
||||
}
|
||||
if(Boolean.TRUE.equals(record.getValueBoolean("pgpEncryptDownloads")) && (pgpKey == null || pgpKey.isBlank())
|
||||
&& oldRecords.orElse(List.of()).stream().noneMatch(old -> java.util.Objects.equals(old.getValue("id"), record.getValue("id")) && old.getValueString("pgpPublicKey") != null))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("PGP download encryption requires a public key"));
|
||||
}
|
||||
for(String limit : List.of("maxConcurrentSessions", "maxOperationsPerMinute", "maxFileSizeMb", "maxUploadMbPerDay", "maxDownloadMbPerDay"))
|
||||
{
|
||||
if(record.getValueInteger(limit) != null && record.getValueInteger(limit) < 1)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(limit + " must be positive, or blank for an unlimited daily quota"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.filegateway.metadata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
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.statusmessages.BadInputStatusMessage;
|
||||
|
||||
/*******************************************************************************
|
||||
** Endpoints are canonical mounts, never a way to shadow application paths.
|
||||
*******************************************************************************/
|
||||
public class AliasCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate the merged row so sparse updates cannot bypass mount validation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
AuditWriter audit = new AuditWriter(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
oldRecords.orElse(List.of()).stream().filter(old -> java.util.Objects.equals(old.getValue("id"), record.getValue("id")))
|
||||
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
|
||||
merged.getValues().putAll(record.getValues());
|
||||
String path = merged.getValueString("path");
|
||||
String error = null;
|
||||
if(path == null || !path.matches("/[a-z0-9._/-]+") || path.contains("..") || path.contains("//") || path.endsWith("/") || List.of(path.split("/")).contains("."))
|
||||
{
|
||||
error = "Endpoint path must be absolute, use lowercase letters, digits, dots, underscores, hyphens and slashes, and contain no dot segments or empty segments";
|
||||
}
|
||||
else
|
||||
{
|
||||
boolean fullTree = merged.getValue("accountId") == null;
|
||||
if(!fullTree)
|
||||
{
|
||||
var accounts = audit.query(FileGatewayTables.ACCOUNT, UploadJobs.equal("id", merged.getValue("accountId")));
|
||||
fullTree = accounts.isEmpty() || !"ENDPOINTS_ONLY".equals(accounts.getFirst().getValueString("rootMode"));
|
||||
}
|
||||
if(fullTree && QContext.getQInstance().getApps().containsKey(path.substring(1).split("/")[0]))
|
||||
{
|
||||
error = "Endpoint path collides with an application name for a FULL_TREE account";
|
||||
}
|
||||
for(QRecord other : audit.query(FileGatewayTables.ALIAS, new QQueryFilter()))
|
||||
{
|
||||
String mount = other.getValueString("path");
|
||||
if(!java.util.Objects.equals(other.getValue("id"), merged.getValue("id")) && mount != null
|
||||
&& (path.equals(mount) || path.startsWith(mount + "/") || mount.startsWith(path + "/")))
|
||||
{
|
||||
error = "Endpoint paths must be unique and must not overlap another endpoint mount";
|
||||
}
|
||||
}
|
||||
}
|
||||
String filename = merged.getValueString("fileName");
|
||||
if(filename != null && !validFileName(filename))
|
||||
{
|
||||
error = "Endpoint file name must be a single safe filename";
|
||||
}
|
||||
if(error != null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(error));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Object names cannot escape a mount or inject control characters.
|
||||
*******************************************************************************/
|
||||
public static boolean validFileName(String name)
|
||||
{
|
||||
return (name != null && !name.isBlank() && !name.equals(".") && !name.equals("..") && !name.contains("/")
|
||||
&& !name.contains("\\") && !name.contains(":") && name.chars().noneMatch(Character::isISOControl));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
/*
|
||||
* 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.filegateway.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.PossibleValueEnum;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable persisted values for the gateway schema.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayEnums
|
||||
{
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Namespace for schema values.
|
||||
*******************************************************************************/
|
||||
private FileGatewayEnums()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AccountType values.
|
||||
*******************************************************************************/
|
||||
public enum AccountType implements PossibleValueEnum<String>
|
||||
{
|
||||
USER("USER"), SERVICE("SERVICE");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
AccountType(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AccountStatus values.
|
||||
*******************************************************************************/
|
||||
public enum AccountStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
ACTIVE("ACTIVE"), DISABLED("DISABLED"), LOCKED("LOCKED");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
AccountStatus(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** RootMode values.
|
||||
*******************************************************************************/
|
||||
public enum RootMode implements PossibleValueEnum<String>
|
||||
{
|
||||
FULL_TREE("FULL_TREE"), ENDPOINTS_ONLY("ENDPOINTS_ONLY");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
RootMode(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AliasKind values.
|
||||
*******************************************************************************/
|
||||
public enum AliasKind implements PossibleValueEnum<String>
|
||||
{
|
||||
BULK_LOAD("BULK_LOAD"), INBOX("INBOX"), VIEW("VIEW"), REPORT("REPORT"), OUTBOX("OUTBOX");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
AliasKind(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UploadStatus values.
|
||||
*******************************************************************************/
|
||||
public enum UploadStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
RECEIVING("RECEIVING"), QUEUED("QUEUED"), RUNNING("RUNNING"), SUCCEEDED("SUCCEEDED"), PARTIAL("PARTIAL"), FAILED("FAILED"), REJECTED("REJECTED"), DUPLICATE("DUPLICATE"), ABORTED("ABORTED");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
UploadStatus(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OutboxStatus values.
|
||||
*******************************************************************************/
|
||||
public enum OutboxStatus implements PossibleValueEnum<String>
|
||||
{
|
||||
READY("READY"), DOWNLOADED("DOWNLOADED"), REMOVED("REMOVED"), EXPIRED("EXPIRED");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
OutboxStatus(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation values.
|
||||
*******************************************************************************/
|
||||
public enum Operation implements PossibleValueEnum<String>
|
||||
{
|
||||
LIST("LIST"), UPLOAD("UPLOAD"), DOWNLOAD("DOWNLOAD"), RENAME("RENAME"), REMOVE("REMOVE");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
Operation(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ObjectKind values.
|
||||
*******************************************************************************/
|
||||
public enum ObjectKind implements PossibleValueEnum<String>
|
||||
{
|
||||
APP("APP"), TABLE("TABLE"), PROFILE("PROFILE"), VIEW("VIEW"), REPORT("REPORT"), RESULT("RESULT"), ARCHIVE("ARCHIVE"), ENDPOINT("ENDPOINT"), OUTBOX("OUTBOX");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
ObjectKind(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TransferOutcome values.
|
||||
*******************************************************************************/
|
||||
public enum TransferOutcome implements PossibleValueEnum<String>
|
||||
{
|
||||
OK("OK"), DENIED("DENIED"), NOT_FOUND("NOT_FOUND"), LIMIT("LIMIT"), ERROR("ERROR");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
TransferOutcome(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ConnectionOutcome values.
|
||||
*******************************************************************************/
|
||||
public enum ConnectionOutcome implements PossibleValueEnum<String>
|
||||
{
|
||||
CONNECTED("CONNECTED"), AUTH_FAILED("AUTH_FAILED"), LOCKED_OUT("LOCKED_OUT"), IP_DENIED("IP_DENIED"), EXPIRED("EXPIRED"), DISABLED("DISABLED"), LIMIT("LIMIT");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
ConnectionOutcome(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Format values.
|
||||
*******************************************************************************/
|
||||
public enum Format implements PossibleValueEnum<String>
|
||||
{
|
||||
CSV("csv"), XLSX("xlsx"), JSON("json");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
Format(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** KeyType values.
|
||||
*******************************************************************************/
|
||||
public enum KeyType implements PossibleValueEnum<String>
|
||||
{
|
||||
SSH_ED25519("ssh-ed25519"), ECDSA_SHA2_NISTP256("ecdsa-sha2-nistp256"), ECDSA_SHA2_NISTP384("ecdsa-sha2-nistp384"), ECDSA_SHA2_NISTP521("ecdsa-sha2-nistp521"), SSH_RSA("ssh-rsa");
|
||||
|
||||
private final String value;
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a persisted value.
|
||||
*******************************************************************************/
|
||||
KeyType(String value)
|
||||
{
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueId()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible value label.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getPossibleValueLabel()
|
||||
{
|
||||
return (label(value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain label for a persisted value: ENDPOINTS_ONLY becomes "Endpoints only".
|
||||
*******************************************************************************/
|
||||
static String label(String value)
|
||||
{
|
||||
String words = value.replace('_', ' ').toLowerCase();
|
||||
return (Character.toUpperCase(words.charAt(0)) + words.substring(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* 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.filegateway.metadata;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
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.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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
|
||||
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.dashboard.QWidgetMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed home widgets over real gateway data, queried as the viewing user.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayHome extends AbstractWidgetRenderer
|
||||
{
|
||||
/** Readable UTC timestamps in home tables (the widget title already names the table). */
|
||||
private static final java.time.format.DateTimeFormatter WHEN = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
|
||||
|
||||
public static final List<String> KEYS = List.of("transfers", "failures", "sessions", "in", "out", "locked", "recent", "partners", "active", "servers");
|
||||
private static final List<String> LABELS = List.of("Transfers today", "Failed jobs (24 hours)", "Active sessions", "MB in today", "MB out today", "Locked accounts",
|
||||
"Recent failures", "Top partners (7 days)", "Active sessions", "Server");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Six small statistics followed by full-width responsive tables, without HTML.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
for(int i = 0; i < KEYS.size(); i++)
|
||||
{
|
||||
widgets.add(new QWidgetMetaData().withName("file_gateway_home_" + KEYS.get(i)).withLabel(LABELS.get(i))
|
||||
.withType((i < 6 ? WidgetType.STATISTICS : WidgetType.TABLE).getType()).withGridColumns(i < 6 ? 2 : 12)
|
||||
.withIsCard(true).withShowReloadButton(true).withCodeReference(new QCodeReference(FileGatewayHome.class))
|
||||
.withPermissionRules(FileGatewayTables.permissionRules("file_gateway")));
|
||||
}
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same permission is required for direct renderer calls and widget routes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(data(input.getWidgetMetaData().getName().replace("file_gateway_home_", ""), Instant.now())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept an explicit time for deterministic seeded-data assertions.
|
||||
*******************************************************************************/
|
||||
public QWidgetData data(String key, Instant now) throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null || !(QContext.getQSession().hasPermission(FileGatewayPermissions.VIEW) || QContext.getQSession().hasPermission(FileGatewayPermissions.ADMIN)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Missing file_gateway.view"));
|
||||
}
|
||||
Instant today = now.atZone(ZoneOffset.UTC).toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
if(List.of("transfers", "in", "out").contains(key))
|
||||
{
|
||||
var rows = query(FileGatewayTables.TRANSFER_LOG, since("createDate", today));
|
||||
long value = "transfers".equals(key) ? rows.size() : rows.stream()
|
||||
.filter(row -> ("in".equals(key) ? "UPLOAD" : "DOWNLOAD").equals(row.getValueString("operation")))
|
||||
.mapToLong(row -> number(row, "bytes")).sum();
|
||||
return (new StatisticsData("transfers".equals(key) ? value : Math.round(value / 10485.76) / 100.0, null, null).withIncreaseIsGood(true));
|
||||
}
|
||||
if("locked".equals(key))
|
||||
{
|
||||
long count = query(FileGatewayTables.ACCOUNT, new QQueryFilter()).stream().filter(row -> "LOCKED".equals(row.getValueString("status"))
|
||||
|| (row.getValueInstant("lockedUntil") != null && row.getValueInstant("lockedUntil").isAfter(now))).count();
|
||||
return (new StatisticsData(count, null, null).withIncreaseIsGood(false));
|
||||
}
|
||||
if(List.of("failures", "recent").contains(key))
|
||||
{
|
||||
var rows = query(FileGatewayTables.UPLOAD_JOB, since("finishedAt", now.minusSeconds("failures".equals(key) ? 86400 : 7 * 86400))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.IN, "FAILED", "PARTIAL", "REJECTED")));
|
||||
if("failures".equals(key))
|
||||
{
|
||||
return (new StatisticsData(rows.size(), null, null).withIncreaseIsGood(false));
|
||||
}
|
||||
rows.sort(java.util.Comparator.comparing((QRecord row) -> row.getValueInstant("finishedAt")).reversed());
|
||||
return (table("Recent failures", FileGatewayTables.UPLOAD_JOB, rows.stream().limit(20).toList(),
|
||||
List.of("fileName", "status", "errorMessage", "finishedAt"), List.of("File", "Status", "Message", "Finished"), "No failed transfers in the last 7 days."));
|
||||
}
|
||||
if(List.of("sessions", "active").contains(key))
|
||||
{
|
||||
var rows = query(FileGatewayTables.CONNECTION_LOG, since("lastSeenAt", now.minusSeconds(180))
|
||||
.withCriteria(new QFilterCriteria("disconnectedAt", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "CONNECTED")));
|
||||
return ("sessions".equals(key) ? new StatisticsData(rows.size(), null, null).withIncreaseIsGood(true) : table("Active sessions", FileGatewayTables.CONNECTION_LOG, rows,
|
||||
List.of("username", "remoteIp", "connectedAt", "operationCount"), List.of("Username", "IP", "Connected", "Operations"),
|
||||
"No one is connected right now (no session has been seen in the last 3 minutes)."));
|
||||
}
|
||||
if("servers".equals(key))
|
||||
{
|
||||
return (table("Server", FileGatewayTables.SERVER, query(FileGatewayTables.SERVER, since("lastSeenAt", now.minusSeconds(180))),
|
||||
List.of("podName", "hostKeyFingerprint", "port", "lastSeenAt"), List.of("Pod", "Host key fingerprint", "Port", "Last seen"),
|
||||
"No gateway server has reported in during the last 3 minutes."));
|
||||
}
|
||||
Map<Integer, QRecord> partners = new LinkedHashMap<>();
|
||||
for(QRecord account : query(FileGatewayTables.ACCOUNT, new QQueryFilter()))
|
||||
{
|
||||
partners.put(account.getValueInteger("id"), account.withValue("transfers", 0L).withValue("mb", 0.0));
|
||||
}
|
||||
for(QRecord transfer : query(FileGatewayTables.TRANSFER_LOG, since("createDate", now.minusSeconds(7 * 86400))))
|
||||
{
|
||||
QRecord partner = partners.get(transfer.getValueInteger("accountId"));
|
||||
if(partner != null)
|
||||
{
|
||||
partner.withValue("transfers", number(partner, "transfers") + 1)
|
||||
.withValue("mb", ((Number) partner.getValue("mb")).doubleValue() + number(transfer, "bytes") / 1048576.0);
|
||||
}
|
||||
}
|
||||
List<QRecord> rows = partners.values().stream().filter(row -> number(row, "transfers") > 0)
|
||||
.sorted(java.util.Comparator.comparingLong((QRecord row) -> number(row, "transfers")).reversed()).limit(10).toList();
|
||||
return (table("Top partners (7 days)", FileGatewayTables.ACCOUNT, rows, List.of("username", "transfers", "mb"), List.of("Partner", "Transfers", "MB"),
|
||||
"No partner has transferred a file in the last 7 days."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed text blocks carry record links; cells never contain markup.
|
||||
*******************************************************************************/
|
||||
private TableData table(String label, String table, List<QRecord> records, List<String> fields, List<String> labels, String noRowsText)
|
||||
{
|
||||
List<TableData.Column> columns = new ArrayList<>();
|
||||
for(int i = 0; i < fields.size(); i++)
|
||||
{
|
||||
columns.add(new TableData.Column(i == 0 ? "block" : "default", labels.get(i), fields.get(i), null, null));
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for(String field : fields)
|
||||
{
|
||||
Object value = record.getValue(field);
|
||||
row.put(field, value == null ? "" : value instanceof Instant instant ? WHEN.format(instant) : value);
|
||||
}
|
||||
row.put(fields.getFirst(), new TextBlockData().withValues(new TextValues(record.getValueString(fields.getFirst())))
|
||||
.withLink("/record/" + table + "/" + record.getValue("id")));
|
||||
rows.add(row);
|
||||
}
|
||||
return (new TableData(null, columns, rows).withRowsPerPage(10).withHidePaginationDropdown(true).withNoRowsFoundHTML(noRowsText));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queries preserve viewer security and use bounded database pages.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
for(int offset = 0; ; offset += 250)
|
||||
{
|
||||
var page = new QueryAction().execute(new QueryInput(table).withFilter(filter.withSkip(offset).withLimit(250).withOrderBy(new QFilterOrderBy("id", true)))).getRecords();
|
||||
rows.addAll(page);
|
||||
if(page.size() < 250)
|
||||
{
|
||||
return (rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UTC time windows agree with quota and transfer bookkeeping.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter since(String field, Instant from)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.GREATER_THAN_OR_EQUALS, from)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Older audit rows may have no counters.
|
||||
*******************************************************************************/
|
||||
private long number(QRecord row, String field)
|
||||
{
|
||||
return (row.getValueLong(field) == null ? 0 : row.getValueLong(field));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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.filegateway.metadata;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
|
||||
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.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.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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative record actions use the standard streamed Review and Result flow.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayProcesses
|
||||
{
|
||||
public static final String RETRY_UPLOAD = "file_gateway_retry_upload";
|
||||
public static final String UNLOCK = "file_gateway_unlock_account";
|
||||
public static final String DISABLE = "file_gateway_disable_account";
|
||||
public static final String REVOKE = "file_gateway_revoke_key";
|
||||
public static final String END_SESSION = "file_gateway_end_session";
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata factory only.
|
||||
*******************************************************************************/
|
||||
private FileGatewayProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative actions bound to their owning tables.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
return (List.of(action(UNLOCK, "Unlock account", FileGatewayTables.ACCOUNT),
|
||||
action(DISABLE, "Disable account", FileGatewayTables.ACCOUNT),
|
||||
action(REVOKE, "Revoke key", FileGatewayTables.SSH_KEY),
|
||||
action(END_SESSION, "End session", FileGatewayTables.CONNECTION_LOG),
|
||||
action(RETRY_UPLOAD, "Retry upload", FileGatewayTables.UPLOAD_JOB)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table-bound action, never a navigation entry.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData action(String name, String label, String table)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected records.");
|
||||
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
|
||||
.withName(name).withLabel(label).withTableName(table).withPermissionRules(FileGatewayTables.permissionRules(name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct step invocation must require the same permission as the process route.
|
||||
*******************************************************************************/
|
||||
private static void requireAdmin() throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(FileGatewayPermissions.ADMIN))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Missing file_gateway.admin"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview selections without changing records.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine summary = new ProcessSummaryLine(Status.OK, 0, null);
|
||||
private final ProcessSummaryLine skipped = new ProcessSummaryLine(Status.INFO, 0, "uploads are not eligible for retry");
|
||||
|
||||
/*******************************************************************************
|
||||
** Review uses the framework's future and past tense summary lines.
|
||||
*******************************************************************************/
|
||||
public Review()
|
||||
{
|
||||
summary.setSingularFutureMessage("record will be updated");
|
||||
summary.setPluralFutureMessage("records will be updated");
|
||||
summary.setSingularPastMessage("record was updated");
|
||||
summary.setPluralPastMessage("records were updated");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pass only authorized selections to the load phase.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
requireAdmin();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
if(RETRY_UPLOAD.equals(input.getProcessName()) && (!List.of("FAILED", "REJECTED").contains(record.getValueString("status"))
|
||||
|| record.getValueString("storageKey") == null || record.getValueString("sha256") == null))
|
||||
{
|
||||
skipped.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
continue;
|
||||
}
|
||||
summary.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Review and Result summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean result)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>(List.of(summary));
|
||||
skipped.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the approved action to the gateway's own bookkeeping tables.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Whitelist mutations, using sparse updates so concurrent account changes survive.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
requireAdmin();
|
||||
AuditWriter audit = new AuditWriter(QContext.getQInstance());
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
if(RETRY_UPLOAD.equals(input.getProcessName()))
|
||||
{
|
||||
audit.withAccountLock("id", record.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord current = audit.query(FileGatewayTables.UPLOAD_JOB, UploadJobs.equal("id", record.getValue("id"))).getFirst();
|
||||
if(!List.of("FAILED", "REJECTED").contains(current.getValueString("status"))
|
||||
|| current.getValueString("storageKey") == null || current.getValueString("sha256") == null)
|
||||
{
|
||||
throw (new QException("Only failed or rejected stored uploads can be retried"));
|
||||
}
|
||||
QRecord retry = new QRecord().withValue("id", record.getValue("id"))
|
||||
.withValue("status", "QUEUED").withValue("attempts", 0).withValue("nextAttemptAt", null)
|
||||
.withValue("claimedBy", null).withValue("claimedAt", null).withValue("finishedAt", null)
|
||||
.withValue("acknowledgedAt", null).withValue("errorMessage", null).withValue("resultSummary", null)
|
||||
.withValue("resultsStorageKey", null).withValue("resultsSizeBytes", null).withValue("processUuid", null)
|
||||
.withValue("insertedCount", null).withValue("errorCount", null).withValue("queuedAt", Instant.now());
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, retry);
|
||||
output.getRecords().add(retry);
|
||||
return (null);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", record.getValue("id"));
|
||||
String table;
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case UNLOCK ->
|
||||
{
|
||||
table = FileGatewayTables.ACCOUNT;
|
||||
update.withValue("lockedUntil", null).withValue("failedLoginCount", 0).withValue("failedLoginWindowAt", null);
|
||||
if("LOCKED".equals(record.getValueString("status")))
|
||||
{
|
||||
update.withValue("status", "ACTIVE");
|
||||
}
|
||||
}
|
||||
case DISABLE ->
|
||||
{
|
||||
table = FileGatewayTables.ACCOUNT;
|
||||
update.withValue("status", "DISABLED");
|
||||
}
|
||||
case REVOKE ->
|
||||
{
|
||||
table = FileGatewayTables.SSH_KEY;
|
||||
update.withValue("revokedAt", Instant.now());
|
||||
}
|
||||
case END_SESSION ->
|
||||
{
|
||||
table = FileGatewayTables.CONNECTION_LOG;
|
||||
update.withValue("endRequestedAt", Instant.now()).withValue("disconnectReason", "Ended by administrator");
|
||||
}
|
||||
default -> throw (new QException("Unsupported gateway action"));
|
||||
}
|
||||
audit.update(table, update);
|
||||
output.getRecords().add(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/*
|
||||
* 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.filegateway.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
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.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
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.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
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.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seven gateway tables, their stable columns, pickers and child lists.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayTables
|
||||
{
|
||||
public static final String ACCOUNT = "file_gateway_account";
|
||||
public static final String SSH_KEY = "file_gateway_ssh_key";
|
||||
public static final String ALIAS = "file_gateway_alias";
|
||||
public static final String UPLOAD_JOB = "file_gateway_upload_job";
|
||||
public static final String OUTBOX_FILE = "file_gateway_outbox_file";
|
||||
public static final String TRANSFER_LOG = "file_gateway_transfer_log";
|
||||
public static final String CONNECTION_LOG = "file_gateway_connection_log";
|
||||
public static final String SERVER = "file_gateway_server";
|
||||
public static final List<String> ALL = List.of(ACCOUNT, SSH_KEY, ALIAS, UPLOAD_JOB, OUTBOX_FILE, TRANSFER_LOG, CONNECTION_LOG, SERVER);
|
||||
public static final List<String> MAIN = List.of(ACCOUNT, ALIAS, UPLOAD_JOB, TRANSFER_LOG, CONNECTION_LOG);
|
||||
public static final List<String> SYSTEM_WRITTEN = List.of(UPLOAD_JOB, OUTBOX_FILE, TRANSFER_LOG, CONNECTION_LOG, SERVER);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata factories only.
|
||||
*******************************************************************************/
|
||||
private FileGatewayTables()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission names use the full table or app name.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules permissionRules(String name)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.READ_WRITE_PERMISSIONS).withPermissionBaseName(name)
|
||||
.withCustomPermissionChecker(new QCodeReference(FileGatewayPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enums and gateway record pickers.
|
||||
*******************************************************************************/
|
||||
public static List<QPossibleValueSource> possibleValueSources()
|
||||
{
|
||||
List<QPossibleValueSource> sources = new ArrayList<>();
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_account_type", FileGatewayEnums.AccountType.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_account_status", FileGatewayEnums.AccountStatus.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_root_mode", FileGatewayEnums.RootMode.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_alias_kind", FileGatewayEnums.AliasKind.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_upload_status", FileGatewayEnums.UploadStatus.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_outbox_status", FileGatewayEnums.OutboxStatus.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_operation", FileGatewayEnums.Operation.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_object_kind", FileGatewayEnums.ObjectKind.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_transfer_outcome", FileGatewayEnums.TransferOutcome.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_connection_outcome", FileGatewayEnums.ConnectionOutcome.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_format", FileGatewayEnums.Format.values()));
|
||||
sources.add(QPossibleValueSource.newForEnum("file_gateway_key_type", FileGatewayEnums.KeyType.values()));
|
||||
ALL.forEach(name -> sources.add(QPossibleValueSource.newForTable(name)));
|
||||
return (sources);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Plain labels for child lists and their record-screen tabs. */
|
||||
static final java.util.Map<String, String> CHILD_LABELS = java.util.Map.of(
|
||||
SSH_KEY, "SSH keys", UPLOAD_JOB, "Upload jobs", CONNECTION_LOG, "Connections",
|
||||
TRANSFER_LOG, "Transfers", OUTBOX_FILE, "Outbox files");
|
||||
|
||||
/** Plain field labels where the name-derived label reads badly. */
|
||||
static final java.util.Map<String, String> FIELD_LABELS = java.util.Map.ofEntries(
|
||||
java.util.Map.entry("allowedCidrs", "Allowed CIDRs"), java.util.Map.entry("userId", "User"),
|
||||
java.util.Map.entry("ownerUserId", "Owner"), java.util.Map.entry("maxFileSizeMb", "Max file size (MB)"),
|
||||
java.util.Map.entry("maxUploadMbPerDay", "Max upload per day (MB)"), java.util.Map.entry("maxDownloadMbPerDay", "Max download per day (MB)"),
|
||||
java.util.Map.entry("lastLoginIp", "Last login IP"), java.util.Map.entry("lastUsedIp", "Last used IP"),
|
||||
java.util.Map.entry("pgpPublicKey", "PGP public key"), java.util.Map.entry("pgpDecryptUploads", "Decrypt PGP uploads"),
|
||||
java.util.Map.entry("pgpEncryptDownloads", "Encrypt downloads with PGP"), java.util.Map.entry("remoteIp", "Remote IP"),
|
||||
java.util.Map.entry("remoteAddress", "Remote IP"), java.util.Map.entry("sha256", "SHA-256"),
|
||||
java.util.Map.entry("processUuid", "Process UUID"), java.util.Map.entry("savedBulkLoadProfileId", "Bulk load profile"),
|
||||
java.util.Map.entry("savedViewId", "Saved view"), java.util.Map.entry("savedReportId", "Saved report"),
|
||||
java.util.Map.entry("sizeBytes", "Size (bytes)"), java.util.Map.entry("accountId", "Account"),
|
||||
java.util.Map.entry("aliasId", "Endpoint"), java.util.Map.entry("connectionLogId", "Connection"),
|
||||
java.util.Map.entry("hostKeyFingerprint", "Host key fingerprint"), java.util.Map.entry("keyFingerprint", "Key fingerprint"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build all seven tables.
|
||||
*******************************************************************************/
|
||||
public static List<QTableMetaData> tables(FileGatewayQBitConfig config)
|
||||
{
|
||||
List<QTableMetaData> tables = List.of(account(config), ssh_key(config), alias(config), upload_job(config), outbox_file(config), transfer_log(config), connection_log(config), server(config));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(FIELD_LABELS.containsKey(field.getName()))
|
||||
{
|
||||
field.setLabel(FIELD_LABELS.get(field.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Common identity and timestamps.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData base(FileGatewayQBitConfig config, String name, String label)
|
||||
{
|
||||
return (new QTableMetaData().withName(name).withLabel(label).withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(permissionRules(name))
|
||||
.withField(field("id", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(field("createDate", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("modifyDate", QFieldType.DATE_TIME).withIsEditable(false)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the host backend naming convention explicitly.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withBackendName(QInstanceEnricher.inferBackendName(name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for accounts.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData account(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ACCOUNT, "Accounts")
|
||||
.withRecordLabelFormatAndFields("%s", "username")
|
||||
.withField(field("username", QFieldType.STRING).withIsRequired(true))
|
||||
.withField(field("label", QFieldType.STRING))
|
||||
.withField(field("accountType", QFieldType.STRING).withPossibleValueSourceName("file_gateway_account_type").withDefaultValue("USER").withIsRequired(true))
|
||||
.withField(field("userId", QFieldType.STRING).withIsRequired(true))
|
||||
.withField(field("ownerUserId", QFieldType.STRING))
|
||||
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("file_gateway_account_status").withDefaultValue("ACTIVE").withIsRequired(true))
|
||||
.withField(field("rootMode", QFieldType.STRING).withPossibleValueSourceName("file_gateway_root_mode"))
|
||||
.withField(field("allowedCidrs", QFieldType.TEXT))
|
||||
.withField(field("expiresAt", QFieldType.DATE_TIME))
|
||||
.withField(field("formats", QFieldType.STRING).withDefaultValue("csv,xlsx"))
|
||||
.withField(field("maxConcurrentSessions", QFieldType.INTEGER).withDefaultValue(config.getDefaultMaxConcurrentSessions()))
|
||||
.withField(field("maxOperationsPerMinute", QFieldType.INTEGER).withDefaultValue(config.getDefaultMaxOperationsPerMinute()))
|
||||
.withField(field("maxFileSizeMb", QFieldType.INTEGER).withDefaultValue(config.getDefaultMaxFileSizeMb()))
|
||||
.withField(field("maxUploadMbPerDay", QFieldType.INTEGER).withDefaultValue(config.getDefaultMaxUploadMbPerDay()))
|
||||
.withField(field("maxDownloadMbPerDay", QFieldType.INTEGER).withDefaultValue(config.getDefaultMaxDownloadMbPerDay()))
|
||||
.withField(field("dedupeUploads", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("notifyOnSuccess", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("pgpPublicKey", QFieldType.TEXT))
|
||||
.withField(field("pgpDecryptUploads", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("pgpEncryptDownloads", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("operationWindowAt", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("operationWindowCount", QFieldType.INTEGER).withDefaultValue(0).withIsEditable(false))
|
||||
.withField(field("failedLoginCount", QFieldType.INTEGER).withDefaultValue(0).withIsEditable(false))
|
||||
.withField(field("failedLoginWindowAt", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("lockedUntil", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("lastLoginAt", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("lastLoginIp", QFieldType.STRING).withIsEditable(false));
|
||||
table.withUniqueKey(new UniqueKey("username"));
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(AccountCustomizer.class));
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(AccountCustomizer.class));
|
||||
table.withSection(SectionFactory.defaultT1("id", "username", "label", "accountType", "userId", "ownerUserId"));
|
||||
table.withSection(SectionFactory.defaultT2("status", "rootMode", "allowedCidrs", "expiresAt", "formats", "maxConcurrentSessions", "maxOperationsPerMinute", "maxFileSizeMb", "maxUploadMbPerDay", "maxDownloadMbPerDay", "dedupeUploads", "notifyOnSuccess", "pgpPublicKey", "pgpDecryptUploads", "pgpEncryptDownloads", "operationWindowAt", "operationWindowCount", "failedLoginCount", "failedLoginWindowAt", "lockedUntil", "lastLoginAt", "lastLoginIp"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2(SSH_KEY, new QIcon("list")).withLabel(CHILD_LABELS.get(SSH_KEY)).withWidgetName(ACCOUNT + "_children_" + SSH_KEY));
|
||||
table.withSection(SectionFactory.customT2(UPLOAD_JOB, new QIcon("list")).withLabel(CHILD_LABELS.get(UPLOAD_JOB)).withWidgetName(ACCOUNT + "_children_" + UPLOAD_JOB));
|
||||
table.withSection(SectionFactory.customT2(CONNECTION_LOG, new QIcon("list")).withLabel(CHILD_LABELS.get(CONNECTION_LOG)).withWidgetName(ACCOUNT + "_children_" + CONNECTION_LOG));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for ssh keys.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData ssh_key(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, SSH_KEY, "SSH Keys")
|
||||
.withRecordLabelFormatAndFields("%s", "label")
|
||||
.withField(field("accountId", QFieldType.INTEGER).withPossibleValueSourceName(ACCOUNT).withIsRequired(true))
|
||||
.withField(field("label", QFieldType.STRING))
|
||||
.withField(field("publicKey", QFieldType.TEXT).withIsRequired(true))
|
||||
.withField(field("fingerprint", QFieldType.STRING).withIsEditable(false))
|
||||
.withField(field("keyType", QFieldType.STRING).withPossibleValueSourceName("file_gateway_key_type").withIsEditable(false))
|
||||
.withField(field("keyBits", QFieldType.INTEGER).withIsEditable(false))
|
||||
.withField(field("expiresAt", QFieldType.DATE_TIME))
|
||||
.withField(field("revokedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("lastUsedAt", QFieldType.DATE_TIME).withIsEditable(false))
|
||||
.withField(field("lastUsedIp", QFieldType.STRING).withIsEditable(false));
|
||||
table.withUniqueKey(new UniqueKey("fingerprint"));
|
||||
table.withSection(SectionFactory.defaultT1("id", "accountId", "label", "publicKey", "fingerprint", "keyType"));
|
||||
table.withSection(SectionFactory.defaultT2("keyBits", "expiresAt", "revokedAt", "lastUsedAt", "lastUsedIp"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(SshKeyCustomizer.class));
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(SshKeyCustomizer.class));
|
||||
table.getField("publicKey").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.CONFIDENTIAL));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for endpoints.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData alias(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, ALIAS, "Endpoints")
|
||||
.withRecordLabelFormatAndFields("%s", "name")
|
||||
.withField(field("name", QFieldType.STRING))
|
||||
.withField(field("path", QFieldType.STRING))
|
||||
.withField(field("kind", QFieldType.STRING).withPossibleValueSourceName("file_gateway_alias_kind"))
|
||||
.withField(field("accountId", QFieldType.INTEGER).withPossibleValueSourceName(ACCOUNT))
|
||||
.withField(field("tableName", QFieldType.STRING).withPossibleValueSourceName("tables"))
|
||||
.withField(field("savedBulkLoadProfileId", QFieldType.INTEGER).withPossibleValueSourceName("savedBulkLoadProfile").withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, "${input.tableName}"))))
|
||||
.withField(field("savedViewId", QFieldType.INTEGER).withPossibleValueSourceName("savedView").withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, "${input.tableName}"))))
|
||||
.withField(field("savedReportId", QFieldType.INTEGER).withPossibleValueSourceName("savedReport").withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, "${input.tableName}"))))
|
||||
.withField(field("format", QFieldType.STRING).withPossibleValueSourceName("file_gateway_format"))
|
||||
.withField(field("fileName", QFieldType.STRING))
|
||||
.withField(field("consumerName", QFieldType.STRING))
|
||||
.withField(field("isActive", QFieldType.BOOLEAN).withDefaultValue(true))
|
||||
.withField(field("description", QFieldType.TEXT));
|
||||
table.withUniqueKey(new UniqueKey("name"));
|
||||
table.withUniqueKey(new UniqueKey("path"));
|
||||
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(AliasCustomizer.class));
|
||||
table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(AliasCustomizer.class));
|
||||
table.withSection(SectionFactory.defaultT1("id", "name", "path", "kind", "accountId", "tableName"));
|
||||
table.withSection(SectionFactory.defaultT2("savedBulkLoadProfileId", "savedViewId", "savedReportId", "format", "fileName", "consumerName", "isActive", "description"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2(UPLOAD_JOB, new QIcon("list")).withLabel(CHILD_LABELS.get(UPLOAD_JOB)).withWidgetName(ALIAS + "_children_" + UPLOAD_JOB));
|
||||
table.withSection(SectionFactory.customT2(OUTBOX_FILE, new QIcon("list")).withLabel(CHILD_LABELS.get(OUTBOX_FILE)).withWidgetName(ALIAS + "_children_" + OUTBOX_FILE));
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for upload jobs.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData upload_job(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, UPLOAD_JOB, "Upload Jobs")
|
||||
.withRecordLabelFormatAndFields("%s", "id")
|
||||
.withField(field("accountId", QFieldType.INTEGER).withPossibleValueSourceName(ACCOUNT))
|
||||
.withField(field("aliasId", QFieldType.INTEGER).withPossibleValueSourceName(ALIAS))
|
||||
.withField(field("connectionLogId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION_LOG))
|
||||
.withField(field("remoteAddress", QFieldType.STRING))
|
||||
.withField(field("tableName", QFieldType.STRING).withPossibleValueSourceName("tables"))
|
||||
.withField(field("savedBulkLoadProfileId", QFieldType.INTEGER).withPossibleValueSourceName("savedBulkLoadProfile"))
|
||||
.withField(field("consumerName", QFieldType.STRING))
|
||||
.withField(field("consumerReference", QFieldType.STRING))
|
||||
.withField(field("path", QFieldType.STRING))
|
||||
.withField(field("fileName", QFieldType.STRING))
|
||||
.withField(field("sizeBytes", QFieldType.LONG))
|
||||
.withField(field("sha256", QFieldType.STRING))
|
||||
.withField(field("storageKey", QFieldType.STRING))
|
||||
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("file_gateway_upload_status"))
|
||||
.withField(field("duplicateOfJobId", QFieldType.INTEGER).withPossibleValueSourceName(UPLOAD_JOB))
|
||||
.withField(field("processUuid", QFieldType.STRING))
|
||||
.withField(field("insertedCount", QFieldType.INTEGER))
|
||||
.withField(field("errorCount", QFieldType.INTEGER))
|
||||
.withField(field("resultSummary", QFieldType.TEXT))
|
||||
.withField(field("resultsStorageKey", QFieldType.STRING))
|
||||
.withField(field("resultsSizeBytes", QFieldType.LONG))
|
||||
.withField(field("plaintextStorageKey", QFieldType.STRING))
|
||||
.withField(field("plaintextSha256", QFieldType.STRING))
|
||||
.withField(field("plaintextSizeBytes", QFieldType.LONG))
|
||||
.withField(field("errorMessage", QFieldType.TEXT))
|
||||
.withField(field("attempts", QFieldType.INTEGER))
|
||||
.withField(field("nextAttemptAt", QFieldType.DATE_TIME))
|
||||
.withField(field("claimedBy", QFieldType.STRING))
|
||||
.withField(field("claimedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("queuedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("startedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("finishedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("acknowledgedAt", QFieldType.DATE_TIME));
|
||||
table.withSection(SectionFactory.defaultT1("id", "accountId", "aliasId", "connectionLogId", "remoteAddress", "tableName"));
|
||||
table.withSection(SectionFactory.defaultT2("savedBulkLoadProfileId", "consumerName", "consumerReference", "path", "fileName", "sizeBytes", "sha256", "storageKey", "status", "duplicateOfJobId", "processUuid", "insertedCount", "errorCount", "resultSummary", "resultsStorageKey", "resultsSizeBytes", "plaintextStorageKey", "plaintextSha256", "plaintextSizeBytes", "errorMessage", "attempts", "nextAttemptAt", "claimedBy", "claimedAt", "queuedAt", "startedAt", "finishedAt", "acknowledgedAt"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withSection(SectionFactory.customT2(TRANSFER_LOG, new QIcon("list")).withLabel(CHILD_LABELS.get(TRANSFER_LOG)).withWidgetName(UPLOAD_JOB + "_children_" + TRANSFER_LOG));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for outbox files.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData outbox_file(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, OUTBOX_FILE, "Outbox Files")
|
||||
.withRecordLabelFormatAndFields("%s", "id")
|
||||
.withField(field("aliasId", QFieldType.INTEGER).withPossibleValueSourceName(ALIAS))
|
||||
.withField(field("fileName", QFieldType.STRING))
|
||||
.withField(field("sizeBytes", QFieldType.LONG))
|
||||
.withField(field("sha256", QFieldType.STRING))
|
||||
.withField(field("storageKey", QFieldType.STRING))
|
||||
.withField(field("status", QFieldType.STRING).withPossibleValueSourceName("file_gateway_outbox_status"))
|
||||
.withField(field("firstDownloadedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("downloadCount", QFieldType.INTEGER))
|
||||
.withField(field("onDownloadedPending", QFieldType.BOOLEAN).withDefaultValue(false))
|
||||
.withField(field("removedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("expiresAt", QFieldType.DATE_TIME))
|
||||
.withField(field("createdByConsumer", QFieldType.STRING));
|
||||
table.withSection(SectionFactory.defaultT1("id", "aliasId", "fileName", "sizeBytes", "sha256", "storageKey"));
|
||||
table.withSection(SectionFactory.defaultT2("status", "firstDownloadedAt", "downloadCount", "onDownloadedPending", "removedAt", "expiresAt", "createdByConsumer"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for transfers.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData transfer_log(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, TRANSFER_LOG, "Transfers")
|
||||
.withRecordLabelFormatAndFields("%s", "id")
|
||||
.withField(field("accountId", QFieldType.INTEGER).withPossibleValueSourceName(ACCOUNT))
|
||||
.withField(field("connectionLogId", QFieldType.INTEGER).withPossibleValueSourceName(CONNECTION_LOG))
|
||||
.withField(field("operation", QFieldType.STRING).withPossibleValueSourceName("file_gateway_operation"))
|
||||
.withField(field("path", QFieldType.STRING))
|
||||
.withField(field("objectKind", QFieldType.STRING).withPossibleValueSourceName("file_gateway_object_kind"))
|
||||
.withField(field("objectId", QFieldType.STRING))
|
||||
.withField(field("format", QFieldType.STRING).withPossibleValueSourceName("file_gateway_format"))
|
||||
.withField(field("bytes", QFieldType.LONG))
|
||||
.withField(field("sha256", QFieldType.STRING))
|
||||
.withField(field("durationMs", QFieldType.LONG))
|
||||
.withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("file_gateway_transfer_outcome"))
|
||||
.withField(field("message", QFieldType.TEXT))
|
||||
.withField(field("clientChannel", QFieldType.STRING))
|
||||
.withField(field("uploadJobId", QFieldType.INTEGER).withPossibleValueSourceName(UPLOAD_JOB));
|
||||
table.withSection(SectionFactory.defaultT1("id", "accountId", "connectionLogId", "operation", "path", "objectKind"));
|
||||
table.withSection(SectionFactory.defaultT2("objectId", "format", "bytes", "sha256", "durationMs", "outcome", "message", "clientChannel", "uploadJobId"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata for connections.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData connection_log(FileGatewayQBitConfig config)
|
||||
{
|
||||
QTableMetaData table = base(config, CONNECTION_LOG, "Connections")
|
||||
.withRecordLabelFormatAndFields("%s", "id")
|
||||
.withField(field("accountId", QFieldType.INTEGER).withPossibleValueSourceName(ACCOUNT))
|
||||
.withField(field("username", QFieldType.STRING))
|
||||
.withField(field("remoteIp", QFieldType.STRING))
|
||||
.withField(field("clientVersion", QFieldType.STRING))
|
||||
.withField(field("keyFingerprint", QFieldType.STRING))
|
||||
.withField(field("outcome", QFieldType.STRING).withPossibleValueSourceName("file_gateway_connection_outcome"))
|
||||
.withField(field("sessionId", QFieldType.STRING))
|
||||
.withField(field("podName", QFieldType.STRING))
|
||||
.withField(field("connectedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("lastSeenAt", QFieldType.DATE_TIME))
|
||||
.withField(field("disconnectedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("endRequestedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("disconnectReason", QFieldType.TEXT))
|
||||
.withField(field("bytesIn", QFieldType.LONG))
|
||||
.withField(field("bytesOut", QFieldType.LONG))
|
||||
.withField(field("operationCount", QFieldType.LONG))
|
||||
.withField(field("clientChannel", QFieldType.STRING));
|
||||
table.withSection(SectionFactory.defaultT1("id", "accountId", "username", "remoteIp", "clientVersion", "keyFingerprint"));
|
||||
table.withSection(SectionFactory.defaultT2("outcome", "sessionId", "podName", "connectedAt", "lastSeenAt", "disconnectedAt", "endRequestedAt", "disconnectReason", "bytesIn", "bytesOut", "operationCount", "clientChannel"));
|
||||
table.withSection(SectionFactory.defaultT3("createDate", "modifyDate"));
|
||||
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
|
||||
return (table);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Gateway heartbeat shared with the main web deployment, never a navigation table.
|
||||
*******************************************************************************/
|
||||
private static QTableMetaData server(FileGatewayQBitConfig config)
|
||||
{
|
||||
return (base(config, SERVER, "Servers").withRecordLabelFormatAndFields("%s", "podName")
|
||||
.withField(field("podName", QFieldType.STRING).withIsRequired(true))
|
||||
.withField(field("hostKeyFingerprint", QFieldType.STRING))
|
||||
.withField(field("port", QFieldType.INTEGER))
|
||||
.withField(field("startedAt", QFieldType.DATE_TIME))
|
||||
.withField(field("lastSeenAt", QFieldType.DATE_TIME))
|
||||
.withUniqueKey(new UniqueKey("podName"))
|
||||
.withSection(SectionFactory.defaultT1("id", "podName", "hostKeyFingerprint", "port", "startedAt", "lastSeenAt"))
|
||||
.withSection(SectionFactory.defaultT3("createDate", "modifyDate"))
|
||||
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parent and child relationships.
|
||||
*******************************************************************************/
|
||||
public static List<QJoinMetaData> joins()
|
||||
{
|
||||
return (List.of(
|
||||
new QJoinMetaData().withName(ACCOUNT + "_join_" + SSH_KEY).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ACCOUNT).withRightTable(SSH_KEY).withJoinOn(new JoinOn("id", "accountId")),
|
||||
new QJoinMetaData().withName(ACCOUNT + "_join_" + UPLOAD_JOB).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ACCOUNT).withRightTable(UPLOAD_JOB).withJoinOn(new JoinOn("id", "accountId")),
|
||||
new QJoinMetaData().withName(ACCOUNT + "_join_" + CONNECTION_LOG).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ACCOUNT).withRightTable(CONNECTION_LOG).withJoinOn(new JoinOn("id", "accountId")),
|
||||
new QJoinMetaData().withName(ALIAS + "_join_" + UPLOAD_JOB).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ALIAS).withRightTable(UPLOAD_JOB).withJoinOn(new JoinOn("id", "aliasId")),
|
||||
new QJoinMetaData().withName(ALIAS + "_join_" + OUTBOX_FILE).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(ALIAS).withRightTable(OUTBOX_FILE).withJoinOn(new JoinOn("id", "aliasId")),
|
||||
new QJoinMetaData().withName(UPLOAD_JOB + "_join_" + TRANSFER_LOG).withType(JoinType.ONE_TO_MANY)
|
||||
.withLeftTable(UPLOAD_JOB).withRightTable(TRANSFER_LOG).withJoinOn(new JoinOn("id", "uploadJobId"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed child-record widgets; no home or HTML widget.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> childWidgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
for(QJoinMetaData join : joins())
|
||||
{
|
||||
widgets.add(ChildRecordListRenderer.widgetMetaDataBuilder(join)
|
||||
.withName(join.getLeftTable() + "_children_" + join.getRightTable()).withLabel(CHILD_LABELS.getOrDefault(join.getRightTable(), join.getRightTable()))
|
||||
.withCanAddChildRecord(SSH_KEY.equals(join.getRightTable())).withMaxRows(100)
|
||||
.getWidgetMetaData().withGridColumns(12).withPermissionRules(permissionRules(join.getRightTable())));
|
||||
}
|
||||
return (widgets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.metadata;
|
||||
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
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.statusmessages.BadInputStatusMessage;
|
||||
import org.apache.sshd.common.config.keys.KeyUtils;
|
||||
import org.apache.sshd.common.config.keys.PublicKeyEntry;
|
||||
import org.apache.sshd.common.digest.BuiltinDigests;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates stored public keys and computes authoritative key metadata.
|
||||
*******************************************************************************/
|
||||
public class SshKeyCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse and validate inserts and partial updates without logging key material.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
Set<String> fingerprints = new HashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord old = oldRecords.orElse(List.of()).stream().filter(candidate -> Objects.equals(candidate.getValue("id"), record.getValue("id"))).findFirst().orElse(null);
|
||||
String text = record.getValues().containsKey("publicKey") ? record.getValueString("publicKey") : old == null ? null : old.getValueString("publicKey");
|
||||
try
|
||||
{
|
||||
PublicKey key = PublicKeyEntry.parsePublicKeyEntry(text).resolvePublicKey(null, Map.of(), null);
|
||||
String type = KeyUtils.getKeyType(key);
|
||||
int bits = KeyUtils.getKeySize(key);
|
||||
if(!("ssh-ed25519".equals(type) || type.startsWith("ecdsa-sha2-nistp") || ("ssh-rsa".equals(type) && bits >= 3072)))
|
||||
{
|
||||
throw (new IllegalArgumentException("Unsupported key type or size"));
|
||||
}
|
||||
record.setValue("fingerprint", KeyUtils.getFingerPrint(BuiltinDigests.sha256, key));
|
||||
record.setValue("keyType", type);
|
||||
record.setValue("keyBits", bits);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Use an OpenSSH Ed25519, ECDSA, or RSA key of at least 3072 bits"));
|
||||
continue;
|
||||
}
|
||||
// Core's unique-key check precedes this computed fingerprint.
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("fingerprint", QCriteriaOperator.EQUALS, record.getValue("fingerprint"));
|
||||
if(old != null)
|
||||
{
|
||||
filter.withCriteria("id", QCriteriaOperator.NOT_EQUALS, old.getValue("id"));
|
||||
}
|
||||
var transaction = input instanceof InsertInput insert ? insert.getTransaction() : ((UpdateInput) input).getTransaction();
|
||||
if(!fingerprints.add(record.getValueString("fingerprint")) || !new QueryAction().execute(new QueryInput(FileGatewayTables.SSH_KEY)
|
||||
.withFilter(filter.withLimit(1)).withTransaction(transaction)).getRecords().isEmpty())
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Fingerprint: this SSH key is already registered. Use a different key."));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,600 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record entity for endpoints.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayAlias extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_alias";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private String name;
|
||||
|
||||
@QField
|
||||
private String path;
|
||||
|
||||
@QField
|
||||
private String kind;
|
||||
|
||||
@QField
|
||||
private Integer accountId;
|
||||
|
||||
@QField
|
||||
private String tableName;
|
||||
|
||||
@QField
|
||||
private Integer savedBulkLoadProfileId;
|
||||
|
||||
@QField
|
||||
private Integer savedViewId;
|
||||
|
||||
@QField
|
||||
private Integer savedReportId;
|
||||
|
||||
@QField
|
||||
private String format;
|
||||
|
||||
@QField
|
||||
private String fileName;
|
||||
|
||||
@QField
|
||||
private String consumerName;
|
||||
|
||||
@QField
|
||||
private Boolean isActive;
|
||||
|
||||
@QField
|
||||
private String description;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an empty entity.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a database record.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name.
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for path.
|
||||
*******************************************************************************/
|
||||
public String getPath()
|
||||
{
|
||||
return (path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for path.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withPath(String path)
|
||||
{
|
||||
this.path = path;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kind.
|
||||
*******************************************************************************/
|
||||
public String getKind()
|
||||
{
|
||||
return (kind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for kind.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for accountId.
|
||||
*******************************************************************************/
|
||||
public Integer getAccountId()
|
||||
{
|
||||
return (accountId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for accountId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName.
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedBulkLoadProfileId.
|
||||
*******************************************************************************/
|
||||
public Integer getSavedBulkLoadProfileId()
|
||||
{
|
||||
return (savedBulkLoadProfileId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedBulkLoadProfileId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withSavedBulkLoadProfileId(Integer savedBulkLoadProfileId)
|
||||
{
|
||||
this.savedBulkLoadProfileId = savedBulkLoadProfileId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedViewId.
|
||||
*******************************************************************************/
|
||||
public Integer getSavedViewId()
|
||||
{
|
||||
return (savedViewId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedViewId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withSavedViewId(Integer savedViewId)
|
||||
{
|
||||
this.savedViewId = savedViewId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for savedReportId.
|
||||
*******************************************************************************/
|
||||
public Integer getSavedReportId()
|
||||
{
|
||||
return (savedReportId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for savedReportId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withSavedReportId(Integer savedReportId)
|
||||
{
|
||||
this.savedReportId = savedReportId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for format.
|
||||
*******************************************************************************/
|
||||
public String getFormat()
|
||||
{
|
||||
return (format);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for format.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withFormat(String format)
|
||||
{
|
||||
this.format = format;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileName.
|
||||
*******************************************************************************/
|
||||
public String getFileName()
|
||||
{
|
||||
return (fileName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for consumerName.
|
||||
*******************************************************************************/
|
||||
public String getConsumerName()
|
||||
{
|
||||
return (consumerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for consumerName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withConsumerName(String consumerName)
|
||||
{
|
||||
this.consumerName = consumerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for isActive.
|
||||
*******************************************************************************/
|
||||
public Boolean getIsActive()
|
||||
{
|
||||
return (isActive);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for isActive.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withIsActive(Boolean isActive)
|
||||
{
|
||||
this.isActive = isActive;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for description.
|
||||
*******************************************************************************/
|
||||
public String getDescription()
|
||||
{
|
||||
return (description);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for description.
|
||||
*******************************************************************************/
|
||||
public FileGatewayAlias withDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setPath(String path)
|
||||
{
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setKind(String kind)
|
||||
{
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSavedBulkLoadProfileId(Integer savedBulkLoadProfileId)
|
||||
{
|
||||
this.savedBulkLoadProfileId = savedBulkLoadProfileId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSavedViewId(Integer savedViewId)
|
||||
{
|
||||
this.savedViewId = savedViewId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSavedReportId(Integer savedReportId)
|
||||
{
|
||||
this.savedReportId = savedReportId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFormat(String format)
|
||||
{
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setConsumerName(String consumerName)
|
||||
{
|
||||
this.consumerName = consumerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setIsActive(Boolean isActive)
|
||||
{
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record entity for connections.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayConnectionLog extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_connection_log";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private Integer accountId;
|
||||
|
||||
@QField
|
||||
private String username;
|
||||
|
||||
@QField
|
||||
private String remoteIp;
|
||||
|
||||
@QField
|
||||
private String clientVersion;
|
||||
|
||||
@QField
|
||||
private String keyFingerprint;
|
||||
|
||||
@QField
|
||||
private String outcome;
|
||||
|
||||
@QField
|
||||
private String sessionId;
|
||||
|
||||
@QField
|
||||
private String podName;
|
||||
|
||||
@QField
|
||||
private Instant connectedAt;
|
||||
|
||||
@QField
|
||||
private Instant lastSeenAt;
|
||||
|
||||
@QField
|
||||
private Instant disconnectedAt;
|
||||
|
||||
@QField
|
||||
private String disconnectReason;
|
||||
|
||||
@QField
|
||||
private Long bytesIn;
|
||||
|
||||
@QField
|
||||
private Long bytesOut;
|
||||
|
||||
@QField
|
||||
private Long operationCount;
|
||||
|
||||
@QField
|
||||
private String clientChannel;
|
||||
|
||||
|
||||
|
||||
@QField
|
||||
private Instant endRequestedAt;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an empty entity.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a database record.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for accountId.
|
||||
*******************************************************************************/
|
||||
public Integer getAccountId()
|
||||
{
|
||||
return (accountId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for accountId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for username.
|
||||
*******************************************************************************/
|
||||
public String getUsername()
|
||||
{
|
||||
return (username);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for username.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for remoteIp.
|
||||
*******************************************************************************/
|
||||
public String getRemoteIp()
|
||||
{
|
||||
return (remoteIp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for remoteIp.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withRemoteIp(String remoteIp)
|
||||
{
|
||||
this.remoteIp = remoteIp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientVersion.
|
||||
*******************************************************************************/
|
||||
public String getClientVersion()
|
||||
{
|
||||
return (clientVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientVersion.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withClientVersion(String clientVersion)
|
||||
{
|
||||
this.clientVersion = clientVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for keyFingerprint.
|
||||
*******************************************************************************/
|
||||
public String getKeyFingerprint()
|
||||
{
|
||||
return (keyFingerprint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for keyFingerprint.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withKeyFingerprint(String keyFingerprint)
|
||||
{
|
||||
this.keyFingerprint = keyFingerprint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outcome.
|
||||
*******************************************************************************/
|
||||
public String getOutcome()
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outcome.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withOutcome(String outcome)
|
||||
{
|
||||
this.outcome = outcome;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sessionId.
|
||||
*******************************************************************************/
|
||||
public String getSessionId()
|
||||
{
|
||||
return (sessionId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sessionId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withSessionId(String sessionId)
|
||||
{
|
||||
this.sessionId = sessionId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for podName.
|
||||
*******************************************************************************/
|
||||
public String getPodName()
|
||||
{
|
||||
return (podName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for podName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withPodName(String podName)
|
||||
{
|
||||
this.podName = podName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for connectedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getConnectedAt()
|
||||
{
|
||||
return (connectedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for connectedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withConnectedAt(Instant connectedAt)
|
||||
{
|
||||
this.connectedAt = connectedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastSeenAt.
|
||||
*******************************************************************************/
|
||||
public Instant getLastSeenAt()
|
||||
{
|
||||
return (lastSeenAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastSeenAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withLastSeenAt(Instant lastSeenAt)
|
||||
{
|
||||
this.lastSeenAt = lastSeenAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for disconnectedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getDisconnectedAt()
|
||||
{
|
||||
return (disconnectedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for disconnectedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withDisconnectedAt(Instant disconnectedAt)
|
||||
{
|
||||
this.disconnectedAt = disconnectedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for disconnectReason.
|
||||
*******************************************************************************/
|
||||
public String getDisconnectReason()
|
||||
{
|
||||
return (disconnectReason);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for disconnectReason.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withDisconnectReason(String disconnectReason)
|
||||
{
|
||||
this.disconnectReason = disconnectReason;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bytesIn.
|
||||
*******************************************************************************/
|
||||
public Long getBytesIn()
|
||||
{
|
||||
return (bytesIn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bytesIn.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withBytesIn(Long bytesIn)
|
||||
{
|
||||
this.bytesIn = bytesIn;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bytesOut.
|
||||
*******************************************************************************/
|
||||
public Long getBytesOut()
|
||||
{
|
||||
return (bytesOut);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bytesOut.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withBytesOut(Long bytesOut)
|
||||
{
|
||||
this.bytesOut = bytesOut;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for operationCount.
|
||||
*******************************************************************************/
|
||||
public Long getOperationCount()
|
||||
{
|
||||
return (operationCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for operationCount.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withOperationCount(Long operationCount)
|
||||
{
|
||||
this.operationCount = operationCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientChannel.
|
||||
*******************************************************************************/
|
||||
public String getClientChannel()
|
||||
{
|
||||
return (clientChannel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientChannel.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setUsername(String username)
|
||||
{
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setRemoteIp(String remoteIp)
|
||||
{
|
||||
this.remoteIp = remoteIp;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setClientVersion(String clientVersion)
|
||||
{
|
||||
this.clientVersion = clientVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setKeyFingerprint(String keyFingerprint)
|
||||
{
|
||||
this.keyFingerprint = keyFingerprint;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setOutcome(String outcome)
|
||||
{
|
||||
this.outcome = outcome;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSessionId(String sessionId)
|
||||
{
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setPodName(String podName)
|
||||
{
|
||||
this.podName = podName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setConnectedAt(Instant connectedAt)
|
||||
{
|
||||
this.connectedAt = connectedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setLastSeenAt(Instant lastSeenAt)
|
||||
{
|
||||
this.lastSeenAt = lastSeenAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setDisconnectedAt(Instant disconnectedAt)
|
||||
{
|
||||
this.disconnectedAt = disconnectedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setDisconnectReason(String disconnectReason)
|
||||
{
|
||||
this.disconnectReason = disconnectReason;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setBytesIn(Long bytesIn)
|
||||
{
|
||||
this.bytesIn = bytesIn;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setBytesOut(Long bytesOut)
|
||||
{
|
||||
this.bytesOut = bytesOut;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setOperationCount(Long operationCount)
|
||||
{
|
||||
this.operationCount = operationCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for endRequestedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getEndRequestedAt()
|
||||
{
|
||||
return (endRequestedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setEndRequestedAt(Instant endRequestedAt)
|
||||
{
|
||||
this.endRequestedAt = endRequestedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for endRequestedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayConnectionLog withEndRequestedAt(Instant endRequestedAt)
|
||||
{
|
||||
this.endRequestedAt = endRequestedAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record entity for outbox files.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayOutboxFile extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_outbox_file";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private Integer aliasId;
|
||||
|
||||
@QField
|
||||
private String fileName;
|
||||
|
||||
@QField
|
||||
private Long sizeBytes;
|
||||
|
||||
@QField
|
||||
private String sha256;
|
||||
|
||||
@QField
|
||||
private String storageKey;
|
||||
|
||||
@QField
|
||||
private String status;
|
||||
|
||||
@QField
|
||||
private Instant firstDownloadedAt;
|
||||
|
||||
@QField
|
||||
private Integer downloadCount;
|
||||
|
||||
@QField
|
||||
private Instant removedAt;
|
||||
|
||||
@QField
|
||||
private Instant expiresAt;
|
||||
|
||||
@QField
|
||||
private String createdByConsumer;
|
||||
|
||||
|
||||
|
||||
@QField
|
||||
private Boolean onDownloadedPending;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an empty entity.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a database record.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for aliasId.
|
||||
*******************************************************************************/
|
||||
public Integer getAliasId()
|
||||
{
|
||||
return (aliasId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for aliasId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withAliasId(Integer aliasId)
|
||||
{
|
||||
this.aliasId = aliasId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fileName.
|
||||
*******************************************************************************/
|
||||
public String getFileName()
|
||||
{
|
||||
return (fileName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fileName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sizeBytes.
|
||||
*******************************************************************************/
|
||||
public Long getSizeBytes()
|
||||
{
|
||||
return (sizeBytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sizeBytes.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withSizeBytes(Long sizeBytes)
|
||||
{
|
||||
this.sizeBytes = sizeBytes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sha256.
|
||||
*******************************************************************************/
|
||||
public String getSha256()
|
||||
{
|
||||
return (sha256);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sha256.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withSha256(String sha256)
|
||||
{
|
||||
this.sha256 = sha256;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for storageKey.
|
||||
*******************************************************************************/
|
||||
public String getStorageKey()
|
||||
{
|
||||
return (storageKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for storageKey.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withStorageKey(String storageKey)
|
||||
{
|
||||
this.storageKey = storageKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status.
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for firstDownloadedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getFirstDownloadedAt()
|
||||
{
|
||||
return (firstDownloadedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for firstDownloadedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withFirstDownloadedAt(Instant firstDownloadedAt)
|
||||
{
|
||||
this.firstDownloadedAt = firstDownloadedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for downloadCount.
|
||||
*******************************************************************************/
|
||||
public Integer getDownloadCount()
|
||||
{
|
||||
return (downloadCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for downloadCount.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withDownloadCount(Integer downloadCount)
|
||||
{
|
||||
this.downloadCount = downloadCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for removedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getRemovedAt()
|
||||
{
|
||||
return (removedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for removedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withRemovedAt(Instant removedAt)
|
||||
{
|
||||
this.removedAt = removedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expiresAt.
|
||||
*******************************************************************************/
|
||||
public Instant getExpiresAt()
|
||||
{
|
||||
return (expiresAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expiresAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createdByConsumer.
|
||||
*******************************************************************************/
|
||||
public String getCreatedByConsumer()
|
||||
{
|
||||
return (createdByConsumer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createdByConsumer.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withCreatedByConsumer(String createdByConsumer)
|
||||
{
|
||||
this.createdByConsumer = createdByConsumer;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setAliasId(Integer aliasId)
|
||||
{
|
||||
this.aliasId = aliasId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFileName(String fileName)
|
||||
{
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSizeBytes(Long sizeBytes)
|
||||
{
|
||||
this.sizeBytes = sizeBytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSha256(String sha256)
|
||||
{
|
||||
this.sha256 = sha256;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setStorageKey(String storageKey)
|
||||
{
|
||||
this.storageKey = storageKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFirstDownloadedAt(Instant firstDownloadedAt)
|
||||
{
|
||||
this.firstDownloadedAt = firstDownloadedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setDownloadCount(Integer downloadCount)
|
||||
{
|
||||
this.downloadCount = downloadCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setRemovedAt(Instant removedAt)
|
||||
{
|
||||
this.removedAt = removedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreatedByConsumer(String createdByConsumer)
|
||||
{
|
||||
this.createdByConsumer = createdByConsumer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for onDownloadedPending.
|
||||
*******************************************************************************/
|
||||
public Boolean getOnDownloadedPending()
|
||||
{
|
||||
return (onDownloadedPending);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setOnDownloadedPending(Boolean onDownloadedPending)
|
||||
{
|
||||
this.onDownloadedPending = onDownloadedPending;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for onDownloadedPending.
|
||||
*******************************************************************************/
|
||||
public FileGatewayOutboxFile withOnDownloadedPending(Boolean onDownloadedPending)
|
||||
{
|
||||
this.onDownloadedPending = onDownloadedPending;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted gateway heartbeat, visible across deployments.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayServer extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_server";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private String podName;
|
||||
|
||||
@QField
|
||||
private String hostKeyFingerprint;
|
||||
|
||||
@QField
|
||||
private Integer port;
|
||||
|
||||
@QField
|
||||
private Instant startedAt;
|
||||
|
||||
@QField
|
||||
private Instant lastSeenAt;
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty record.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from gateway persistence.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access id.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access id.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access createDate.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access modifyDate.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access podName.
|
||||
*******************************************************************************/
|
||||
public String getPodName()
|
||||
{
|
||||
return (podName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access podName.
|
||||
*******************************************************************************/
|
||||
public void setPodName(String podName)
|
||||
{
|
||||
this.podName = podName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access podName.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withPodName(String podName)
|
||||
{
|
||||
this.podName = podName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access hostKeyFingerprint.
|
||||
*******************************************************************************/
|
||||
public String getHostKeyFingerprint()
|
||||
{
|
||||
return (hostKeyFingerprint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access hostKeyFingerprint.
|
||||
*******************************************************************************/
|
||||
public void setHostKeyFingerprint(String hostKeyFingerprint)
|
||||
{
|
||||
this.hostKeyFingerprint = hostKeyFingerprint;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access hostKeyFingerprint.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withHostKeyFingerprint(String hostKeyFingerprint)
|
||||
{
|
||||
this.hostKeyFingerprint = hostKeyFingerprint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access port.
|
||||
*******************************************************************************/
|
||||
public Integer getPort()
|
||||
{
|
||||
return (port);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access port.
|
||||
*******************************************************************************/
|
||||
public void setPort(Integer port)
|
||||
{
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access port.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withPort(Integer port)
|
||||
{
|
||||
this.port = port;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access startedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getStartedAt()
|
||||
{
|
||||
return (startedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access startedAt.
|
||||
*******************************************************************************/
|
||||
public void setStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access startedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access lastSeenAt.
|
||||
*******************************************************************************/
|
||||
public Instant getLastSeenAt()
|
||||
{
|
||||
return (lastSeenAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access lastSeenAt.
|
||||
*******************************************************************************/
|
||||
public void setLastSeenAt(Instant lastSeenAt)
|
||||
{
|
||||
this.lastSeenAt = lastSeenAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access lastSeenAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewayServer withLastSeenAt(Instant lastSeenAt)
|
||||
{
|
||||
this.lastSeenAt = lastSeenAt;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record entity for ssh keys.
|
||||
*******************************************************************************/
|
||||
public class FileGatewaySshKey extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_ssh_key";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private Integer accountId;
|
||||
|
||||
@QField
|
||||
private String label;
|
||||
|
||||
@QField
|
||||
private String publicKey;
|
||||
|
||||
@QField
|
||||
private String fingerprint;
|
||||
|
||||
@QField
|
||||
private String keyType;
|
||||
|
||||
@QField
|
||||
private Integer keyBits;
|
||||
|
||||
@QField
|
||||
private Instant expiresAt;
|
||||
|
||||
@QField
|
||||
private Instant revokedAt;
|
||||
|
||||
@QField
|
||||
private Instant lastUsedAt;
|
||||
|
||||
@QField
|
||||
private String lastUsedIp;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an empty entity.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a database record.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for accountId.
|
||||
*******************************************************************************/
|
||||
public Integer getAccountId()
|
||||
{
|
||||
return (accountId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for accountId.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label.
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for label.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for publicKey.
|
||||
*******************************************************************************/
|
||||
public String getPublicKey()
|
||||
{
|
||||
return (publicKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for publicKey.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withPublicKey(String publicKey)
|
||||
{
|
||||
this.publicKey = publicKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fingerprint.
|
||||
*******************************************************************************/
|
||||
public String getFingerprint()
|
||||
{
|
||||
return (fingerprint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fingerprint.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withFingerprint(String fingerprint)
|
||||
{
|
||||
this.fingerprint = fingerprint;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for keyType.
|
||||
*******************************************************************************/
|
||||
public String getKeyType()
|
||||
{
|
||||
return (keyType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for keyType.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withKeyType(String keyType)
|
||||
{
|
||||
this.keyType = keyType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for keyBits.
|
||||
*******************************************************************************/
|
||||
public Integer getKeyBits()
|
||||
{
|
||||
return (keyBits);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for keyBits.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withKeyBits(Integer keyBits)
|
||||
{
|
||||
this.keyBits = keyBits;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for expiresAt.
|
||||
*******************************************************************************/
|
||||
public Instant getExpiresAt()
|
||||
{
|
||||
return (expiresAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for expiresAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for revokedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getRevokedAt()
|
||||
{
|
||||
return (revokedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for revokedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withRevokedAt(Instant revokedAt)
|
||||
{
|
||||
this.revokedAt = revokedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastUsedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getLastUsedAt()
|
||||
{
|
||||
return (lastUsedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastUsedAt.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withLastUsedAt(Instant lastUsedAt)
|
||||
{
|
||||
this.lastUsedAt = lastUsedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastUsedIp.
|
||||
*******************************************************************************/
|
||||
public String getLastUsedIp()
|
||||
{
|
||||
return (lastUsedIp);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastUsedIp.
|
||||
*******************************************************************************/
|
||||
public FileGatewaySshKey withLastUsedIp(String lastUsedIp)
|
||||
{
|
||||
this.lastUsedIp = lastUsedIp;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setLabel(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setPublicKey(String publicKey)
|
||||
{
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFingerprint(String fingerprint)
|
||||
{
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setKeyType(String keyType)
|
||||
{
|
||||
this.keyType = keyType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setKeyBits(Integer keyBits)
|
||||
{
|
||||
this.keyBits = keyBits;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setExpiresAt(Instant expiresAt)
|
||||
{
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setRevokedAt(Instant revokedAt)
|
||||
{
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setLastUsedAt(Instant lastUsedAt)
|
||||
{
|
||||
this.lastUsedAt = lastUsedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setLastUsedIp(String lastUsedIp)
|
||||
{
|
||||
this.lastUsedIp = lastUsedIp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
* 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.filegateway.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record entity for transfers.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayTransferLog extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "file_gateway_transfer_log";
|
||||
|
||||
@QField
|
||||
private Integer id;
|
||||
|
||||
@QField
|
||||
private Instant createDate;
|
||||
|
||||
@QField
|
||||
private Instant modifyDate;
|
||||
|
||||
@QField
|
||||
private Integer accountId;
|
||||
|
||||
@QField
|
||||
private Integer connectionLogId;
|
||||
|
||||
@QField
|
||||
private String operation;
|
||||
|
||||
@QField
|
||||
private String path;
|
||||
|
||||
@QField
|
||||
private String objectKind;
|
||||
|
||||
@QField
|
||||
private String objectId;
|
||||
|
||||
@QField
|
||||
private String format;
|
||||
|
||||
@QField
|
||||
private Long bytes;
|
||||
|
||||
@QField
|
||||
private String sha256;
|
||||
|
||||
@QField
|
||||
private Long durationMs;
|
||||
|
||||
@QField
|
||||
private String outcome;
|
||||
|
||||
@QField
|
||||
private String message;
|
||||
|
||||
@QField
|
||||
private String clientChannel;
|
||||
|
||||
@QField
|
||||
private Integer uploadJobId;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create an empty entity.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a database record.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for accountId.
|
||||
*******************************************************************************/
|
||||
public Integer getAccountId()
|
||||
{
|
||||
return (accountId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for accountId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for connectionLogId.
|
||||
*******************************************************************************/
|
||||
public Integer getConnectionLogId()
|
||||
{
|
||||
return (connectionLogId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for connectionLogId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withConnectionLogId(Integer connectionLogId)
|
||||
{
|
||||
this.connectionLogId = connectionLogId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for operation.
|
||||
*******************************************************************************/
|
||||
public String getOperation()
|
||||
{
|
||||
return (operation);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for operation.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withOperation(String operation)
|
||||
{
|
||||
this.operation = operation;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for path.
|
||||
*******************************************************************************/
|
||||
public String getPath()
|
||||
{
|
||||
return (path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for path.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withPath(String path)
|
||||
{
|
||||
this.path = path;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for objectKind.
|
||||
*******************************************************************************/
|
||||
public String getObjectKind()
|
||||
{
|
||||
return (objectKind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for objectKind.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withObjectKind(String objectKind)
|
||||
{
|
||||
this.objectKind = objectKind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for objectId.
|
||||
*******************************************************************************/
|
||||
public String getObjectId()
|
||||
{
|
||||
return (objectId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for objectId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withObjectId(String objectId)
|
||||
{
|
||||
this.objectId = objectId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for format.
|
||||
*******************************************************************************/
|
||||
public String getFormat()
|
||||
{
|
||||
return (format);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for format.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withFormat(String format)
|
||||
{
|
||||
this.format = format;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bytes.
|
||||
*******************************************************************************/
|
||||
public Long getBytes()
|
||||
{
|
||||
return (bytes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bytes.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withBytes(Long bytes)
|
||||
{
|
||||
this.bytes = bytes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for sha256.
|
||||
*******************************************************************************/
|
||||
public String getSha256()
|
||||
{
|
||||
return (sha256);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for sha256.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withSha256(String sha256)
|
||||
{
|
||||
this.sha256 = sha256;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for durationMs.
|
||||
*******************************************************************************/
|
||||
public Long getDurationMs()
|
||||
{
|
||||
return (durationMs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for durationMs.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withDurationMs(Long durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outcome.
|
||||
*******************************************************************************/
|
||||
public String getOutcome()
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outcome.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withOutcome(String outcome)
|
||||
{
|
||||
this.outcome = outcome;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for message.
|
||||
*******************************************************************************/
|
||||
public String getMessage()
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for message.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientChannel.
|
||||
*******************************************************************************/
|
||||
public String getClientChannel()
|
||||
{
|
||||
return (clientChannel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientChannel.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uploadJobId.
|
||||
*******************************************************************************/
|
||||
public Integer getUploadJobId()
|
||||
{
|
||||
return (uploadJobId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for uploadJobId.
|
||||
*******************************************************************************/
|
||||
public FileGatewayTransferLog withUploadJobId(Integer uploadJobId)
|
||||
{
|
||||
this.uploadJobId = uploadJobId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setAccountId(Integer accountId)
|
||||
{
|
||||
this.accountId = accountId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setConnectionLogId(Integer connectionLogId)
|
||||
{
|
||||
this.connectionLogId = connectionLogId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setOperation(String operation)
|
||||
{
|
||||
this.operation = operation;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setPath(String path)
|
||||
{
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setObjectKind(String objectKind)
|
||||
{
|
||||
this.objectKind = objectKind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setObjectId(String objectId)
|
||||
{
|
||||
this.objectId = objectId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setFormat(String format)
|
||||
{
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setBytes(Long bytes)
|
||||
{
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setSha256(String sha256)
|
||||
{
|
||||
this.sha256 = sha256;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setDurationMs(Long durationMs)
|
||||
{
|
||||
this.durationMs = durationMs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setOutcome(String outcome)
|
||||
{
|
||||
this.outcome = outcome;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter used by QRecordEntity population.
|
||||
*******************************************************************************/
|
||||
public void setUploadJobId(Integer uploadJobId)
|
||||
{
|
||||
this.uploadJobId = uploadJobId;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* 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.filegateway.outbox;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayConsumer;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayConsumersMetaData;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayOutboxProvider;
|
||||
import com.kingsrook.qbits.filegateway.api.OutboxFileRef;
|
||||
import com.kingsrook.qbits.filegateway.metadata.AliasCustomizer;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Database and object-storage outbox usable in web pods without an SFTP listener.
|
||||
*******************************************************************************/
|
||||
public class OutboxService implements FileGatewayOutboxProvider
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(OutboxService.class);
|
||||
private final QInstance instance;
|
||||
private final AuditWriter audit;
|
||||
private final GatewayObjectStore store;
|
||||
private final FileGatewayQBitConfig config;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No runtime, threads or protocol credentials are required.
|
||||
*******************************************************************************/
|
||||
public OutboxService(QInstance instance, GatewayObjectStore store, FileGatewayQBitConfig config, Clock clock)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.audit = new AuditWriter(instance);
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish only after the object is complete and its caller-supplied size matches.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public OutboxFileRef put(String endpointName, String fileName, InputStream content, long sizeBytes) throws QException
|
||||
{
|
||||
if(!AliasCustomizer.validFileName(fileName) || sizeBytes < 0)
|
||||
{
|
||||
throw (new QException("Invalid outbox filename or size"));
|
||||
}
|
||||
QRecord alias = endpoint(endpointName);
|
||||
String key = "file-gateway/outbox/" + alias.getValue("id") + "/" + UUID.randomUUID() + "/" + fileName;
|
||||
try
|
||||
{
|
||||
GatewayObjectStore.Receipt receipt = store.put(key, content);
|
||||
if(receipt.size() != sizeBytes)
|
||||
{
|
||||
throw (new QException("Outbox stream size does not match sizeBytes"));
|
||||
}
|
||||
return (audit.withRowLock(FileGatewayTables.ALIAS, "id", alias.getValue("id"), () ->
|
||||
{
|
||||
QRecord current = endpoint(endpointName);
|
||||
for(QRecord existing : list(current.getValueInteger("id")))
|
||||
{
|
||||
if(fileName.equals(existing.getValueString("fileName")))
|
||||
{
|
||||
throw (new QException("An active outbox file already has this filename"));
|
||||
}
|
||||
}
|
||||
QRecord row = audit.insert(FileGatewayTables.OUTBOX_FILE, new QRecord().withValue("aliasId", current.getValue("id"))
|
||||
.withValue("fileName", fileName).withValue("storageKey", key).withValue("sizeBytes", receipt.size()).withValue("sha256", receipt.sha256())
|
||||
.withValue("status", "READY").withValue("downloadCount", 0).withValue("onDownloadedPending", false)
|
||||
.withValue("createdByConsumer", current.getValue("consumerName")).withValue("createDate", clock.instant())
|
||||
.withValue("expiresAt", clock.instant().plus(config.getOutboxRetention())));
|
||||
return (reference(row, current));
|
||||
}));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.delete(key);
|
||||
}
|
||||
catch(IOException cleanup)
|
||||
{
|
||||
e.addSuppressed(cleanup);
|
||||
}
|
||||
throw (new QException("Unable to publish outbox file: " + (e instanceof QException ? e.getMessage() : "storage failed"), e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing, inactive or incorrectly typed endpoint cannot accept output.
|
||||
*******************************************************************************/
|
||||
private QRecord endpoint(String name) throws QException
|
||||
{
|
||||
var rows = audit.query(FileGatewayTables.ALIAS, UploadJobs.equal("name", name));
|
||||
if(rows.isEmpty() || !Boolean.TRUE.equals(rows.getFirst().getValueBoolean("isActive")) || !"OUTBOX".equals(rows.getFirst().getValueString("kind")))
|
||||
{
|
||||
throw (new QException("Outbox endpoint is missing, inactive or not OUTBOX"));
|
||||
}
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Removed and expired files disappear immediately from the virtual folder.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> list(Integer aliasId) throws QException
|
||||
{
|
||||
return (audit.query(FileGatewayTables.OUTBOX_FILE, UploadJobs.equal("aliasId", aliasId)).stream()
|
||||
.filter(row -> List.of("READY", "DOWNLOADED").contains(row.getValueString("status"))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only a complete read schedules the durable, at-least-once notification.
|
||||
*******************************************************************************/
|
||||
public void downloaded(QRecord row) throws QException
|
||||
{
|
||||
audit.withRowLock(FileGatewayTables.OUTBOX_FILE, "id", row.getValue("id"), () ->
|
||||
{
|
||||
QRecord current = audit.query(FileGatewayTables.OUTBOX_FILE, UploadJobs.equal("id", row.getValue("id"))).getFirst();
|
||||
if(List.of("READY", "DOWNLOADED").contains(current.getValueString("status")))
|
||||
{
|
||||
QRecord changes = new QRecord().withValue("id", current.getValue("id")).withValue("status", "DOWNLOADED")
|
||||
.withValue("downloadCount", current.getValueInteger("downloadCount") + 1);
|
||||
if(current.getValue("firstDownloadedAt") == null)
|
||||
{
|
||||
changes.withValue("firstDownloadedAt", clock.instant()).withValue("onDownloadedPending", true);
|
||||
}
|
||||
audit.update(FileGatewayTables.OUTBOX_FILE, changes);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
notifyDownloaded(row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Holding the row lock prevents duplicate concurrent delivery; crash retries remain possible.
|
||||
*******************************************************************************/
|
||||
public void notifyDownloaded(QRecord row) throws QException
|
||||
{
|
||||
audit.withRowLock(FileGatewayTables.OUTBOX_FILE, "id", row.getValue("id"), () ->
|
||||
{
|
||||
QRecord current = audit.query(FileGatewayTables.OUTBOX_FILE, UploadJobs.equal("id", row.getValue("id"))).getFirst();
|
||||
if(!Boolean.TRUE.equals(current.getValueBoolean("onDownloadedPending")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
QRecord alias = audit.query(FileGatewayTables.ALIAS, UploadJobs.equal("id", current.getValue("aliasId"))).getFirst();
|
||||
String name = alias.getValueString("consumerName");
|
||||
if(name != null && !name.isBlank())
|
||||
{
|
||||
FileGatewayConsumer selected = null;
|
||||
for(var code : FileGatewayConsumersMetaData.ofOrWithNew(instance).getConsumers())
|
||||
{
|
||||
FileGatewayConsumer consumer = QCodeLoader.getAdHoc(FileGatewayConsumer.class, code);
|
||||
if(name.equals(consumer.getName()))
|
||||
{
|
||||
selected = consumer;
|
||||
}
|
||||
}
|
||||
if(selected == null)
|
||||
{
|
||||
throw (new QException("Outbox consumer is not registered"));
|
||||
}
|
||||
selected.onDownloaded(reference(current, alias));
|
||||
}
|
||||
audit.update(FileGatewayTables.OUTBOX_FILE, new QRecord().withValue("id", row.getValue("id")).withValue("onDownloadedPending", false));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Outbox download notification failed; maintenance will retry");
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledgment retains audit state until object retention runs.
|
||||
*******************************************************************************/
|
||||
public void remove(QRecord row) throws QException
|
||||
{
|
||||
audit.withRowLock(FileGatewayTables.OUTBOX_FILE, "id", row.getValue("id"), () ->
|
||||
{
|
||||
audit.update(FileGatewayTables.OUTBOX_FILE, new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("status", "REMOVED").withValue("removedAt", clock.instant()));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Consumer contract contains no implementation types or storage credentials.
|
||||
*******************************************************************************/
|
||||
private OutboxFileRef reference(QRecord row, QRecord alias)
|
||||
{
|
||||
return (new OutboxFileRef(row.getValueInteger("id"), alias.getValueString("name"), alias.getValueString("path") + "/" + row.getValueString("fileName"),
|
||||
row.getValueString("fileName"), row.getValueLong("sizeBytes"), row.getValueString("sha256"), row.getValueInstant("createDate")));
|
||||
}
|
||||
}
|
||||
+106
@@ -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.filegateway.permissions;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
|
||||
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.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
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.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read with view or admin; only administrators write configuration tables.
|
||||
*******************************************************************************/
|
||||
public class FileGatewayPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check the current user against the gateway permission model.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
boolean write = metadata instanceof QProcessMetaData || input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput;
|
||||
if(write && metadata instanceof QTableMetaData table && FileGatewayTables.SYSTEM_WRITTEN.contains(table.getName()))
|
||||
{
|
||||
throw (new QPermissionDeniedException("This table is written only by the file gateway"));
|
||||
}
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || !(session.hasPermission(FileGatewayPermissions.ADMIN) || (!write && session.hasPermission(FileGatewayPermissions.VIEW))))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Missing file gateway permission"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This checker gates on the three gateway permission names, not on per-object
|
||||
** names, so it tells the catalog (getAllAvailablePermissions) those names.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean handlesBuildAvailablePermission()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Collapse every gateway object onto file_gateway.view, .admin or .connect.
|
||||
** The object name and type are fixed so the catalog (a set) lists each name
|
||||
** once: reads and the app need view, writes and processes need admin, and the
|
||||
** app also stands for SFTP sign-in (connect), which the gateway grants to
|
||||
** sessions rather than checking on an object.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
|
||||
{
|
||||
AvailablePermission base = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
|
||||
if(base == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
boolean write = "WRITE".equals(base.getPermissionType()) || "Process".equals(objectType);
|
||||
String name = write ? FileGatewayPermissions.ADMIN : FileGatewayPermissions.VIEW;
|
||||
if(metaDataWithName instanceof QAppMetaData)
|
||||
{
|
||||
name = FileGatewayPermissions.CONNECT;
|
||||
}
|
||||
return (base.withName(name).withObjectName("File Gateway").withObjectType("App").withPermissionType(name.substring(name.indexOf('.') + 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.filegateway.permissions;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permissions granted by the host application.
|
||||
*******************************************************************************/
|
||||
public final class FileGatewayPermissions
|
||||
{
|
||||
public static final String VIEW = "file_gateway.view";
|
||||
public static final String ADMIN = "file_gateway.admin";
|
||||
public static final String CONNECT = "file_gateway.connect";
|
||||
public static final List<String> ALL = List.of(VIEW, ADMIN, CONNECT);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constants only.
|
||||
*******************************************************************************/
|
||||
private FileGatewayPermissions()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* 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.filegateway.pgp;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Date;
|
||||
import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags;
|
||||
import org.bouncycastle.openpgp.PGPCompressedData;
|
||||
import org.bouncycastle.openpgp.PGPEncryptedDataGenerator;
|
||||
import org.bouncycastle.openpgp.PGPEncryptedDataList;
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPLiteralData;
|
||||
import org.bouncycastle.openpgp.PGPLiteralDataGenerator;
|
||||
import org.bouncycastle.openpgp.PGPObjectFactory;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData;
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
|
||||
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
|
||||
import org.bouncycastle.openpgp.PGPUtil;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPDataEncryptorBuilder;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory;
|
||||
import org.bouncycastle.openpgp.operator.bc.BcPublicKeyKeyEncryptionMethodGenerator;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded, in-memory OpenPGP streams; no key files, providers or background threads.
|
||||
*******************************************************************************/
|
||||
public final class PgpStreams
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static stream factories only.
|
||||
*******************************************************************************/
|
||||
private PgpStreams()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require a usable encryption subkey, rather than accepting signing-only keys.
|
||||
*******************************************************************************/
|
||||
public static PGPPublicKey encryptionKey(String text) throws IOException, PGPException
|
||||
{
|
||||
if(text == null || text.isBlank())
|
||||
{
|
||||
throw (new PGPException("PGP public key is required"));
|
||||
}
|
||||
var rings = new PGPPublicKeyRingCollection(PGPUtil.getDecoderStream(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))), new BcKeyFingerprintCalculator());
|
||||
for(var ring : rings)
|
||||
{
|
||||
for(var key : ring)
|
||||
{
|
||||
if(!key.isMasterKey() && key.isEncryptionKey() && !key.hasRevocation()
|
||||
&& (key.getValidSeconds() == 0 || key.getCreationTime().toInstant().plusSeconds(key.getValidSeconds()).isAfter(java.time.Instant.now())))
|
||||
{
|
||||
return (key);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw (new PGPException("PGP public key must contain an unrevoked, unexpired encryption subkey"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AES-256 binary packets with integrity protection and bounded partial packets.
|
||||
*******************************************************************************/
|
||||
public static InputStream encrypt(InputStream source, String keyText, String filename) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream pending = new ByteArrayOutputStream(131072);
|
||||
var generator = new PGPEncryptedDataGenerator(new BcPGPDataEncryptorBuilder(SymmetricKeyAlgorithmTags.AES_256)
|
||||
.setWithIntegrityPacket(true).setSecureRandom(new SecureRandom()));
|
||||
generator.addMethod(new BcPublicKeyKeyEncryptionMethodGenerator(encryptionKey(keyText)));
|
||||
OutputStream encrypted = generator.open(pending, new byte[32768]);
|
||||
var literalGenerator = new PGPLiteralDataGenerator();
|
||||
OutputStream literal = literalGenerator.open(encrypted, PGPLiteralData.BINARY, filename, new Date(0), new byte[32768]);
|
||||
return (new InputStream()
|
||||
{
|
||||
private InputStream available = InputStream.nullInputStream();
|
||||
private boolean finished;
|
||||
private boolean closed;
|
||||
|
||||
@Override
|
||||
public int read() throws IOException
|
||||
{
|
||||
byte[] one = new byte[1];
|
||||
return (read(one, 0, 1) < 0 ? -1 : one[0] & 255);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] bytes, int offset, int length) throws IOException
|
||||
{
|
||||
java.util.Objects.checkFromIndexSize(offset, length, bytes.length);
|
||||
if(closed)
|
||||
{
|
||||
throw (new IOException("PGP stream closed"));
|
||||
}
|
||||
if(length == 0)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
while(true)
|
||||
{
|
||||
int count = available.read(bytes, offset, length);
|
||||
if(count >= 0)
|
||||
{
|
||||
return (count);
|
||||
}
|
||||
if(pending.size() > 0)
|
||||
{
|
||||
available = new ByteArrayInputStream(pending.toByteArray());
|
||||
pending.reset();
|
||||
}
|
||||
else if(finished)
|
||||
{
|
||||
return (-1);
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] chunk = source.readNBytes(32768);
|
||||
if(chunk.length == 0)
|
||||
{
|
||||
literal.close();
|
||||
encrypted.close();
|
||||
finished = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
literal.write(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
closed = true;
|
||||
source.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(PGPException e)
|
||||
{
|
||||
throw (new IOException("Unable to encrypt PGP download", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify integrity at EOF before object storage can complete its plaintext upload.
|
||||
*******************************************************************************/
|
||||
public static InputStream decrypt(InputStream source, String secret, char[] passphrase, long maximum) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(secret == null)
|
||||
{
|
||||
throw (new PGPException("PGP private key environment variable is missing"));
|
||||
}
|
||||
var keys = new PGPSecretKeyRingCollection(PGPUtil.getDecoderStream(new ByteArrayInputStream(secret.getBytes(StandardCharsets.UTF_8))), new BcKeyFingerprintCalculator());
|
||||
var factory = new PGPObjectFactory(PGPUtil.getDecoderStream(source), new BcKeyFingerprintCalculator());
|
||||
Object packet = factory.nextObject();
|
||||
if(!(packet instanceof PGPEncryptedDataList))
|
||||
{
|
||||
packet = factory.nextObject();
|
||||
}
|
||||
if(!(packet instanceof PGPEncryptedDataList encrypted))
|
||||
{
|
||||
throw (new PGPException("Expected PGP encrypted data"));
|
||||
}
|
||||
for(var item : encrypted)
|
||||
{
|
||||
if(!(item instanceof PGPPublicKeyEncryptedData data))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var key = keys.getSecretKey(data.getKeyID());
|
||||
if(key == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var privateKey = key.extractPrivateKey(new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider()).build(passphrase));
|
||||
InputStream clear = data.getDataStream(new BcPublicKeyDataDecryptorFactory(privateKey));
|
||||
Object message = new PGPObjectFactory(clear, new BcKeyFingerprintCalculator()).nextObject();
|
||||
for(int depth = 0; message instanceof PGPCompressedData compressed && depth < 4; depth++)
|
||||
{
|
||||
message = new PGPObjectFactory(compressed.getDataStream(), new BcKeyFingerprintCalculator()).nextObject();
|
||||
}
|
||||
if(!(message instanceof PGPLiteralData literal) || !data.isIntegrityProtected())
|
||||
{
|
||||
throw (new PGPException("Expected integrity-protected literal PGP content"));
|
||||
}
|
||||
return (new FilterInputStream(literal.getInputStream())
|
||||
{
|
||||
private long consumed;
|
||||
private boolean verified;
|
||||
|
||||
@Override
|
||||
public int read() throws IOException
|
||||
{
|
||||
byte[] one = new byte[1];
|
||||
return (read(one, 0, 1) < 0 ? -1 : one[0] & 255);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] bytes, int offset, int length) throws IOException
|
||||
{
|
||||
int count = in.read(bytes, offset, length);
|
||||
if(count > 0 && (consumed += count) > maximum)
|
||||
{
|
||||
throw (new IOException("Decrypted upload exceeds file size limit"));
|
||||
}
|
||||
if(count < 0 && !verified)
|
||||
{
|
||||
try
|
||||
{
|
||||
clear.transferTo(OutputStream.nullOutputStream());
|
||||
if(!data.verify())
|
||||
{
|
||||
throw (new IOException("PGP integrity check failed"));
|
||||
}
|
||||
verified = true;
|
||||
}
|
||||
catch(PGPException e)
|
||||
{
|
||||
throw (new IOException("PGP integrity check failed", e));
|
||||
}
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
super.close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
throw (new PGPException("No matching PGP private key"));
|
||||
}
|
||||
catch(PGPException | RuntimeException e)
|
||||
{
|
||||
throw (new IOException("Unable to decrypt PGP upload", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
* 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.filegateway.preferences;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
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.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
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.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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionRequest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionResult;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceActionScope;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceListMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRow;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRowChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-service SSH keys. Core supplies the trusted scope and retains the user
|
||||
** and client channel; this provider never switches to a system session.
|
||||
*******************************************************************************/
|
||||
public class FileTransferPreferencesProvider implements UserPreferenceProviderInterface
|
||||
{
|
||||
public static final String SECTION_NAME = "file-transfer";
|
||||
private static final List<String> KEY_FIELDS = List.of("label", "fingerprint", "keyType", "lastUsedAt", "expiresAt");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register without an app scope: connect permission is sufficient.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance, FileGatewayQBitConfig config)
|
||||
{
|
||||
UserPreferenceSectionMetaData section = new UserPreferenceSectionMetaData()
|
||||
.withName(SECTION_NAME).withLabel("File transfer").withIcon(new QIcon("swap_vert")).withOrder(60)
|
||||
.withDescription("Sign in to the SFTP file gateway with your SSH keys.")
|
||||
.withPermissionName(FileGatewayPermissions.CONNECT).withProvider(new QCodeReference(FileTransferPreferencesProvider.class))
|
||||
.withField(new QFieldMetaData("username", QFieldType.STRING).withLabel("Username").withIsEditable(false)
|
||||
.withHelpContent(new QHelpContent("Adding your first key creates your SFTP account and username.")))
|
||||
.withField(new QFieldMetaData("port", QFieldType.INTEGER).withLabel("Port").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("hostKeyFingerprint", QFieldType.STRING).withLabel("Host key fingerprint").withIsEditable(false))
|
||||
.withList(new UserPreferenceListMetaData().withName("keys").withLabel("SSH keys").withRowLabel("Key")
|
||||
.withDescription("Adding your first key creates your SFTP account.")
|
||||
.withField(new QFieldMetaData("label", QFieldType.STRING).withLabel("Label").withIsRequired(true).withMaxLength(1024))
|
||||
.withField(new QFieldMetaData("fingerprint", QFieldType.STRING).withLabel("Fingerprint").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("keyType", QFieldType.STRING).withLabel("Key type").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("lastUsedAt", QFieldType.DATE_TIME).withLabel("Last used").withIsEditable(false))
|
||||
.withField(new QFieldMetaData("expiresAt", QFieldType.DATE_TIME).withLabel("Expires at")))
|
||||
.withAction(new UserPreferenceActionMetaData().withName("addKey").withLabel("Add key")
|
||||
.withScope(UserPreferenceActionScope.LIST).withListName("keys").withOrder(10)
|
||||
.withInputField(new QFieldMetaData("publicKey", QFieldType.TEXT).withLabel("Public key").withIsRequired(true))
|
||||
.withInputField(new QFieldMetaData("label", QFieldType.STRING).withLabel("Label").withIsRequired(true).withMaxLength(1024))
|
||||
.withInputField(new QFieldMetaData("expiresAt", QFieldType.DATE_TIME).withLabel("Expires at")))
|
||||
.withAction(new UserPreferenceActionMetaData().withName("revokeKey").withLabel("Revoke")
|
||||
.withScope(UserPreferenceActionScope.ROW).withListName("keys").withOrder(20).withIsDestructive(true)
|
||||
.withConfirmationText("Revoke this SSH key? It will no longer be able to sign in."));
|
||||
if(config.getPublicHostName() != null && !config.getPublicHostName().isBlank())
|
||||
{
|
||||
section.withField(new QFieldMetaData("host", QFieldType.STRING).withLabel("Host").withIsEditable(false).withDefaultValue(config.getPublicHostName()));
|
||||
}
|
||||
UserPreferencesMetaData.ofOrWithNew(instance).withSection(section);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish only the user's active key metadata, never the public-key text.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
QRecord account = account(context, null);
|
||||
UserPreferenceValues result = new UserPreferenceValues();
|
||||
result.getValues().put("username", account == null ? "" : account.getValue("username"));
|
||||
List<QRecord> servers = query(FileGatewayTables.SERVER, new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("lastSeenAt", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(1), null);
|
||||
if(!servers.isEmpty())
|
||||
{
|
||||
result.getValues().put("port", servers.getFirst().getValue("port"));
|
||||
result.getValues().put("hostKeyFingerprint", servers.getFirst().getValue("hostKeyFingerprint"));
|
||||
}
|
||||
List<UserPreferenceRow> rows = new ArrayList<>();
|
||||
if(account != null)
|
||||
{
|
||||
for(QRecord key : query(FileGatewayTables.SSH_KEY, keysFilter(account), null))
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
KEY_FIELDS.forEach(field -> values.put(field, key.getValue(field)));
|
||||
rows.add(new UserPreferenceRow().withKey(key.getValueString("id")).withLabel(key.getValueString("label")).withValues(values));
|
||||
}
|
||||
}
|
||||
result.getLists().put("keys", rows);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core validates types; reject blank labels as well as missing ones.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
for(UserPreferenceRowChange row : change.getLists().getOrDefault("keys", List.of()))
|
||||
{
|
||||
if(row.getValues().containsKey("label") && (row.getValues().get("label") == null || row.getValues().get("label").toString().isBlank()))
|
||||
{
|
||||
errors.add("Label: is required");
|
||||
}
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply partial changes atomically, checking ownership again under row locks.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
try(var tx = QBackendTransaction.openFor(new QueryInput(FileGatewayTables.ACCOUNT)))
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord account = lockedAccount(context, tx);
|
||||
for(UserPreferenceRowChange row : change.getLists().getOrDefault("keys", List.of()))
|
||||
{
|
||||
QRecord key = lockedKey(account, row.getKey(), tx);
|
||||
QRecord update = new QRecord().withValue("id", key.getValue("id"));
|
||||
for(String field : List.of("label", "expiresAt"))
|
||||
{
|
||||
if(row.getValues().containsKey(field))
|
||||
{
|
||||
update.setValue(field, row.getValues().get(field));
|
||||
}
|
||||
}
|
||||
update(FileGatewayTables.SSH_KEY, update, tx);
|
||||
}
|
||||
tx.commit();
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
tx.rollback();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add through the normal key customizer, or revoke an owned, unrevoked row.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceActionResult runAction(UserPreferenceRequestContext context, UserPreferenceActionRequest request) throws QException
|
||||
{
|
||||
if(!List.of("addKey", "revokeKey").contains(request.getActionName()))
|
||||
{
|
||||
throw (new QBadRequestException("This action isn't available."));
|
||||
}
|
||||
if("addKey".equals(request.getActionName()))
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
for(String field : List.of("publicKey", "label"))
|
||||
{
|
||||
if(request.getValues().get(field) == null || request.getValues().get(field).toString().isBlank())
|
||||
{
|
||||
errors.add(("publicKey".equals(field) ? "Public key" : "Label") + ": is required");
|
||||
}
|
||||
}
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QBadRequestException(String.join("\n", errors)));
|
||||
}
|
||||
}
|
||||
try(var tx = QBackendTransaction.openFor(new QueryInput(FileGatewayTables.ACCOUNT)))
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord account = lockedAccount(context, tx);
|
||||
UserPreferenceActionResult result;
|
||||
if("addKey".equals(request.getActionName()))
|
||||
{
|
||||
if(account == null)
|
||||
{
|
||||
account = insert(FileGatewayTables.ACCOUNT, new QRecord().withValue("username", availableUsername(context.getUserId(), tx))
|
||||
.withValue("userId", context.getUserId()).withValue("accountType", "USER").withValue("rootMode", "FULL_TREE"), tx);
|
||||
}
|
||||
QRecord key = insert(FileGatewayTables.SSH_KEY, new QRecord().withValue("accountId", account.getValue("id"))
|
||||
.withValue("publicKey", request.getValues().get("publicKey")).withValue("label", request.getValues().get("label"))
|
||||
.withValue("expiresAt", request.getValues().get("expiresAt")), tx);
|
||||
result = new UserPreferenceActionResult().withMessage("Added key " + key.getValueString("fingerprint") + ".").withFocusRowKey(key.getValueString("id"));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord key = lockedKey(account, request.getRowKey(), tx);
|
||||
update(FileGatewayTables.SSH_KEY, new QRecord().withValue("id", key.getValue("id")).withValue("revokedAt", Instant.now()), tx);
|
||||
result = new UserPreferenceActionResult().withMessage("Key revoked.");
|
||||
}
|
||||
tx.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
tx.rollback();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use only the session identity; service accounts stay in the admin tables.
|
||||
*******************************************************************************/
|
||||
private QRecord account(UserPreferenceRequestContext context, QBackendTransaction tx) throws QException
|
||||
{
|
||||
List<QRecord> rows = query(FileGatewayTables.ACCOUNT, new QQueryFilter()
|
||||
.withCriteria("userId", QCriteriaOperator.EQUALS, context.getUserId())
|
||||
.withCriteria("accountType", QCriteriaOperator.EQUALS, "USER").withOrderBy(new QFilterOrderBy("id", true)).withLimit(1), tx);
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A transactional timestamp update acquires the same lock used by gateway auth.
|
||||
** Re-read after the lock in case an administrator changed ownership meanwhile.
|
||||
*******************************************************************************/
|
||||
private QRecord lockedAccount(UserPreferenceRequestContext context, QBackendTransaction tx) throws QException
|
||||
{
|
||||
QRecord account = account(context, tx);
|
||||
if(account != null)
|
||||
{
|
||||
update(FileGatewayTables.ACCOUNT, new QRecord().withValue("id", account.getValue("id")).withValue("modifyDate", Instant.now()), tx);
|
||||
QRecord current = account(context, tx);
|
||||
if(current == null || !Objects.equals(current.getValue("id"), account.getValue("id")))
|
||||
{
|
||||
throw (new QBadRequestException("Account: isn't a setting here"));
|
||||
}
|
||||
return (current);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scope every key lookup to the caller's account and unrevoked rows.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter keysFilter(QRecord account)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("accountId", QCriteriaOperator.EQUALS, account.getValue("id"))
|
||||
.withCriteria("revokedAt", QCriteriaOperator.IS_BLANK).withOrderBy(new QFilterOrderBy("id", true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check ownership both before and after locking the key against reassignment.
|
||||
*******************************************************************************/
|
||||
private QRecord lockedKey(QRecord account, String rowKey, QBackendTransaction tx) throws QException
|
||||
{
|
||||
QRecord key = ownKey(account, rowKey, tx);
|
||||
update(FileGatewayTables.SSH_KEY, new QRecord().withValue("id", key.getValue("id")).withValue("modifyDate", Instant.now()), tx);
|
||||
return (ownKey(account, rowKey, tx));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not interpret an arbitrary caller-supplied key as an unrestricted ID.
|
||||
*******************************************************************************/
|
||||
private QRecord ownKey(QRecord account, String rowKey, QBackendTransaction tx) throws QException
|
||||
{
|
||||
if(account != null)
|
||||
{
|
||||
for(QRecord key : query(FileGatewayTables.SSH_KEY, keysFilter(account), tx))
|
||||
{
|
||||
if(key.getValueString("id").equals(rowKey))
|
||||
{
|
||||
return (key);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw (new QBadRequestException("Key: isn't a setting here"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize opaque user IDs to the account validator's 3-64 character syntax.
|
||||
** Truncate before suffixing, so even a 64-character collision stays valid.
|
||||
*******************************************************************************/
|
||||
private String availableUsername(String userId, QBackendTransaction tx) throws QException
|
||||
{
|
||||
String base = userId.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]", "-");
|
||||
if(base.length() < 3)
|
||||
{
|
||||
base = "user-" + base;
|
||||
}
|
||||
base = base.substring(0, Math.min(64, base.length()));
|
||||
String username = base;
|
||||
int suffix = 2;
|
||||
while(!query(FileGatewayTables.ACCOUNT, new QQueryFilter().withCriteria("username", QCriteriaOperator.EQUALS, username).withLimit(1), tx).isEmpty())
|
||||
{
|
||||
String ending = "-" + suffix++;
|
||||
username = base.substring(0, Math.min(base.length(), 64 - ending.length())) + ending;
|
||||
}
|
||||
return (username);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard actions retain customizers, configured audits and the caller's session.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> query(String table, QQueryFilter filter, QBackendTransaction tx) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withTransaction(tx)).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve domain validation messages while withholding internal database errors.
|
||||
*******************************************************************************/
|
||||
private QRecord checked(QRecord record) throws QException
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
List<String> messages = record.getErrors().stream().map(error -> error instanceof BadInputStatusMessage ? error.getMessage() : "Could not save this setting.").toList();
|
||||
throw (new QBadRequestException(String.join("\n", messages)));
|
||||
}
|
||||
return (record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert via ordinary table validation, including fingerprint uniqueness.
|
||||
*******************************************************************************/
|
||||
private QRecord insert(String table, QRecord record, QBackendTransaction tx) throws QException
|
||||
{
|
||||
return (checked(new InsertAction().execute(new InsertInput(table).withRecord(record).withTransaction(tx)).getRecords().getFirst()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist only supplied fields, with the original user's audit attribution.
|
||||
*******************************************************************************/
|
||||
private void update(String table, QRecord record, QBackendTransaction tx) throws QException
|
||||
{
|
||||
checked(new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record)).withTransaction(tx)).getRecords().getFirst());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CorePermissionEnforcer;
|
||||
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.insert.InsertInput;
|
||||
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.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.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** System-session access limited to the gateway own tables; application data uses the UI API.
|
||||
*******************************************************************************/
|
||||
public class AuditWriter
|
||||
{
|
||||
private final QInstance instance;
|
||||
private final ThreadLocal<QBackendTransaction> transaction = new ThreadLocal<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the gateway database instance.
|
||||
*******************************************************************************/
|
||||
public AuditWriter(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Work within a short system-session scope.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Work<T>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Execute the database operation.
|
||||
*******************************************************************************/
|
||||
T run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve caller context while writing gateway-owned state.
|
||||
*******************************************************************************/
|
||||
public <T> T system(Work<T> work) throws QException
|
||||
{
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
try(var ignored = CorePermissionEnforcer.openAuthorizedScope())
|
||||
{
|
||||
return (work.run());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize account authentication across pods with a transactional row update.
|
||||
** Re-reading after acquiring the write lock sees committed counters and sessions.
|
||||
*******************************************************************************/
|
||||
public <T> T withAccountLock(String username, Work<T> work) throws QException
|
||||
{
|
||||
return (withAccountLock("username", username, work));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lock one account row without changing any user-managed fields.
|
||||
*******************************************************************************/
|
||||
public <T> T withAccountLock(String field, Serializable value, Work<T> work) throws QException
|
||||
{
|
||||
return (withRowLock(FileGatewayTables.ACCOUNT, field, value, work));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize transitions on one gateway-owned row across gateway and web pods.
|
||||
*******************************************************************************/
|
||||
public <T> T withRowLock(String table, String field, Serializable value, Work<T> work) throws QException
|
||||
{
|
||||
checkTable(table);
|
||||
return (system(() ->
|
||||
{
|
||||
try(var tx = QBackendTransaction.openFor(new QueryInput(table)))
|
||||
{
|
||||
transaction.set(tx);
|
||||
try
|
||||
{
|
||||
var rows = query(table, new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
if(!rows.isEmpty())
|
||||
{
|
||||
update(table, new QRecord().withValue("id", rows.getFirst().getValue("id")).withValue("modifyDate", Instant.now()));
|
||||
}
|
||||
T result = work.run();
|
||||
tx.commit();
|
||||
return (result);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
tx.rollback();
|
||||
throw (new QException("Gateway account transaction failed", e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
transaction.remove();
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Charge an account-wide minute window atomically, including other gateway pods.
|
||||
*******************************************************************************/
|
||||
public boolean operationAllowed(Integer accountId, Instant now, int maximum) throws QException
|
||||
{
|
||||
return (withAccountLock("id", accountId, () ->
|
||||
{
|
||||
var rows = query(FileGatewayTables.ACCOUNT, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, accountId)));
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw (new QException("Gateway account no longer exists"));
|
||||
}
|
||||
QRecord account = rows.getFirst();
|
||||
Instant window = account.getValueInstant("operationWindowAt");
|
||||
Integer previous = account.getValueInteger("operationWindowCount");
|
||||
boolean reset = window == null || !window.plusSeconds(60).isAfter(now);
|
||||
int count = reset ? 1 : (previous == null ? 0 : previous) + 1;
|
||||
update(FileGatewayTables.ACCOUNT, new QRecord().withValue("id", accountId)
|
||||
.withValue("operationWindowAt", reset ? now : window).withValue("operationWindowCount", count));
|
||||
return (count <= maximum);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject accidental use on application data.
|
||||
*******************************************************************************/
|
||||
private void checkTable(String table)
|
||||
{
|
||||
if(!FileGatewayTables.ALL.contains(table))
|
||||
{
|
||||
throw (new IllegalArgumentException("System access is limited to gateway tables"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read gateway state.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
checkTable(table);
|
||||
return (system(() -> new QueryAction().execute(new QueryInput(table).withFilter(filter).withTransaction(transaction.get())).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert and require successful persistence.
|
||||
*******************************************************************************/
|
||||
public QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
checkTable(table);
|
||||
QRecord result = system(() -> new InsertAction().execute(new InsertInput(table).withRecord(record).withTransaction(transaction.get())).getRecords().get(0));
|
||||
checkErrors(result);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update gateway state.
|
||||
*******************************************************************************/
|
||||
public void update(String table, QRecord record) throws QException
|
||||
{
|
||||
checkTable(table);
|
||||
QRecord result = system(() -> new UpdateAction().execute(new UpdateInput(table).withRecords(List.of(record)).withTransaction(transaction.get())).getRecords().get(0));
|
||||
checkErrors(result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention deletes only gateway-owned rows under the bookkeeping session.
|
||||
*******************************************************************************/
|
||||
public void delete(String table, Serializable id) throws QException
|
||||
{
|
||||
checkTable(table);
|
||||
system(() -> new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(
|
||||
new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput(table).withPrimaryKey(id).withTransaction(transaction.get())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guarded status transition. Callers hold withAccountLock, so the status re-read in
|
||||
** that transaction is serialized across pods; status is compared here rather than
|
||||
** through a core record-version field, because core advances version fields on
|
||||
** every update (a STRING version becomes a random UUID).
|
||||
*******************************************************************************/
|
||||
public boolean transitionUpload(QRecord changes, String expectedStatus) throws QException
|
||||
{
|
||||
if(transaction.get() == null)
|
||||
{
|
||||
throw (new QException("Upload transitions require the account lock transaction"));
|
||||
}
|
||||
var rows = query(FileGatewayTables.UPLOAD_JOB, new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, changes.getValue("id"))));
|
||||
if(rows.isEmpty() || !Objects.equals(expectedStatus, rows.getFirst().getValueString("status")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
update(FileGatewayTables.UPLOAD_JOB, changes);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed on bookkeeping errors without printing records.
|
||||
*******************************************************************************/
|
||||
private void checkErrors(QRecord record) throws QException
|
||||
{
|
||||
if(record.getErrors() != null && !record.getErrors().isEmpty())
|
||||
{
|
||||
throw (new QException("Unable to persist gateway state"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.net.InetAddress;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Numeric IPv4 and IPv6 CIDR matching; hostnames never trigger DNS resolution.
|
||||
*******************************************************************************/
|
||||
public final class CidrMatcher
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility only.
|
||||
*******************************************************************************/
|
||||
private CidrMatcher()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Match an address against comma or whitespace separated networks.
|
||||
*******************************************************************************/
|
||||
public static boolean matches(String address, String cidrs)
|
||||
{
|
||||
if(cidrs == null || cidrs.isBlank())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
try
|
||||
{
|
||||
byte[] candidate = numeric(address);
|
||||
for(String cidr : cidrs.split("[,\\s]+"))
|
||||
{
|
||||
String[] parts = cidr.split("/", -1);
|
||||
byte[] network = numeric(parts[0]);
|
||||
int bits = parts.length == 1 ? network.length * 8 : Integer.parseInt(parts[1]);
|
||||
if(parts.length > 2 || bits < 0 || bits > network.length * 8)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(candidate.length != network.length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean match = true;
|
||||
for(int i = 0; i < bits; i++)
|
||||
{
|
||||
int mask = 1 << (7 - i % 8);
|
||||
if((candidate[i / 8] & mask) != (network[i / 8] & mask))
|
||||
{
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(match)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse numeric addresses only.
|
||||
*******************************************************************************/
|
||||
private static byte[] numeric(String address) throws Exception
|
||||
{
|
||||
if(address == null || !address.matches("[0-9a-fA-F:.]+") || (!address.contains(":") && !address.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}")))
|
||||
{
|
||||
throw (new IllegalArgumentException("Expected a numeric IP address"));
|
||||
}
|
||||
return (InetAddress.getByName(address).getAddress());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.security.PublicKey;
|
||||
import org.apache.sshd.server.session.ServerSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Protocol authentication boundary for public-key credentials.
|
||||
*******************************************************************************/
|
||||
public interface FileGatewayAuthenticatorInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Resolve and authenticate a protocol credential.
|
||||
*******************************************************************************/
|
||||
boolean authenticate(String username, PublicKey key, ServerSession session);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.sftp;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle of a gateway protocol listener.
|
||||
*******************************************************************************/
|
||||
public interface FileGatewayProtocolServer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Start listening.
|
||||
*******************************************************************************/
|
||||
void start() throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Close the listener and live sessions.
|
||||
*******************************************************************************/
|
||||
void stop() throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Number of open sessions.
|
||||
*******************************************************************************/
|
||||
int activeSessions();
|
||||
}
|
||||
@@ -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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInProcessSessions;
|
||||
import org.apache.sshd.common.AttributeRepository.AttributeKey;
|
||||
import org.apache.sshd.server.session.ServerSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One SSH connection, its audit identity and refreshable in-process credentials.
|
||||
*******************************************************************************/
|
||||
public class GatewaySession
|
||||
{
|
||||
public static final AttributeKey<GatewaySession> ATTRIBUTE = new AttributeKey<>();
|
||||
public static final AttributeKey<String> TOKEN = new AttributeKey<>();
|
||||
public final String id = UUID.randomUUID().toString();
|
||||
public final ServerSession ssh;
|
||||
public final String remoteIp;
|
||||
public Integer connectionId;
|
||||
public java.time.Instant connectedAt;
|
||||
public QRecord account;
|
||||
public QRecord key;
|
||||
public long operations;
|
||||
public String disconnectReason = "Client disconnected";
|
||||
private Instant mintedAt;
|
||||
private final QInstance instance;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind an accepted SSH connection.
|
||||
*******************************************************************************/
|
||||
public GatewaySession(QInstance instance, ServerSession ssh, Clock clock)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.ssh = ssh;
|
||||
this.clock = clock;
|
||||
this.remoteIp = ((InetSocketAddress) ssh.getClientAddress()).getAddress().getHostAddress();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mint via the host authentication module and refresh permissions every five minutes.
|
||||
*******************************************************************************/
|
||||
public synchronized String token() throws QException
|
||||
{
|
||||
if(account == null || ssh.isClosing() || !ssh.isOpen())
|
||||
{
|
||||
throw (new QException("Unauthenticated connection"));
|
||||
}
|
||||
if(mintedAt == null || !mintedAt.plus(Duration.ofMinutes(5)).isAfter(clock.instant()))
|
||||
{
|
||||
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
|
||||
var user = new AuditWriter(instance).system(() -> module.createAutomatedSessionForUser(instance, account.getValueString("userId")));
|
||||
if(user == null || !user.hasPermission(FileGatewayPermissions.CONNECT))
|
||||
{
|
||||
revoke();
|
||||
throw (new QException("no file_gateway.connect"));
|
||||
}
|
||||
String next = UiApiInProcessSessions.register(user, Duration.ofMinutes(10));
|
||||
UiApiInProcessSessions.revoke(ssh.getAttribute(TOKEN));
|
||||
ssh.setAttribute(TOKEN, next);
|
||||
mintedAt = clock.instant();
|
||||
}
|
||||
return (ssh.getAttribute(TOKEN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release credentials immediately on disconnect or revocation.
|
||||
*******************************************************************************/
|
||||
public synchronized void revoke()
|
||||
{
|
||||
UiApiInProcessSessions.revoke(ssh.removeAttribute(TOKEN));
|
||||
mintedAt = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count operations locally and charge the shared account minute window.
|
||||
*******************************************************************************/
|
||||
public synchronized boolean operationAllowed() throws IOException
|
||||
{
|
||||
operations++;
|
||||
Integer maximum = account.getValueInteger("maxOperationsPerMinute");
|
||||
try
|
||||
{
|
||||
return (new AuditWriter(instance).operationAllowed(account.getValueInteger("id"), clock.instant(), maximum == null ? 600 : maximum));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to verify account operation limit", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
import com.kingsrook.qbits.filegateway.storage.ObjectStorageGatewayStore;
|
||||
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
|
||||
import com.kingsrook.qbits.filegateway.vfs.GatewayFileSystem;
|
||||
import com.kingsrook.qbits.filegateway.vfs.GatewayUiApiClient;
|
||||
import com.kingsrook.qbits.filegateway.vfs.GeneratedDownloads;
|
||||
import com.kingsrook.qbits.filegateway.vfs.VirtualTree;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
import org.apache.sshd.common.cipher.BuiltinCiphers;
|
||||
import org.apache.sshd.common.file.FileSystemFactory;
|
||||
import org.apache.sshd.common.kex.BuiltinDHFactories;
|
||||
import org.apache.sshd.common.keyprovider.KeyPairProvider;
|
||||
import org.apache.sshd.common.mac.BuiltinMacs;
|
||||
import org.apache.sshd.common.session.Session;
|
||||
import org.apache.sshd.common.session.SessionContext;
|
||||
import org.apache.sshd.common.session.SessionListener;
|
||||
import org.apache.sshd.common.signature.BuiltinSignatures;
|
||||
import org.apache.sshd.core.CoreModuleProperties;
|
||||
import org.apache.sshd.server.ServerBuilder;
|
||||
import org.apache.sshd.server.SshServer;
|
||||
import org.apache.sshd.server.auth.pubkey.UserAuthPublicKeyFactory;
|
||||
import org.apache.sshd.server.forward.RejectAllForwardingFilter;
|
||||
import org.apache.sshd.server.session.ServerSession;
|
||||
import org.apache.sshd.sftp.server.SftpSubsystemFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Public-key-only SFTP with explicit modern algorithms and an entirely virtual filesystem.
|
||||
*******************************************************************************/
|
||||
public class SftpProtocolServer implements FileGatewayProtocolServer
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SftpProtocolServer.class);
|
||||
private final SshServer server;
|
||||
private final SshKeyAuthenticator authenticator;
|
||||
private final Duration pollInterval;
|
||||
private ScheduledExecutorService poller;
|
||||
private final AuditWriter heartbeatAudit;
|
||||
private final Clock heartbeatClock;
|
||||
private final String heartbeatPod;
|
||||
private final String hostFingerprint;
|
||||
private java.time.Instant startedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure the listener, security policy and injected runtime dependencies.
|
||||
*******************************************************************************/
|
||||
public SftpProtocolServer(QInstance instance, FileGatewayQBitConfig config, UiApiInvoker invoker, KeyPair hostKey, Clock clock, Duration pollInterval, String podName)
|
||||
{
|
||||
this(instance, config, invoker, hostKey, clock, pollInterval, podName, new ObjectStorageGatewayStore(instance, config.getFileGatewayBucketAlias()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inject storage for transfer tests; production uses the object-storage implementation.
|
||||
*******************************************************************************/
|
||||
public SftpProtocolServer(QInstance instance, FileGatewayQBitConfig config, UiApiInvoker invoker, KeyPair hostKey, Clock clock, Duration pollInterval, String podName, GatewayObjectStore store)
|
||||
{
|
||||
this.pollInterval = pollInterval;
|
||||
heartbeatAudit = new AuditWriter(instance);
|
||||
heartbeatClock = clock;
|
||||
heartbeatPod = podName == null ? java.util.UUID.randomUUID().toString() : podName;
|
||||
hostFingerprint = org.apache.sshd.common.config.keys.KeyUtils.getFingerPrint(org.apache.sshd.common.digest.BuiltinDigests.sha256, hostKey.getPublic());
|
||||
AuditWriter audit = new AuditWriter(instance);
|
||||
var outbox = new com.kingsrook.qbits.filegateway.outbox.OutboxService(instance, store, config, clock);
|
||||
com.kingsrook.qbits.filegateway.api.FileGatewayOutbox.setProvider(outbox);
|
||||
authenticator = new SshKeyAuthenticator(instance, config, audit, clock, podName);
|
||||
server = SshServer.setUpDefaultServer();
|
||||
server.setPort(config.getSftpPort());
|
||||
server.setKeyPairProvider(KeyPairProvider.wrap(hostKey));
|
||||
server.setCipherFactories(List.of(BuiltinCiphers.cc20p1305_openssh, BuiltinCiphers.aes128gcm, BuiltinCiphers.aes256gcm, BuiltinCiphers.aes128ctr, BuiltinCiphers.aes192ctr, BuiltinCiphers.aes256ctr));
|
||||
server.setMacFactories(List.of(BuiltinMacs.hmacsha256etm, BuiltinMacs.hmacsha512etm));
|
||||
server.setKeyExchangeFactories(List.of(BuiltinDHFactories.curve25519, BuiltinDHFactories.curve25519_libssh, BuiltinDHFactories.ecdhp256, BuiltinDHFactories.ecdhp384, BuiltinDHFactories.ecdhp521, BuiltinDHFactories.dhg16_512, BuiltinDHFactories.dhg18_512)
|
||||
.stream().filter(BuiltinDHFactories::isSupported).map(ServerBuilder.DH2KEX).toList());
|
||||
server.setSignatureFactories(List.of(BuiltinSignatures.ed25519, BuiltinSignatures.nistp256, BuiltinSignatures.nistp384, BuiltinSignatures.nistp521, BuiltinSignatures.rsaSHA256, BuiltinSignatures.rsaSHA512));
|
||||
server.setPasswordAuthenticator(null);
|
||||
server.setKeyboardInteractiveAuthenticator(null);
|
||||
server.setGSSAuthenticator(null);
|
||||
server.setHostBasedAuthenticator(null);
|
||||
server.setUserAuthFactories(List.of(UserAuthPublicKeyFactory.INSTANCE));
|
||||
server.setPublickeyAuthenticator(authenticator);
|
||||
server.setShellFactory(null);
|
||||
server.setCommandFactory(null);
|
||||
server.setForwardingFilter(RejectAllForwardingFilter.INSTANCE);
|
||||
server.setAgentFactory(null);
|
||||
server.setSubsystemFactories(List.of(new SftpSubsystemFactory.Builder().withSftpErrorStatusDataHandler(new org.apache.sshd.sftp.server.SftpErrorStatusDataHandler()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve actionable gateway and UI API errors in SFTP failure responses.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String resolveErrorMessage(org.apache.sshd.sftp.server.SftpSubsystemEnvironment subsystem, int id, Throwable error, int status, int command, Object... args)
|
||||
{
|
||||
return (error instanceof IOException && error.getMessage() != null ? error.getMessage()
|
||||
: org.apache.sshd.sftp.common.SftpHelper.resolveStatusMessage(status));
|
||||
}
|
||||
}).withFileSystemAccessor(new org.apache.sshd.sftp.server.SftpFileSystemAccessor()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** MINA must use our seekable channels, not the native FileChannel default.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public java.nio.channels.SeekableByteChannel openFile(org.apache.sshd.sftp.server.SftpSubsystemProxy subsystem, org.apache.sshd.sftp.server.FileHandle handle, Path path, String handleId,
|
||||
java.util.Set<? extends java.nio.file.OpenOption> options, java.nio.file.attribute.FileAttribute<?>... attributes) throws IOException
|
||||
{
|
||||
return (java.nio.file.Files.newByteChannel(path, options, attributes));
|
||||
}
|
||||
}).build()));
|
||||
CoreModuleProperties.IDLE_TIMEOUT.set(server, Duration.ofMinutes(15));
|
||||
CoreModuleProperties.AUTH_TIMEOUT.set(server, Duration.ofSeconds(30));
|
||||
server.setFileSystemFactory(new FileSystemFactory()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Create the session virtual root.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getUserHomeDir(SessionContext session) throws IOException
|
||||
{
|
||||
return (createFileSystem(session).getPath("/"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No native filesystem is consulted.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileSystem createFileSystem(SessionContext session)
|
||||
{
|
||||
GatewaySession state = session.getAttribute(GatewaySession.ATTRIBUTE);
|
||||
GatewayUiApiClient client = new GatewayUiApiClient(invoker, state);
|
||||
return (new GatewayFileSystem(new VirtualTree(client, state, audit, clock).withUploads(new UploadJobs(audit, store, config, clock), store)
|
||||
.withDownloads(new GeneratedDownloads(client, state, audit, config, clock, instance)).withOutbox(outbox)));
|
||||
}
|
||||
});
|
||||
server.addSessionListener(new SessionListener()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Persist accept before authentication.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void sessionCreated(Session session)
|
||||
{
|
||||
try
|
||||
{
|
||||
authenticator.accepted((ServerSession) session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Unable to audit SFTP accept");
|
||||
session.close(true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Login bookkeeping starts only after proof of private-key possession.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void sessionEvent(Session session, Event event)
|
||||
{
|
||||
if(event == Event.Authenticated)
|
||||
{
|
||||
try
|
||||
{
|
||||
authenticator.authenticated((ServerSession) session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Unable to finalize SFTP authentication");
|
||||
session.close(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always revoke the in-process token.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void sessionClosed(Session session)
|
||||
{
|
||||
try
|
||||
{
|
||||
authenticator.closed((ServerSession) session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Unable to finalize SFTP connection audit");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the listener and revocation heartbeat.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void start() throws IOException
|
||||
{
|
||||
server.start();
|
||||
startedAt = heartbeatClock.instant();
|
||||
try
|
||||
{
|
||||
heartbeat();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
|
||||
{
|
||||
server.stop(true);
|
||||
throw (new IOException("Unable to publish gateway heartbeat", e));
|
||||
}
|
||||
poller = Executors.newSingleThreadScheduledExecutor(runnable ->
|
||||
{
|
||||
Thread thread = new Thread(runnable, "file-gateway-revocation");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
poller.scheduleWithFixedDelay(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
authenticator.poll();
|
||||
heartbeat();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Unable to verify live SFTP sessions; closing them");
|
||||
server.getActiveSessions().forEach(session -> session.close(true));
|
||||
}
|
||||
}, pollInterval.toMillis(), pollInterval.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish host identity to the database for app homes in other deployments.
|
||||
*******************************************************************************/
|
||||
public void heartbeat() throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
var rows = heartbeatAudit.query(com.kingsrook.qbits.filegateway.metadata.FileGatewayTables.SERVER,
|
||||
UploadJobs.equal("podName", heartbeatPod));
|
||||
var values = new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("podName", heartbeatPod)
|
||||
.withValue("hostKeyFingerprint", hostFingerprint).withValue("port", server.getPort())
|
||||
.withValue("startedAt", startedAt).withValue("lastSeenAt", heartbeatClock.instant());
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
heartbeatAudit.insert(com.kingsrook.qbits.filegateway.metadata.FileGatewayTables.SERVER, values);
|
||||
}
|
||||
else
|
||||
{
|
||||
heartbeatAudit.update(com.kingsrook.qbits.filegateway.metadata.FileGatewayTables.SERVER, values.withValue("id", rows.getFirst().getValue("id")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop polling, revoke sessions and stop the listener.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void stop() throws IOException
|
||||
{
|
||||
if(poller != null)
|
||||
{
|
||||
poller.shutdownNow();
|
||||
}
|
||||
server.stop(true);
|
||||
try
|
||||
{
|
||||
authenticator.shutdown();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
|
||||
{
|
||||
throw (new IOException("Unable to finalize gateway shutdown", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count active SSH connections.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int activeSessions()
|
||||
{
|
||||
return (server.getActiveSessions().size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound port, including ephemeral test ports.
|
||||
*******************************************************************************/
|
||||
public int getPort()
|
||||
{
|
||||
return (server.getPort());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Server security configuration for inspection.
|
||||
*******************************************************************************/
|
||||
public SshServer getServer()
|
||||
{
|
||||
return (server);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Live revocation hook for deterministic tests.
|
||||
*******************************************************************************/
|
||||
public SshKeyAuthenticator getAuthenticator()
|
||||
{
|
||||
return (authenticator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* 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.filegateway.sftp;
|
||||
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import org.apache.sshd.common.config.keys.KeyUtils;
|
||||
import org.apache.sshd.common.digest.BuiltinDigests;
|
||||
import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
|
||||
import org.apache.sshd.server.session.ServerSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Database-backed SSH key authentication, account limits, IP windows and live revocation.
|
||||
*******************************************************************************/
|
||||
public class SshKeyAuthenticator implements PublickeyAuthenticator, FileGatewayAuthenticatorInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SshKeyAuthenticator.class);
|
||||
private final QInstance instance;
|
||||
private final FileGatewayQBitConfig config;
|
||||
private final AuditWriter audit;
|
||||
private final Clock clock;
|
||||
private final String podName;
|
||||
private final Map<String, GatewaySession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Deque<Instant>> failures = new HashMap<>();
|
||||
private final Map<String, Deque<Instant>> connections = new HashMap<>();
|
||||
private final Map<String, Instant> ipLocks = new HashMap<>();
|
||||
private final Map<String, String> ipLockAccounts = new HashMap<>();
|
||||
private final Map<String, String> failureAccounts = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind authentication and audit dependencies.
|
||||
*******************************************************************************/
|
||||
public SshKeyAuthenticator(QInstance instance, FileGatewayQBitConfig config, AuditWriter audit, Clock clock, String podName)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.config = config;
|
||||
this.audit = audit;
|
||||
this.clock = clock;
|
||||
this.podName = podName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Track TCP accept and enforce the pre-authentication connection rate. The
|
||||
** connection-log row is written at the first sign-in attempt (or a refusal),
|
||||
** so TCP health probes and port scans that never authenticate leave no row.
|
||||
*******************************************************************************/
|
||||
public synchronized void accepted(ServerSession ssh) throws QException
|
||||
{
|
||||
GatewaySession state = new GatewaySession(instance, ssh, clock);
|
||||
ssh.setAttribute(GatewaySession.ATTRIBUTE, state);
|
||||
state.connectedAt = clock.instant();
|
||||
sessions.put(state.id, state);
|
||||
prune(connections, clock.instant().minusSeconds(60));
|
||||
Deque<Instant> window = connections.computeIfAbsent(state.remoteIp, ignored -> new ArrayDeque<>());
|
||||
window.addLast(clock.instant());
|
||||
if(window.size() > config.getMaxConnectionsPerIpPerMinute())
|
||||
{
|
||||
outcome(state, "LIMIT", "Connection rate limit exceeded");
|
||||
ssh.close(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the account and offered public key, then mint the user session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized boolean authenticate(String username, PublicKey publicKey, ServerSession ssh)
|
||||
{
|
||||
GatewaySession state = ssh.getAttribute(GatewaySession.ATTRIBUTE);
|
||||
if(state == null || ssh.isClosing())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (audit.withAccountLock(username, () -> authenticateLocked(username, publicKey, ssh, state)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("SFTP authentication service failed", e);
|
||||
state.revoke();
|
||||
state.disconnectReason = "Authentication service unavailable";
|
||||
ssh.close(true);
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Account checks and reservations share one database transaction across pods.
|
||||
*******************************************************************************/
|
||||
private boolean authenticateLocked(String username, PublicKey publicKey, ServerSession ssh, GatewaySession state) throws QException
|
||||
{
|
||||
if(state.account != null)
|
||||
{
|
||||
return (username.equals(state.account.getValueString("username")) && state.key != null
|
||||
&& KeyUtils.getFingerPrint(BuiltinDigests.sha256, publicKey).equals(state.key.getValueString("fingerprint")));
|
||||
}
|
||||
QRecord account = first(FileGatewayTables.ACCOUNT, "username", username);
|
||||
String fingerprint = KeyUtils.getFingerPrint(BuiltinDigests.sha256, publicKey);
|
||||
ensureConnectionRow(state);
|
||||
audit.update(FileGatewayTables.CONNECTION_LOG, new QRecord().withValue("id", state.connectionId)
|
||||
.withValue("username", username).withValue("keyFingerprint", fingerprint).withValue("clientVersion", ssh.getClientVersion())
|
||||
.withValue("accountId", account == null ? null : account.getValue("id")));
|
||||
Instant now = clock.instant();
|
||||
if(account != null && account.getValue("lockedUntil") == null && username.equals(ipLockAccounts.get(state.remoteIp)))
|
||||
{
|
||||
ipLocks.remove(state.remoteIp);
|
||||
ipLockAccounts.remove(state.remoteIp);
|
||||
failures.remove(state.remoteIp);
|
||||
failureAccounts.remove(state.remoteIp);
|
||||
}
|
||||
ipLocks.entrySet().removeIf(entry -> !entry.getValue().isAfter(now));
|
||||
ipLockAccounts.keySet().retainAll(ipLocks.keySet());
|
||||
if(ipLocks.containsKey(state.remoteIp) || (account != null && (future(account, "lockedUntil") || "LOCKED".equals(account.getValueString("status")))))
|
||||
{
|
||||
return (outcome(state, "LOCKED_OUT", "Authentication locked"));
|
||||
}
|
||||
if(account == null)
|
||||
{
|
||||
return (failed(state, null, "AUTH_FAILED", "Unknown account or key"));
|
||||
}
|
||||
if(!"ACTIVE".equals(account.getValueString("status")))
|
||||
{
|
||||
return (failed(state, account, "DISABLED", "Account disabled"));
|
||||
}
|
||||
if(expired(account, "expiresAt"))
|
||||
{
|
||||
return (failed(state, account, "EXPIRED", "Account expired"));
|
||||
}
|
||||
if(!CidrMatcher.matches(state.remoteIp, account.getValueString("allowedCidrs")))
|
||||
{
|
||||
return (failed(state, account, "IP_DENIED", "Address outside allowlist"));
|
||||
}
|
||||
QRecord key = first(FileGatewayTables.SSH_KEY, "fingerprint", fingerprint);
|
||||
if(key == null || !account.getValue("id").equals(key.getValue("accountId")) || key.getValue("revokedAt") != null)
|
||||
{
|
||||
return (failed(state, account, "AUTH_FAILED", "Unknown or revoked key"));
|
||||
}
|
||||
if(expired(key, "expiresAt"))
|
||||
{
|
||||
return (failed(state, account, "EXPIRED", "Key expired"));
|
||||
}
|
||||
int limit = account.getValueInteger("maxConcurrentSessions") == null ? config.getDefaultMaxConcurrentSessions() : account.getValueInteger("maxConcurrentSessions");
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// only live rows: connected, not disconnected, seen in the last 3 min //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QQueryFilter liveFilter = filter("accountId", account.getValue("id"))
|
||||
.withCriteria(new QFilterCriteria("outcome", QCriteriaOperator.EQUALS, "CONNECTED"))
|
||||
.withCriteria(new QFilterCriteria("disconnectedAt", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("lastSeenAt", QCriteriaOperator.GREATER_THAN, now.minusSeconds(180)));
|
||||
long active = audit.query(FileGatewayTables.CONNECTION_LOG, liveFilter).size();
|
||||
if(active >= limit)
|
||||
{
|
||||
return (outcome(state, "LIMIT", "Concurrent session limit exceeded"));
|
||||
}
|
||||
state.account = account;
|
||||
state.key = key;
|
||||
try
|
||||
{
|
||||
state.token();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
state.account = null;
|
||||
state.key = null;
|
||||
return (failed(state, account, "AUTH_FAILED", "no file_gateway.connect"));
|
||||
}
|
||||
outcome(state, "CONNECTED", null);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MINA has verified the signature, not just accepted an unsigned key probe.
|
||||
*******************************************************************************/
|
||||
public synchronized void authenticated(ServerSession ssh) throws QException
|
||||
{
|
||||
GatewaySession state = ssh.getAttribute(GatewaySession.ATTRIBUTE);
|
||||
if(state == null || state.account == null)
|
||||
{
|
||||
throw (new QException("Missing authenticated gateway identity"));
|
||||
}
|
||||
QRecord account = state.account;
|
||||
QRecord key = state.key;
|
||||
Instant now = clock.instant();
|
||||
audit.update(FileGatewayTables.ACCOUNT, new QRecord().withValue("id", account.getValue("id"))
|
||||
.withValue("failedLoginCount", 0).withValue("failedLoginWindowAt", null)
|
||||
.withValue("lastLoginAt", now).withValue("lastLoginIp", state.remoteIp));
|
||||
audit.update(FileGatewayTables.SSH_KEY, new QRecord().withValue("id", key.getValue("id"))
|
||||
.withValue("lastUsedAt", now).withValue("lastUsedIp", state.remoteIp));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist account failure windows and apply the per-IP lockout.
|
||||
*******************************************************************************/
|
||||
private boolean failed(GatewaySession state, QRecord account, String result, String reason) throws QException
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
prune(failures, now.minus(config.getLockoutWindow()));
|
||||
failureAccounts.keySet().retainAll(failures.keySet());
|
||||
Deque<Instant> window = failures.computeIfAbsent(state.remoteIp, ignored -> new ArrayDeque<>());
|
||||
String username = account == null ? "" : account.getValueString("username");
|
||||
if(window.isEmpty())
|
||||
{
|
||||
failureAccounts.put(state.remoteIp, username);
|
||||
}
|
||||
else if(!username.equals(failureAccounts.get(state.remoteIp)))
|
||||
{
|
||||
failureAccounts.put(state.remoteIp, "");
|
||||
}
|
||||
window.addLast(now);
|
||||
boolean lock = window.size() >= config.getLockoutThreshold();
|
||||
if(lock)
|
||||
{
|
||||
ipLocks.put(state.remoteIp, now.plus(config.getLockoutDuration()));
|
||||
ipLockAccounts.put(state.remoteIp, failureAccounts.get(state.remoteIp));
|
||||
}
|
||||
if(account != null)
|
||||
{
|
||||
Instant start = account.getValueInstant("failedLoginWindowAt");
|
||||
Integer oldCount = account.getValueInteger("failedLoginCount");
|
||||
int count = start == null || !start.plus(config.getLockoutWindow()).isAfter(now) ? 1 : (oldCount == null ? 0 : oldCount) + 1;
|
||||
QRecord update = new QRecord().withValue("id", account.getValue("id")).withValue("failedLoginCount", count)
|
||||
.withValue("failedLoginWindowAt", count == 1 ? now : start);
|
||||
if(count >= config.getLockoutThreshold())
|
||||
{
|
||||
update.setValue("lockedUntil", now.plus(config.getLockoutDuration()));
|
||||
lock = true;
|
||||
}
|
||||
audit.update(FileGatewayTables.ACCOUNT, update);
|
||||
}
|
||||
if(lock)
|
||||
{
|
||||
audit.system(() ->
|
||||
{
|
||||
QAlerts.raise(new AlertSignal("filegateway.lockout").withDedupKey(account == null ? state.remoteIp : account.getValueString("id"))
|
||||
.withSummary("File gateway authentication locked"));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
return (outcome(state, result, reason));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Write the connection-log row on first need (an auth attempt or a refusal).
|
||||
** Its outcome stays AUTH_FAILED until the sign-in succeeds.
|
||||
*******************************************************************************/
|
||||
private void ensureConnectionRow(GatewaySession state) throws QException
|
||||
{
|
||||
if(state.connectionId == null)
|
||||
{
|
||||
Instant at = state.connectedAt == null ? clock.instant() : state.connectedAt;
|
||||
state.connectionId = audit.insert(FileGatewayTables.CONNECTION_LOG, new QRecord()
|
||||
.withValue("sessionId", state.id).withValue("remoteIp", state.remoteIp).withValue("podName", podName)
|
||||
.withValue("connectedAt", at).withValue("lastSeenAt", clock.instant())
|
||||
.withValue("clientChannel", "sftp").withValue("outcome", "AUTH_FAILED")).getValueInteger("id");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the authentication outcome without credentials.
|
||||
*******************************************************************************/
|
||||
private boolean outcome(GatewaySession state, String outcome, String reason) throws QException
|
||||
{
|
||||
ensureConnectionRow(state);
|
||||
audit.update(FileGatewayTables.CONNECTION_LOG, new QRecord().withValue("id", state.connectionId)
|
||||
.withValue("outcome", outcome).withValue("disconnectReason", reason).withValue("lastSeenAt", clock.instant()));
|
||||
if(reason != null)
|
||||
{
|
||||
state.disconnectReason = reason;
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Revoke credentials and finalize connection accounting.
|
||||
*******************************************************************************/
|
||||
public synchronized void closed(ServerSession ssh) throws QException
|
||||
{
|
||||
GatewaySession state = ssh.removeAttribute(GatewaySession.ATTRIBUTE);
|
||||
if(state == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
state.revoke();
|
||||
sessions.remove(state.id);
|
||||
if(state.account != null && !ssh.isAuthenticated())
|
||||
{
|
||||
outcome(state, "AUTH_FAILED", "Public key signature was not verified");
|
||||
}
|
||||
if(state.connectionId != null)
|
||||
{
|
||||
audit.update(FileGatewayTables.CONNECTION_LOG, new QRecord().withValue("id", state.connectionId)
|
||||
.withValue("disconnectedAt", clock.instant()).withValue("disconnectReason", state.disconnectReason)
|
||||
.withValue("bytesIn", 0L).withValue("bytesOut", 0L)
|
||||
.withValue("operationCount", state.operations));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finish any callbacks still queued when MINA has stopped accepting work.
|
||||
*******************************************************************************/
|
||||
public synchronized void shutdown() throws QException
|
||||
{
|
||||
for(GatewaySession state : List.copyOf(sessions.values()))
|
||||
{
|
||||
closed(state.ssh);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read live accounts and keys and heartbeat open connection rows.
|
||||
*******************************************************************************/
|
||||
public synchronized void poll() throws QException
|
||||
{
|
||||
for(GatewaySession state : List.copyOf(sessions.values()))
|
||||
{
|
||||
if(state.account == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord account = first(FileGatewayTables.ACCOUNT, "id", state.account.getValue("id"));
|
||||
QRecord key = first(FileGatewayTables.SSH_KEY, "id", state.key.getValue("id"));
|
||||
QRecord connection = first(FileGatewayTables.CONNECTION_LOG, "id", state.connectionId);
|
||||
if(account == null || key == null || !"ACTIVE".equals(account.getValueString("status"))
|
||||
|| expired(account, "expiresAt") || expired(key, "expiresAt") || key.getValue("revokedAt") != null
|
||||
|| connection == null || connection.getValue("disconnectedAt") != null || connection.getValue("endRequestedAt") != null)
|
||||
{
|
||||
state.disconnectReason = connection != null && connection.getValueString("disconnectReason") != null
|
||||
? connection.getValueString("disconnectReason") : "Account or key revoked";
|
||||
state.revoke();
|
||||
state.ssh.close(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
state.account = account;
|
||||
audit.update(FileGatewayTables.CONNECTION_LOG, new QRecord().withValue("id", state.connectionId)
|
||||
.withValue("lastSeenAt", clock.instant()).withValue("operationCount", state.operations));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find gateway-owned state.
|
||||
*******************************************************************************/
|
||||
private QRecord first(String table, String field, java.io.Serializable value) throws QException
|
||||
{
|
||||
return (audit.query(table, filter(field, value)).stream().findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Equality query for owned tables.
|
||||
*******************************************************************************/
|
||||
private QQueryFilter filter(String field, java.io.Serializable value)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a timestamp lies in the future.
|
||||
*******************************************************************************/
|
||||
private boolean future(QRecord row, String field)
|
||||
{
|
||||
Instant value = row.getValueInstant(field);
|
||||
return (value != null && value.isAfter(clock.instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an optional expiry has passed.
|
||||
*******************************************************************************/
|
||||
private boolean expired(QRecord row, String field)
|
||||
{
|
||||
Instant value = row.getValueInstant(field);
|
||||
return (value != null && !value.isAfter(clock.instant()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound in-memory IP windows to their active interval.
|
||||
*******************************************************************************/
|
||||
private void prune(Map<String, Deque<Instant>> windows, Instant cutoff)
|
||||
{
|
||||
windows.values().forEach(window -> window.removeIf(instant -> !instant.isAfter(cutoff)));
|
||||
windows.entrySet().removeIf(entry -> entry.getValue().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.storage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/*******************************************************************************
|
||||
** Streaming storage boundary. Production uses the object-storage system API.
|
||||
*******************************************************************************/
|
||||
public interface GatewayObjectStore
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Completed immutable object identity, measured while streaming.
|
||||
*******************************************************************************/
|
||||
record Receipt(long size, String sha256)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Consume an unknown-length stream; a read failure must abort multipart upload.
|
||||
*******************************************************************************/
|
||||
Receipt put(String key, InputStream content) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a fresh stream owned by the caller.
|
||||
*******************************************************************************/
|
||||
InputStream open(String key) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Open an inclusive byte range, owned by the caller.
|
||||
*******************************************************************************/
|
||||
InputStream openRange(String key, long start, long end) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy within the configured bucket.
|
||||
*******************************************************************************/
|
||||
void copy(String source, String destination) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Permanently remove an aborted or quarantined source object.
|
||||
*******************************************************************************/
|
||||
void delete(String key) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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.filegateway.storage;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.NonWritableChannelException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A generated stream with a fixed-size rolling window; never regenerates or spools.
|
||||
*******************************************************************************/
|
||||
public final class GeneratedReadChannel implements SeekableByteChannel
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Charge newly consumed bytes and finalize the transfer when its handle closes.
|
||||
*******************************************************************************/
|
||||
public interface Audit
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Enforce limits before exposing the newly generated bytes to the client.
|
||||
*******************************************************************************/
|
||||
void charge(int bytes) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the final outcome, including stream and seek errors.
|
||||
*******************************************************************************/
|
||||
void finish(IOException failure) throws IOException;
|
||||
}
|
||||
|
||||
private final InputStream input;
|
||||
private final byte[] window;
|
||||
private final Audit audit;
|
||||
private long end;
|
||||
private long position;
|
||||
private boolean eof;
|
||||
private boolean open = true;
|
||||
private IOException failure;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The supplied stream is owned by this channel from construction through close.
|
||||
*******************************************************************************/
|
||||
public GeneratedReadChannel(InputStream input, int windowBytes, Audit audit)
|
||||
{
|
||||
if(windowBytes < 1)
|
||||
{
|
||||
throw (new IllegalArgumentException("Read-ahead window must be positive"));
|
||||
}
|
||||
this.input = input;
|
||||
this.window = new byte[windowBytes];
|
||||
this.audit = audit;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain the most recent window, including skipped bytes for pipelined reads.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized int read(ByteBuffer destination) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
int total = 0;
|
||||
while(destination.hasRemaining())
|
||||
{
|
||||
int count = readChunk(destination);
|
||||
if(count < 0)
|
||||
{
|
||||
return (total == 0 ? -1 : total);
|
||||
}
|
||||
total += count;
|
||||
}
|
||||
return (total);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill one ring segment; the public read fills its request to avoid false MINA EOF.
|
||||
*******************************************************************************/
|
||||
private int readChunk(ByteBuffer destination) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
if(failure != null)
|
||||
{
|
||||
throw (failure);
|
||||
}
|
||||
if(!destination.hasRemaining())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
try
|
||||
{
|
||||
if(eof && position >= end)
|
||||
{
|
||||
return (-1);
|
||||
}
|
||||
if(position < Math.max(0, end - window.length) || position - end >= window.length)
|
||||
{
|
||||
throw (new IOException("Read outside the generated-file window at " + position + " (stream offset " + end + "); download sequentially"));
|
||||
}
|
||||
while(position >= end && !eof)
|
||||
{
|
||||
int offset = (int) (end % window.length);
|
||||
int length = (int) Math.min(Math.min(65536, window.length - offset), position - end + Math.min(destination.remaining(), window.length));
|
||||
int count = input.readNBytes(window, offset, length);
|
||||
if(count == 0)
|
||||
{
|
||||
eof = true;
|
||||
}
|
||||
else if(count > 0)
|
||||
{
|
||||
audit.charge(count);
|
||||
end += count;
|
||||
}
|
||||
}
|
||||
if(position >= end)
|
||||
{
|
||||
return (-1);
|
||||
}
|
||||
int offset = (int) (position % window.length);
|
||||
int count = (int) Math.min(Math.min(destination.remaining(), end - position), window.length - offset);
|
||||
destination.put(window, offset, count);
|
||||
position += count;
|
||||
return (count);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
failure = e;
|
||||
try
|
||||
{
|
||||
input.close();
|
||||
}
|
||||
catch(IOException closeError)
|
||||
{
|
||||
e.addSuppressed(closeError);
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated content is read-only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int write(ByteBuffer source)
|
||||
{
|
||||
throw (new NonWritableChannelException());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current logical read offset.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized long position() throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
return (position);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate window membership on read, when an actual request is made.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized SeekableByteChannel position(long value) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
if(value < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Negative read offset"));
|
||||
}
|
||||
position = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated size is unknown; consumers must read until EOF.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long size() throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
return (0);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generated content cannot be truncated.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SeekableByteChannel truncate(long size)
|
||||
{
|
||||
throw (new NonWritableChannelException());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isOpen()
|
||||
{
|
||||
return (open);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always close the HTTP stream and finalize exactly one transfer row.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void close() throws IOException
|
||||
{
|
||||
if(open)
|
||||
{
|
||||
open = false;
|
||||
try
|
||||
{
|
||||
input.close();
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
failure = e;
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
audit.finish(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject operations after close.
|
||||
*******************************************************************************/
|
||||
private void checkOpen() throws ClosedChannelException
|
||||
{
|
||||
if(!open)
|
||||
{
|
||||
throw (new ClosedChannelException());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* 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.filegateway.storage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.NonWritableChannelException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
|
||||
/*******************************************************************************
|
||||
** Seekable range reads with bounded per-request memory and prompt response cleanup.
|
||||
*******************************************************************************/
|
||||
public final class ObjectReadChannel implements SeekableByteChannel
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Open an inclusive range of an immutable object or generated sidecar.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Ranges
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Caller closes the response stream.
|
||||
*******************************************************************************/
|
||||
InputStream open(long start, long end) throws IOException;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist one transfer outcome when the handle closes.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface Completion
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Report actual bytes sent and any read failure.
|
||||
*******************************************************************************/
|
||||
void done(long bytes, boolean failed) throws IOException;
|
||||
}
|
||||
|
||||
private final long size;
|
||||
private final Ranges ranges;
|
||||
private final Completion completion;
|
||||
private long bytesRead;
|
||||
private Completion fullDownload;
|
||||
private long contiguousEnd;
|
||||
private final java.util.TreeMap<Long, Long> readRanges = new java.util.TreeMap<>();
|
||||
private boolean failed;
|
||||
private long position;
|
||||
private boolean open = true;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind a stored size and ranged stream supplier.
|
||||
*******************************************************************************/
|
||||
public ObjectReadChannel(long size, Ranges ranges)
|
||||
{
|
||||
this(size, ranges, (bytes, failure) ->
|
||||
{
|
||||
// Direct channel users may omit a transfer audit callback.
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind an audited object read.
|
||||
*******************************************************************************/
|
||||
public ObjectReadChannel(long size, Ranges ranges, Completion completion)
|
||||
{
|
||||
this.size = size;
|
||||
this.ranges = ranges;
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** At most 64 KiB is buffered, regardless of object size or seek distance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int read(ByteBuffer destination) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
if(!destination.hasRemaining())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
if(position >= size)
|
||||
{
|
||||
return (-1);
|
||||
}
|
||||
int length = (int) Math.min(Math.min(destination.remaining(), 65536), size - position);
|
||||
try(InputStream input = ranges.open(position, position + length - 1))
|
||||
{
|
||||
byte[] bytes = input.readNBytes(length);
|
||||
if(bytes.length == 0)
|
||||
{
|
||||
throw (new IOException("Stored object ended before its recorded size"));
|
||||
}
|
||||
destination.put(bytes);
|
||||
recordRange(position, position + bytes.length);
|
||||
position += bytes.length;
|
||||
bytesRead += bytes.length;
|
||||
return (bytes.length);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
failed = true;
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored objects are immutable.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int write(ByteBuffer source)
|
||||
{
|
||||
throw (new NonWritableChannelException());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Current read offset.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long position() throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
return (position);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Seek without reading preceding bytes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SeekableByteChannel position(long value) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
if(value < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Negative read offset"));
|
||||
}
|
||||
position = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted object length.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long size()
|
||||
{
|
||||
return (size);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored objects cannot be truncated.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SeekableByteChannel truncate(long value)
|
||||
{
|
||||
throw (new NonWritableChannelException());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle state.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isOpen()
|
||||
{
|
||||
return (open);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No stream is retained between reads.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
if(open)
|
||||
{
|
||||
open = false;
|
||||
completion.done(bytesRead, failed);
|
||||
if(!failed && contiguousEnd >= size && fullDownload != null)
|
||||
{
|
||||
fullDownload.done(bytesRead, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional acknowledgment, only after all bytes have been read on this handle.
|
||||
*******************************************************************************/
|
||||
public ObjectReadChannel withFullDownload(Completion callback)
|
||||
{
|
||||
fullDownload = callback;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded interval tracking avoids counting repeated ranged reads as a complete file.
|
||||
*******************************************************************************/
|
||||
private void recordRange(long start, long end)
|
||||
{
|
||||
if(start <= contiguousEnd)
|
||||
{
|
||||
contiguousEnd = Math.max(contiguousEnd, end);
|
||||
}
|
||||
else if(readRanges.size() < 4096)
|
||||
{
|
||||
readRanges.merge(start, end, Math::max);
|
||||
}
|
||||
while(!readRanges.isEmpty() && readRanges.firstKey() <= contiguousEnd)
|
||||
{
|
||||
contiguousEnd = Math.max(contiguousEnd, readRanges.pollFirstEntry().getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject use after close.
|
||||
*******************************************************************************/
|
||||
private void checkOpen() throws ClosedChannelException
|
||||
{
|
||||
if(!open)
|
||||
{
|
||||
throw (new ClosedChannelException());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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.filegateway.storage;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.objectstorage.api.CopyRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
|
||||
import com.kingsrook.qbits.objectstorage.api.PutRequest;
|
||||
import com.kingsrook.qbits.objectstorage.api.Range;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the allow-listed object-storage API is used, under a named system caller.
|
||||
*******************************************************************************/
|
||||
public final class ObjectStorageGatewayStore implements GatewayObjectStore
|
||||
{
|
||||
private final AuditWriter context;
|
||||
private final String alias;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the host instance and bucket alias without touching storage at startup.
|
||||
*******************************************************************************/
|
||||
public ObjectStorageGatewayStore(QInstance instance, String alias)
|
||||
{
|
||||
context = new AuditWriter(instance);
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown-length puts use object-storage's bounded multipart implementation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Receipt put(String key, InputStream content) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
var result = context.system(() -> ObjectStorage.asSystem("file-gateway", key).bucket(alias)
|
||||
.put(PutRequest.of(key, new DigestInputStream(content, digest))));
|
||||
return (new Receipt(result.getBytes(), HexFormat.of().formatHex(digest.digest())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IOException("Object upload failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream the complete object.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream open(String key) throws IOException
|
||||
{
|
||||
return (read(key, Range.all()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream only the requested range.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public InputStream openRange(String key, long start, long end) throws IOException
|
||||
{
|
||||
return (read(key, Range.bytes(start, end)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Closing the returned stream also releases its object response.
|
||||
*******************************************************************************/
|
||||
private InputStream read(String key, Range range) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
var object = context.system(() -> ObjectStorage.asSystem("file-gateway", key).bucket(alias).get(key, range));
|
||||
return (new FilterInputStream(object.getInputStream())
|
||||
{
|
||||
@Override
|
||||
public void close() throws IOException
|
||||
{
|
||||
object.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IOException("Object read failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-side quarantine copy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void copy(String source, String destination) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
context.system(() ->
|
||||
{
|
||||
ObjectStorage.asSystem("file-gateway", source).bucket(alias).copy(CopyRequest.of(source, destination));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IOException("Object copy failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove all versions of a gateway-owned object.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void delete(String key) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
context.system(() -> ObjectStorage.asSystem("file-gateway", key).bucket(alias).delete(DeleteRequest.of(key).permanent()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IOException("Object deletion failed", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.filegateway.api.FileAcceptance;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayConsumer;
|
||||
import com.kingsrook.qbits.filegateway.api.InboundFile;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
|
||||
/*******************************************************************************
|
||||
** Drives the real bulk-insert UI process as the user; never inserts application rows directly.
|
||||
*******************************************************************************/
|
||||
public final class BulkLoadConsumer implements FileGatewayConsumer
|
||||
{
|
||||
private final WorkerUserSession session;
|
||||
private final QRecord job;
|
||||
private QRecord outcome;
|
||||
|
||||
/*******************************************************************************
|
||||
** One consumer instance per delivery, holding only that process's outcome.
|
||||
*******************************************************************************/
|
||||
public BulkLoadConsumer(WorkerUserSession session, QRecord job)
|
||||
{
|
||||
this.session = session;
|
||||
this.job = job;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable built-in registry name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("bulk-load");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Follow returned frontend steps; the API resumes backend steps after each one.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileAcceptance accept(InboundFile file) throws Exception
|
||||
{
|
||||
String process = job.getValueString("tableName") + ".bulkInsert";
|
||||
JsonNode profiles = session.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/query")
|
||||
.withPathParam("tableName", "savedBulkLoadProfile").withJsonBody(Map.of("filter", UploadJobs.equal("id", job.getValue("savedBulkLoadProfileId"))))).path("records");
|
||||
if(profiles.size() != 1 || !job.getValueString("tableName").equals(profiles.get(0).path("values").path("tableName").asText()))
|
||||
{
|
||||
return (failed("Saved bulk load profile is unavailable"));
|
||||
}
|
||||
JsonNode mapping = new ObjectMapper().readTree(profiles.get(0).path("values").path("mappingJson").asText());
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
values.put("savedBulkLoadProfileId", job.getValue("savedBulkLoadProfileId"));
|
||||
values.put("version", mapping.path("version").asText());
|
||||
values.put("layout", mapping.path("layout").asText());
|
||||
values.put("hasHeaderRow", mapping.path("hasHeaderRow").asBoolean());
|
||||
values.put("fieldListJSON", mapping.path("fieldList").toString());
|
||||
for(JsonNode field : mapping.path("fieldList"))
|
||||
{
|
||||
if(field.path("doValueMapping").asBoolean() && field.path("valueMappings").isEmpty())
|
||||
{
|
||||
return (failed("this profile needs value mappings saved in it"));
|
||||
}
|
||||
}
|
||||
JsonNode response = session.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/processes/{processName}/init")
|
||||
.withPathParam("processName", process).withFormParam("values", new ObjectMapper().writeValueAsString(Map.of("tableName", job.getValueString("tableName"))))
|
||||
.withMultipartFile("theFile", file.fileName(), "application/octet-stream", file.streamSupplier()));
|
||||
String uuid = response.path("processUUID").asText();
|
||||
job.withValue("processUuid", uuid);
|
||||
for(int steps = 0; steps < 20; steps++)
|
||||
{
|
||||
response = await(process, uuid, response);
|
||||
if("ERROR".equals(response.path("type").asText()))
|
||||
{
|
||||
return (failed(response.path("userFacingError").asText("Bulk load process failed")));
|
||||
}
|
||||
String next = response.path("nextStep").asText();
|
||||
if(next.equals("valueMapping"))
|
||||
{
|
||||
return (failed("this profile needs value mappings saved in it"));
|
||||
}
|
||||
if(response.path("values").has("processResults"))
|
||||
{
|
||||
summarize(response.path("values").path("processResults"));
|
||||
return (FileAcceptance.accepted(uuid));
|
||||
}
|
||||
if(!java.util.List.of("upload", "fileMapping", "review").contains(next))
|
||||
{
|
||||
return (failed("Unexpected bulk load step: " + next));
|
||||
}
|
||||
Map<String, Object> stepValues = next.equals("fileMapping") ? values : Map.of();
|
||||
UiApiRequest step = new UiApiRequest().withMethod(HttpMethod.POST).withPath("/processes/{processName}/{processUUID}/step/{stepName}")
|
||||
.withPathParam("processName", process).withPathParam("processUUID", uuid).withPathParam("stepName", next);
|
||||
if(!stepValues.isEmpty())
|
||||
{
|
||||
// the invoker sends form fields as multipart for this multipart-only operation (qqq#1170)
|
||||
step.withFormParam("values", new ObjectMapper().writeValueAsString(stepValues));
|
||||
}
|
||||
response = session.invoke(step);
|
||||
}
|
||||
return (failed("Bulk load exceeded its step limit"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Asynchronous UI API steps retain their job UUID across RUNNING responses.
|
||||
*******************************************************************************/
|
||||
private JsonNode await(String process, String uuid, JsonNode response) throws Exception
|
||||
{
|
||||
String async = response.path("jobUUID").asText();
|
||||
for(int poll = 0; poll < 3600 && ("JOB_STARTED".equals(response.path("type").asText()) || "RUNNING".equals(response.path("type").asText())); poll++)
|
||||
{
|
||||
Thread.sleep(100);
|
||||
response = session.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath("/processes/{processName}/{processUUID}/status/{jobUUID}")
|
||||
.withPathParam("processName", process).withPathParam("processUUID", uuid).withPathParam("jobUUID", async));
|
||||
}
|
||||
if("RUNNING".equals(response.path("type").asText()))
|
||||
{
|
||||
throw (new IOException("Bulk load status timed out"));
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the API's detailed summary (including row examples) for the result artifact.
|
||||
*******************************************************************************/
|
||||
private void summarize(JsonNode lines)
|
||||
{
|
||||
int inserted = 0;
|
||||
int errors = 0;
|
||||
StringBuilder summary = new StringBuilder();
|
||||
StringBuilder csv = new StringBuilder("row,field,message\r\n");
|
||||
for(JsonNode line : lines)
|
||||
{
|
||||
String status = line.path("status").asText();
|
||||
int count = line.path("count").asInt();
|
||||
String message = line.path("message").asText();
|
||||
if("OK".equals(status))
|
||||
{
|
||||
inserted += count;
|
||||
}
|
||||
if("ERROR".equals(status))
|
||||
{
|
||||
errors += count;
|
||||
}
|
||||
summary.append(count).append(" ").append(message).append("\n");
|
||||
csv.append(",,").append(csvCell(count + " " + message)).append("\r\n");
|
||||
for(JsonNode bullet : line.path("bulletsOfText"))
|
||||
{
|
||||
String detail = bullet.asText();
|
||||
summary.append(detail).append("\n");
|
||||
var row = java.util.regex.Pattern.compile("(?i)(?:row[^0-9]*)?([0-9]+)").matcher(detail);
|
||||
csv.append(row.find() ? row.group(1) : "").append(",,").append(csvCell(message + " " + detail)).append("\r\n");
|
||||
}
|
||||
}
|
||||
outcome = new QRecord().withValue("status", errors == 0 ? "SUCCEEDED" : inserted == 0 ? "FAILED" : "PARTIAL")
|
||||
.withValue("insertedCount", inserted).withValue("errorCount", errors).withValue("resultSummary", summary.toString()).withValue("_resultsCsv", csv.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Quote CSV cells, including embedded commas, newlines and quotes.
|
||||
*******************************************************************************/
|
||||
private String csvCell(String value)
|
||||
{
|
||||
return ("\"" + value.replace("\"", "\"\"") + "\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Process errors are terminal rather than silently replaying an executed import.
|
||||
*******************************************************************************/
|
||||
private FileAcceptance failed(String message)
|
||||
{
|
||||
outcome = new QRecord().withValue("status", "FAILED").withValue("errorMessage", message).withValue("resultSummary", message);
|
||||
return (FileAcceptance.accepted(job.getValueString("processUuid")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Worker persists counts and status along with the consumer reference.
|
||||
*******************************************************************************/
|
||||
public QRecord outcome()
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PipedInputStream;
|
||||
import java.io.PipedOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.NonReadableChannelException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
|
||||
/*******************************************************************************
|
||||
** Sequential storage stream with a bounded SFTP reorder window and backpressure.
|
||||
*******************************************************************************/
|
||||
public final class UploadChannel implements SeekableByteChannel
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Persist reservations and terminal state before acknowledging SFTP operations.
|
||||
*******************************************************************************/
|
||||
public interface Lifecycle
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Atomically reserve the current high-water size against account limits.
|
||||
*******************************************************************************/
|
||||
void reserve(long size) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Commit a completed object.
|
||||
*******************************************************************************/
|
||||
void complete(GatewayObjectStore.Receipt receipt) throws IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist an aborted transfer and its reason.
|
||||
*******************************************************************************/
|
||||
void abort(String message, boolean limit) throws IOException;
|
||||
}
|
||||
|
||||
private final GatewayObjectStore store;
|
||||
private final String key;
|
||||
private final Lifecycle lifecycle;
|
||||
private final long window;
|
||||
private final PipedInputStream input;
|
||||
private final PipedOutputStream output;
|
||||
private final CompletableFuture<GatewayObjectStore.Receipt> result = new CompletableFuture<>();
|
||||
private final TreeMap<Long, byte[]> pending = new TreeMap<>();
|
||||
private volatile boolean aborted;
|
||||
private boolean open = true;
|
||||
private long position;
|
||||
private long contiguous;
|
||||
private long size;
|
||||
|
||||
/*******************************************************************************
|
||||
** A virtual thread consumes the pipe; only bounded buffers hold upload content.
|
||||
*******************************************************************************/
|
||||
public UploadChannel(GatewayObjectStore store, String key, int bufferSize, long window, Lifecycle lifecycle) throws IOException
|
||||
{
|
||||
this.store = store;
|
||||
this.key = key;
|
||||
this.lifecycle = lifecycle;
|
||||
this.window = window;
|
||||
input = new PipedInputStream(bufferSize);
|
||||
output = new PipedOutputStream(input);
|
||||
Thread.startVirtualThread(() ->
|
||||
{
|
||||
try(var source = new FilterInputStream(input)
|
||||
{
|
||||
@Override
|
||||
public int read(byte[] bytes, int offset, int length) throws IOException
|
||||
{
|
||||
if(aborted)
|
||||
{
|
||||
throw (new IOException("Upload aborted"));
|
||||
}
|
||||
int count = super.read(bytes, offset, length);
|
||||
if(aborted)
|
||||
{
|
||||
throw (new IOException("Upload aborted"));
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
})
|
||||
{
|
||||
var receipt = store.put(key, source);
|
||||
result.complete(receipt);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
result.completeExceptionally(e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(aborted)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.delete(key);
|
||||
}
|
||||
catch(IOException ignored)
|
||||
{
|
||||
// The caller also deletes after waiting for completion.
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail closed and release the streaming producer before deleting the object.
|
||||
*******************************************************************************/
|
||||
private void fail(String message, boolean limit) throws IOException
|
||||
{
|
||||
aborted = true;
|
||||
open = false;
|
||||
pending.clear();
|
||||
output.close();
|
||||
input.close();
|
||||
try
|
||||
{
|
||||
result.join();
|
||||
}
|
||||
catch(RuntimeException ignored)
|
||||
{
|
||||
// A failing input is how the storage client aborts multipart uploads.
|
||||
}
|
||||
try
|
||||
{
|
||||
store.delete(key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lifecycle.abort(message, limit);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Writes behind the committed prefix and overlapping blocks are unsupported.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized int write(ByteBuffer source) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
int count = source.remaining();
|
||||
if(count == 0)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
try
|
||||
{
|
||||
long end = Math.addExact(position, count);
|
||||
var before = pending.floorEntry(position);
|
||||
var after = pending.ceilingEntry(position);
|
||||
if(position < contiguous || (before != null && before.getKey() + before.getValue().length > position)
|
||||
|| (after != null && after.getKey() < end) || end - contiguous > window)
|
||||
{
|
||||
throw (new IOException("Upload exceeds reorder window or overlaps an earlier write"));
|
||||
}
|
||||
lifecycle.reserve(Math.max(size, end));
|
||||
byte[] bytes = new byte[count];
|
||||
source.get(bytes);
|
||||
pending.put(position, bytes);
|
||||
size = Math.max(size, end);
|
||||
position = end;
|
||||
while(pending.containsKey(contiguous))
|
||||
{
|
||||
byte[] next = pending.remove(contiguous);
|
||||
output.write(next);
|
||||
contiguous += next.length;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
boolean limit = e instanceof UploadLimitException;
|
||||
fail(limit ? e.getMessage() : "Upload stream failed", limit);
|
||||
throw (new IOException(limit ? e.getMessage() : "Upload stream failed", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete only a contiguous upload; close of a temporary file does not queue it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void close() throws IOException
|
||||
{
|
||||
if(!open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
if(!pending.isEmpty())
|
||||
{
|
||||
throw (new IOException("Upload has missing blocks"));
|
||||
}
|
||||
output.close();
|
||||
lifecycle.complete(result.join());
|
||||
open = false;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
fail("Upload did not complete", false);
|
||||
throw (new IOException("Upload did not complete", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify this handle is usable.
|
||||
*******************************************************************************/
|
||||
private void checkOpen() throws ClosedChannelException
|
||||
{
|
||||
if(!open)
|
||||
{
|
||||
throw (new ClosedChannelException());
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upload handles cannot read.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int read(ByteBuffer destination)
|
||||
{
|
||||
throw (new NonReadableChannelException());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Current SFTP write offset.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized long position() throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
return (position);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SFTP clients set the offset before each write.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized SeekableByteChannel position(long value) throws IOException
|
||||
{
|
||||
checkOpen();
|
||||
if(value < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Negative upload offset"));
|
||||
}
|
||||
position = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Highest received offset.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized long size()
|
||||
{
|
||||
return (size);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Arbitrary truncation would invalidate a streamed object.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SeekableByteChannel truncate(long value) throws IOException
|
||||
{
|
||||
if(value != size)
|
||||
{
|
||||
throw (new IOException("Upload truncation is unsupported"));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle lifecycle.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isOpen()
|
||||
{
|
||||
return (open);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.api.FileAcceptance;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayConsumer;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayConsumersMetaData;
|
||||
import com.kingsrook.qbits.filegateway.api.FileGatewayScannerInterface;
|
||||
import com.kingsrook.qbits.filegateway.api.InboundFile;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded, at-least-once upload delivery with fenced claims and deterministic polling.
|
||||
*******************************************************************************/
|
||||
public class UploadJobWorker implements AutoCloseable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(UploadJobWorker.class);
|
||||
private static final long[] RETRY_SECONDS = {60, 300, 900, 3600, 21600};
|
||||
private final QInstance instance;
|
||||
private final UiApiInvoker invoker;
|
||||
private final FileGatewayQBitConfig config;
|
||||
private final GatewayObjectStore store;
|
||||
private final AuditWriter audit;
|
||||
private final Clock clock;
|
||||
private final String pod;
|
||||
private java.util.function.Function<String, String> environment = System::getenv;
|
||||
private volatile boolean stopped;
|
||||
private ScheduledExecutorService pool;
|
||||
|
||||
/*******************************************************************************
|
||||
** No threads are started until the enabled runtime explicitly starts this worker.
|
||||
*******************************************************************************/
|
||||
public UploadJobWorker(QInstance instance, UiApiInvoker invoker, FileGatewayQBitConfig config, GatewayObjectStore store, Clock clock, String pod)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.invoker = invoker;
|
||||
this.config = config;
|
||||
this.store = store;
|
||||
this.clock = clock;
|
||||
this.pod = pod == null ? java.util.UUID.randomUUID().toString() : pod;
|
||||
audit = new AuditWriter(instance);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the host-key environment seam; secret values are never persisted or logged.
|
||||
*******************************************************************************/
|
||||
public UploadJobWorker withEnvironment(java.util.function.Function<String, String> environment)
|
||||
{
|
||||
this.environment = environment;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each scheduled lane handles one job at a time, without an unbounded work queue.
|
||||
*******************************************************************************/
|
||||
public void start(Duration interval)
|
||||
{
|
||||
if(!config.getSftpEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
pool = Executors.newScheduledThreadPool(config.getUploadWorkerThreads(), runnable ->
|
||||
{
|
||||
Thread thread = new Thread(runnable, "file-gateway-upload");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
for(int lane = 0; lane < config.getUploadWorkerThreads(); lane++)
|
||||
{
|
||||
pool.scheduleWithFixedDelay(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
runOnce();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Upload worker could not persist job state");
|
||||
}
|
||||
}, interval.toMillis(), interval.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconcile stale leases and synchronously deliver at most one eligible job.
|
||||
*******************************************************************************/
|
||||
public boolean runOnce() throws QException
|
||||
{
|
||||
if(stopped)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
requeueStale();
|
||||
for(QRecord candidate : audit.query(FileGatewayTables.UPLOAD_JOB, UploadJobs.equal("status", "QUEUED")))
|
||||
{
|
||||
if(candidate.getValueInstant("nextAttemptAt") != null && candidate.getValueInstant("nextAttemptAt").isAfter(clock.instant()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord claimed = claim(candidate);
|
||||
if(claimed != null)
|
||||
{
|
||||
deliver(claimed);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The expected status is checked inside the account transaction; competing pods cannot both win.
|
||||
*******************************************************************************/
|
||||
public QRecord claim(QRecord candidate) throws QException
|
||||
{
|
||||
return (audit.withAccountLock("id", candidate.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord current = find(candidate);
|
||||
if(stopped || !"QUEUED".equals(current.getValueString("status"))
|
||||
|| (current.getValueInstant("nextAttemptAt") != null && current.getValueInstant("nextAttemptAt").isAfter(clock.instant())))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord changes = new QRecord().withValue("id", current.getValue("id")).withValue("status", "RUNNING")
|
||||
.withValue("claimedBy", pod).withValue("claimedAt", clock.instant()).withValue("startedAt", clock.instant())
|
||||
.withValue("attempts", attempts(current) + 1);
|
||||
return (audit.transitionUpload(changes, "QUEUED") ? find(current) : null);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Requeue abandoned leases up to three starts, fencing late completions by claim time.
|
||||
*******************************************************************************/
|
||||
public void requeueStale() throws QException
|
||||
{
|
||||
for(QRecord candidate : audit.query(FileGatewayTables.UPLOAD_JOB, UploadJobs.equal("status", "RUNNING")))
|
||||
{
|
||||
audit.withAccountLock("id", candidate.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord job = find(candidate);
|
||||
if("RUNNING".equals(job.getValueString("status")) && job.getValueInstant("claimedAt") != null
|
||||
&& !job.getValueInstant("claimedAt").plus(config.getJobStaleAfter()).isAfter(clock.instant()))
|
||||
{
|
||||
QRecord changes = new QRecord().withValue("id", job.getValue("id"))
|
||||
.withValue("status", attempts(job) >= 3 ? "FAILED" : "QUEUED").withValue("claimedBy", null).withValue("claimedAt", null)
|
||||
.withValue("errorMessage", "Upload worker lease expired").withValue("nextAttemptAt", clock.instant());
|
||||
if(attempts(job) >= 3)
|
||||
{
|
||||
changes.withValue("finishedAt", clock.instant());
|
||||
}
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, changes);
|
||||
QRecord account = audit.query(FileGatewayTables.ACCOUNT, UploadJobs.equal("id", job.getValue("accountId"))).getFirst();
|
||||
UploadNotifications.send(audit, job, changes, account);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh user context is scoped to each delivery and always restored and revoked.
|
||||
*******************************************************************************/
|
||||
private void deliver(QRecord job) throws QException
|
||||
{
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QRecord account = audit.query(FileGatewayTables.ACCOUNT, UploadJobs.equal("id", job.getValue("accountId"))).getFirst();
|
||||
if(!"ACTIVE".equals(account.getValueString("status"))
|
||||
|| (account.getValueInstant("expiresAt") != null && !account.getValueInstant("expiresAt").isAfter(clock.instant())))
|
||||
{
|
||||
finish(job, new QRecord().withValue("status", "REJECTED").withValue("errorMessage", "Gateway account is unavailable"), account);
|
||||
return;
|
||||
}
|
||||
try(var user = new WorkerUserSession(instance, invoker, account.getValueString("userId"), "upload-" + job.getValue("id"), clock))
|
||||
{
|
||||
InboundFile file = inbound(job, account);
|
||||
if(config.getScanner() != null)
|
||||
{
|
||||
var scan = QCodeLoader.getAdHoc(FileGatewayScannerInterface.class, config.getScanner()).scan(file);
|
||||
if(scan == FileGatewayScannerInterface.Result.INFECTED)
|
||||
{
|
||||
String quarantine = "file-gateway/quarantine/" + job.getValue("id") + "/" + job.getValueString("fileName");
|
||||
store.copy(file.storageKey(), quarantine);
|
||||
store.delete(file.storageKey());
|
||||
finish(job, new QRecord().withValue("status", "REJECTED").withValue("storageKey", quarantine)
|
||||
.withValue("errorMessage", "Scanner rejected infected file"), account);
|
||||
return;
|
||||
}
|
||||
if(scan != FileGatewayScannerInterface.Result.CLEAN)
|
||||
{
|
||||
retry(job, "Scanner unavailable", null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(Boolean.TRUE.equals(account.getValueBoolean("pgpDecryptUploads")) && job.getValueString("fileName").toLowerCase(java.util.Locale.ROOT).matches(".*[.](pgp|gpg)$"))
|
||||
{
|
||||
try
|
||||
{
|
||||
decrypt(job, account);
|
||||
file = inbound(job, account);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
finish(job, new QRecord().withValue("status", "REJECTED").withValue("errorMessage", "PGP decryption failed: invalid ciphertext, key, integrity or size limit"), account);
|
||||
return;
|
||||
}
|
||||
}
|
||||
FileGatewayConsumer consumer = consumer(job, user);
|
||||
if(consumer == null)
|
||||
{
|
||||
finish(job, new QRecord().withValue("status", "REJECTED").withValue("errorMessage", "Unknown consumer: " + job.getValueString("consumerName")), account);
|
||||
return;
|
||||
}
|
||||
FileAcceptance acceptance = consumer.accept(file);
|
||||
if(acceptance.getStatus() == FileAcceptance.Status.RETRY_LATER)
|
||||
{
|
||||
retry(job, acceptance.getReason(), acceptance.getRetryAfter());
|
||||
return;
|
||||
}
|
||||
QRecord outcome = consumer instanceof BulkLoadConsumer bulk ? bulk.outcome() : new QRecord()
|
||||
.withValue("status", acceptance.getStatus() == FileAcceptance.Status.ACCEPTED ? "SUCCEEDED" : "REJECTED")
|
||||
.withValue("errorMessage", acceptance.getReason());
|
||||
outcome.withValue("consumerReference", acceptance.getConsumerReference()).withValue("processUuid", job.getValue("processUuid"));
|
||||
finish(job, outcome, account);
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
retry(job, "Consumer delivery failed", null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the original for the archive and persist authenticated plaintext for retries.
|
||||
*******************************************************************************/
|
||||
private void decrypt(QRecord job, QRecord account) throws IOException, QException
|
||||
{
|
||||
if(job.getValue("plaintextStorageKey") != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String key = "file-gateway/plaintext/" + job.getValue("id") + "/" + java.util.UUID.randomUUID();
|
||||
String pass = config.getPgpPassphraseEnvVarName() == null ? null : environment.apply(config.getPgpPassphraseEnvVarName());
|
||||
char[] password = pass == null ? new char[0] : pass.toCharArray();
|
||||
try(var source = store.open(job.getValueString("storageKey"));
|
||||
var plaintext = com.kingsrook.qbits.filegateway.pgp.PgpStreams.decrypt(source,
|
||||
config.getPgpPrivateKeyEnvVarName() == null ? null : environment.apply(config.getPgpPrivateKeyEnvVarName()), password,
|
||||
(account.getValueInteger("maxFileSizeMb") == null ? config.getDefaultMaxFileSizeMb() : account.getValueInteger("maxFileSizeMb")) * UploadJobs.MIB))
|
||||
{
|
||||
var receipt = store.put(key, plaintext);
|
||||
QRecord changes = new QRecord().withValue("plaintextStorageKey", key).withValue("plaintextSha256", receipt.sha256()).withValue("plaintextSizeBytes", receipt.size());
|
||||
if(!persistOwned(job, changes))
|
||||
{
|
||||
throw (new IOException("Upload claim expired during PGP decryption"));
|
||||
}
|
||||
job.getValues().putAll(changes.getValues());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
store.delete(key);
|
||||
throw (new IOException("PGP decryption failed", e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
java.util.Arrays.fill(password, '\0');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry lookup is by declared name, with the built-in bulk-load consumer reserved.
|
||||
*******************************************************************************/
|
||||
private FileGatewayConsumer consumer(QRecord job, WorkerUserSession user)
|
||||
{
|
||||
String name = job.getValueString("consumerName");
|
||||
if("bulk-load".equals(name))
|
||||
{
|
||||
return (new BulkLoadConsumer(user, job));
|
||||
}
|
||||
for(var reference : FileGatewayConsumersMetaData.ofOrWithNew(instance).getConsumers())
|
||||
{
|
||||
FileGatewayConsumer consumer = QCodeLoader.getAdHoc(FileGatewayConsumer.class, reference);
|
||||
if(name.equals(consumer.getName()))
|
||||
{
|
||||
return (consumer);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable delivery envelope; each openStream gets an independent object stream.
|
||||
*******************************************************************************/
|
||||
private InboundFile inbound(QRecord job, QRecord account) throws QException
|
||||
{
|
||||
String endpointName = job.getValue("aliasId") == null ? null
|
||||
: audit.query(FileGatewayTables.ALIAS, UploadJobs.equal("id", job.getValue("aliasId"))).getFirst().getValueString("name");
|
||||
boolean plaintext = job.getValue("plaintextStorageKey") != null;
|
||||
String storageKey = job.getValueString(plaintext ? "plaintextStorageKey" : "storageKey");
|
||||
String filename = job.getValueString("fileName");
|
||||
if(plaintext)
|
||||
{
|
||||
filename = filename.substring(0, filename.length() - 4);
|
||||
}
|
||||
return (new InboundFile(job.getValueInteger("id"), endpointName, account.getValueString("username"), account.getValueString("userId"),
|
||||
job.getValueString("remoteAddress"), job.getValueString("path"), filename, job.getValueLong(plaintext ? "plaintextSizeBytes" : "sizeBytes"),
|
||||
job.getValueString(plaintext ? "plaintextSha256" : "sha256"), job.getValueInstant("createDate"), config.getFileGatewayBucketAlias(), storageKey, () ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return (store.open(storageKey));
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new UncheckedIOException(e));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retry delays have a five-attempt cap; a consumer can request a longer delay.
|
||||
*******************************************************************************/
|
||||
private void retry(QRecord job, String message, Duration requested) throws QException
|
||||
{
|
||||
if(stopped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(attempts(job) >= 5)
|
||||
{
|
||||
QRecord account = audit.query(FileGatewayTables.ACCOUNT, UploadJobs.equal("id", job.getValue("accountId"))).getFirst();
|
||||
try
|
||||
{
|
||||
finish(job, new QRecord().withValue("status", "FAILED").withValue("errorMessage", message), account);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new QException("Unable to finish failed upload", e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
long delay = RETRY_SECONDS[Math.min(Math.max(0, attempts(job) - 1), RETRY_SECONDS.length - 1)];
|
||||
if(requested != null)
|
||||
{
|
||||
delay = Math.max(delay, requested.toSeconds());
|
||||
}
|
||||
persistOwned(job, new QRecord().withValue("status", "QUEUED").withValue("errorMessage", message)
|
||||
.withValue("nextAttemptAt", clock.instant().plusSeconds(delay)).withValue("claimedBy", null).withValue("claimedAt", null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Results are bounded by the process summary, never by the original upload size.
|
||||
*******************************************************************************/
|
||||
private void finish(QRecord job, QRecord outcome, QRecord account) throws QException, IOException
|
||||
{
|
||||
String summary = outcome.getValueString("resultSummary");
|
||||
if(summary == null)
|
||||
{
|
||||
summary = outcome.getValueString("errorMessage");
|
||||
}
|
||||
if(summary == null)
|
||||
{
|
||||
summary = outcome.getValueString("status");
|
||||
}
|
||||
String key = "file-gateway/results/" + job.getValue("accountId") + "/" + job.getValue("id") + "/" + java.util.UUID.randomUUID() + ".csv";
|
||||
byte[] csv = ("row,field,message\r\n,,\"" + summary.replace("\"", "\"\"") + "\"\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
String detailed = outcome.getValueString("_resultsCsv");
|
||||
outcome.getValues().remove("_resultsCsv");
|
||||
if(detailed != null)
|
||||
{
|
||||
csv = detailed.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
store.put(key, new ByteArrayInputStream(csv));
|
||||
outcome.withValue("resultSummary", summary).withValue("resultsStorageKey", key).withValue("resultsSizeBytes", (long) csv.length)
|
||||
.withValue("finishedAt", clock.instant()).withValue("nextAttemptAt", null).withValue("claimedBy", null).withValue("claimedAt", null);
|
||||
if(persistOwned(job, outcome))
|
||||
{
|
||||
UploadNotifications.send(audit, job, outcome, account);
|
||||
}
|
||||
else
|
||||
{
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Late workers cannot overwrite a requeued or newly claimed job.
|
||||
*******************************************************************************/
|
||||
private boolean persistOwned(QRecord job, QRecord changes) throws QException
|
||||
{
|
||||
return (audit.withAccountLock("id", job.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord current = find(job);
|
||||
if(!"RUNNING".equals(current.getValueString("status")) || !Objects.equals(pod, current.getValueString("claimedBy"))
|
||||
|| !Objects.equals(job.getValueInstant("claimedAt"), current.getValueInstant("claimedAt")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (audit.transitionUpload(changes.withValue("id", job.getValue("id")), "RUNNING"));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reload a gateway job inside a short bookkeeping transaction.
|
||||
*******************************************************************************/
|
||||
private QRecord find(QRecord job) throws QException
|
||||
{
|
||||
return (audit.query(FileGatewayTables.UPLOAD_JOB, UploadJobs.equal("id", job.getValue("id"))).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Null attempts on older rows count as zero.
|
||||
*******************************************************************************/
|
||||
private int attempts(QRecord job)
|
||||
{
|
||||
return (job.getValueInteger("attempts") == null ? 0 : job.getValueInteger("attempts"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Interrupt in-flight delivery and release only this pod's leases.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close() throws QException
|
||||
{
|
||||
stopped = true;
|
||||
if(pool != null)
|
||||
{
|
||||
pool.shutdownNow();
|
||||
}
|
||||
for(QRecord job : audit.query(FileGatewayTables.UPLOAD_JOB, UploadJobs.equal("claimedBy", pod)))
|
||||
{
|
||||
persistOwned(job, new QRecord().withValue("status", "QUEUED").withValue("claimedBy", null).withValue("claimedAt", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.sftp.GatewaySession;
|
||||
import com.kingsrook.qbits.filegateway.storage.GatewayObjectStore;
|
||||
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.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable upload lifecycle. Account row transactions serialize quotas and renames across pods.
|
||||
*******************************************************************************/
|
||||
public class UploadJobs
|
||||
{
|
||||
public static final long MIB = 1024L * 1024L;
|
||||
private final AuditWriter audit;
|
||||
private final GatewayObjectStore store;
|
||||
private final FileGatewayQBitConfig config;
|
||||
private final Clock clock;
|
||||
|
||||
/*******************************************************************************
|
||||
** Share storage and persistence between the virtual tree and workers.
|
||||
*******************************************************************************/
|
||||
public UploadJobs(AuditWriter audit, GatewayObjectStore store, FileGatewayQBitConfig config, Clock clock)
|
||||
{
|
||||
this.audit = audit;
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The target was resolved through the user's metadata; endpoint routing can reuse this seam.
|
||||
*******************************************************************************/
|
||||
public SeekableByteChannel receive(GatewaySession session, String path, String table, Integer profile) throws IOException
|
||||
{
|
||||
return (receive(session, path, table, profile, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist the resolved endpoint with the upload before any bytes are accepted.
|
||||
*******************************************************************************/
|
||||
public SeekableByteChannel receive(GatewaySession session, String path, String table, Integer profile, QRecord alias) throws IOException
|
||||
{
|
||||
String name = path.substring(path.lastIndexOf('/') + 1);
|
||||
if(name.isBlank() || name.contains(":") || name.contains("\\") || name.chars().anyMatch(Character::isISOControl))
|
||||
{
|
||||
throw (new IOException("Invalid upload filename"));
|
||||
}
|
||||
if(!session.operationAllowed())
|
||||
{
|
||||
throw (new IOException("Operation limit exceeded"));
|
||||
}
|
||||
try
|
||||
{
|
||||
String date = DateTimeFormatter.ofPattern("yyyy/MM/dd").withZone(ZoneOffset.UTC).format(clock.instant());
|
||||
String key = "file-gateway/uploads/" + session.account.getValue("id") + "/" + date + "/" + UUID.randomUUID() + "/" + name;
|
||||
QRecord job = audit.insert(FileGatewayTables.UPLOAD_JOB, new QRecord()
|
||||
.withValue("accountId", session.account.getValue("id")).withValue("connectionLogId", session.connectionId)
|
||||
.withValue("remoteAddress", session.remoteIp).withValue("tableName", table).withValue("savedBulkLoadProfileId", profile)
|
||||
.withValue("aliasId", alias == null ? null : alias.getValue("id"))
|
||||
.withValue("consumerName", alias != null && "INBOX".equals(alias.getValueString("kind")) ? alias.getValueString("consumerName") : "bulk-load").withValue("path", path).withValue("fileName", name).withValue("storageKey", key)
|
||||
.withValue("status", "RECEIVING").withValue("sizeBytes", 0L).withValue("attempts", 0).withValue("createDate", clock.instant()));
|
||||
long started = System.nanoTime();
|
||||
return (new UploadChannel(store, key, Math.toIntExact(config.getUploadPartSizeMb() * MIB), config.getUploadReorderWindowMb() * MIB, new UploadChannel.Lifecycle()
|
||||
{
|
||||
@Override
|
||||
public void reserve(long size) throws IOException
|
||||
{
|
||||
reserveBytes(job, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(GatewayObjectStore.Receipt receipt) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(session.ssh.isClosing() || !session.ssh.isOpen())
|
||||
{
|
||||
throw (new IOException("Connection ended before upload close"));
|
||||
}
|
||||
job.withValue("sizeBytes", receipt.size()).withValue("sha256", receipt.sha256());
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, new QRecord().withValue("id", job.getValue("id"))
|
||||
.withValue("sizeBytes", receipt.size()).withValue("sha256", receipt.sha256()));
|
||||
if(!temporary(name))
|
||||
{
|
||||
queue(job, path, name);
|
||||
}
|
||||
log(session, job, "OK", null, started);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to finalize upload", e));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void abort(String message, boolean limit) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
QRecord outcome = new QRecord().withValue("id", job.getValue("id"))
|
||||
.withValue("status", limit ? "REJECTED" : "ABORTED").withValue("errorMessage", message).withValue("finishedAt", clock.instant());
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, outcome);
|
||||
UploadNotifications.send(audit, job, outcome, session.account);
|
||||
log(session, job, limit ? "LIMIT" : "ERROR", message, started);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to audit aborted upload", e));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to create upload job", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Charge the high-water byte count under an account lock, including concurrent uploads.
|
||||
*******************************************************************************/
|
||||
private void reserveBytes(QRecord job, long size) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
String rejection = audit.withAccountLock("id", job.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord account = audit.query(FileGatewayTables.ACCOUNT, equal("id", job.getValue("accountId"))).getFirst();
|
||||
Integer maximum = account.getValueInteger("maxFileSizeMb");
|
||||
if(size > (maximum == null ? config.getDefaultMaxFileSizeMb() : maximum) * MIB)
|
||||
{
|
||||
return ("File size limit exceeded");
|
||||
}
|
||||
Integer daily = account.getValueInteger("maxUploadMbPerDay");
|
||||
if(daily != null)
|
||||
{
|
||||
var midnight = clock.instant().atZone(ZoneOffset.UTC).toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
long used = 0;
|
||||
for(QRecord other : audit.query(FileGatewayTables.UPLOAD_JOB, equal("accountId", job.getValue("accountId"))
|
||||
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, midnight))))
|
||||
{
|
||||
if(!Objects.equals(other.getValue("id"), job.getValue("id")))
|
||||
{
|
||||
used += other.getValueLong("sizeBytes") == null ? 0 : other.getValueLong("sizeBytes");
|
||||
}
|
||||
}
|
||||
if(size > daily * MIB - used)
|
||||
{
|
||||
return ("Daily upload quota exceeded");
|
||||
}
|
||||
}
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, new QRecord().withValue("id", job.getValue("id")).withValue("sizeBytes", size));
|
||||
return (null);
|
||||
});
|
||||
if(rejection != null)
|
||||
{
|
||||
throw (new UploadLimitException(rejection));
|
||||
}
|
||||
job.withValue("sizeBytes", size);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to reserve upload quota", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Close and rename share one idempotent transition, with target-aware deduplication.
|
||||
*******************************************************************************/
|
||||
public void queue(QRecord job, String path, String name) throws QException
|
||||
{
|
||||
audit.withAccountLock("id", job.getValue("accountId"), () ->
|
||||
{
|
||||
QRecord current = audit.query(FileGatewayTables.UPLOAD_JOB, equal("id", job.getValue("id"))).getFirst();
|
||||
if(!"RECEIVING".equals(current.getValueString("status")) || current.getValueString("sha256") == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", job.getValue("id")).withValue("path", path).withValue("fileName", name);
|
||||
if(temporary(name))
|
||||
{
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, update);
|
||||
return (null);
|
||||
}
|
||||
update.withValue("status", "QUEUED").withValue("queuedAt", clock.instant());
|
||||
QRecord account = audit.query(FileGatewayTables.ACCOUNT, equal("id", job.getValue("accountId"))).getFirst();
|
||||
if(Boolean.TRUE.equals(account.getValueBoolean("dedupeUploads")))
|
||||
{
|
||||
for(QRecord candidate : audit.query(FileGatewayTables.UPLOAD_JOB, equal("accountId", job.getValue("accountId"))
|
||||
.withCriteria(new QFilterCriteria("sha256", QCriteriaOperator.EQUALS, current.getValue("sha256")))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "SUCCEEDED"))
|
||||
.withCriteria(new QFilterCriteria("finishedAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, clock.instant().minus(config.getDedupeWindow())))))
|
||||
{
|
||||
if(Objects.equals(current.getValue("tableName"), candidate.getValue("tableName"))
|
||||
&& Objects.equals(current.getValue("savedBulkLoadProfileId"), candidate.getValue("savedBulkLoadProfileId"))
|
||||
&& Objects.equals(current.getValue("aliasId"), candidate.getValue("aliasId"))
|
||||
&& Objects.equals(current.getValue("consumerName"), candidate.getValue("consumerName")))
|
||||
{
|
||||
update.withValue("status", "DUPLICATE").withValue("duplicateOfJobId", candidate.getValue("id"))
|
||||
.withValue("finishedAt", clock.instant()).withValue("resultSummary", "Skipped: duplicate of upload " + candidate.getValue("id"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, update);
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Closed temporary files can be renamed only within their original upload folder.
|
||||
*******************************************************************************/
|
||||
public void rename(Integer accountId, String source, String target) throws IOException
|
||||
{
|
||||
if(!source.substring(0, source.lastIndexOf('/')).equals(target.substring(0, target.lastIndexOf('/'))))
|
||||
{
|
||||
throw (new IOException("Rename must stay inside one upload folder"));
|
||||
}
|
||||
String name = target.substring(target.lastIndexOf('/') + 1);
|
||||
if(name.isBlank() || name.contains(":") || name.contains("\\") || name.chars().anyMatch(Character::isISOControl))
|
||||
{
|
||||
throw (new IOException("Invalid upload filename"));
|
||||
}
|
||||
try
|
||||
{
|
||||
var rows = audit.query(FileGatewayTables.UPLOAD_JOB, equal("accountId", accountId)
|
||||
.withCriteria(new QFilterCriteria("path", QCriteriaOperator.EQUALS, source))
|
||||
.withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, "RECEIVING")));
|
||||
if(rows.size() != 1 || rows.getFirst().getValueString("sha256") == null || !temporary(rows.getFirst().getValueString("fileName")))
|
||||
{
|
||||
throw (new IOException("Only a completed temporary upload can be renamed"));
|
||||
}
|
||||
queue(rows.getFirst(), target, target.substring(target.lastIndexOf('/') + 1));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to rename upload", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Account-scoped records for a visible table.
|
||||
*******************************************************************************/
|
||||
public List<QRecord> forTable(Integer accountId, String table) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (audit.query(FileGatewayTables.UPLOAD_JOB, equal("accountId", accountId)
|
||||
.withCriteria(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, table))));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to list upload jobs", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Acknowledging either artifact hides both results and the sidecar.
|
||||
*******************************************************************************/
|
||||
public void acknowledge(QRecord job) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
audit.update(FileGatewayTables.UPLOAD_JOB, new QRecord().withValue("id", job.getValue("id")).withValue("acknowledgedAt", clock.instant()));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to acknowledge upload", e));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured temporary filename suffixes.
|
||||
*******************************************************************************/
|
||||
public boolean temporary(String name)
|
||||
{
|
||||
return (config.getTemporaryUploadSuffixes().stream().anyMatch(name::endsWith));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common equality filter for gateway bookkeeping only.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter equal(String field, java.io.Serializable value)
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria(field, QCriteriaOperator.EQUALS, value)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upload audit includes measured bytes, digest and the original connection identity.
|
||||
*******************************************************************************/
|
||||
private void log(GatewaySession session, QRecord job, String outcome, String message, long started) throws QException
|
||||
{
|
||||
audit.insert(FileGatewayTables.TRANSFER_LOG, new QRecord().withValue("accountId", session.account.getValue("id"))
|
||||
.withValue("connectionLogId", session.connectionId).withValue("operation", "UPLOAD").withValue("path", job.getValue("path"))
|
||||
.withValue("objectKind", "PROFILE").withValue("objectId", job.getValueString("savedBulkLoadProfileId"))
|
||||
.withValue("uploadJobId", job.getValue("id")).withValue("bytes", job.getValue("sizeBytes")).withValue("sha256", job.getValue("sha256"))
|
||||
.withValue("durationMs", (System.nanoTime() - started) / 1_000_000).withValue("outcome", outcome).withValue("message", message)
|
||||
.withValue("clientChannel", "sftp/" + session.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/*******************************************************************************
|
||||
** An upload exceeded the account's byte budget.
|
||||
*******************************************************************************/
|
||||
public class UploadLimitException extends IOException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Safe reason to expose through SFTP and the transfer audit.
|
||||
*******************************************************************************/
|
||||
public UploadLimitException(String message)
|
||||
{
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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.filegateway.upload;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertLink;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Core notification signals for terminal upload outcomes, including transfer limits.
|
||||
*******************************************************************************/
|
||||
public final class UploadNotifications
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static signal publisher.
|
||||
*******************************************************************************/
|
||||
private UploadNotifications()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No alerts producer is a no-op; service account messages go to their owner.
|
||||
*******************************************************************************/
|
||||
public static void send(AuditWriter audit, QRecord job, QRecord outcome, QRecord account) throws QException
|
||||
{
|
||||
String status = outcome.getValueString("status");
|
||||
if(!List.of("FAILED", "PARTIAL", "REJECTED").contains(status)
|
||||
&& !("SUCCEEDED".equals(status) && Boolean.TRUE.equals(account.getValueBoolean("notifyOnSuccess"))))
|
||||
{
|
||||
return;
|
||||
}
|
||||
audit.system(() ->
|
||||
{
|
||||
var alerts = AlertsInstanceMetaData.of(QContext.getQInstance());
|
||||
if(alerts == null || alerts.getSink() == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String recipient = "SERVICE".equals(account.getValueString("accountType")) ? account.getValueString("ownerUserId") : account.getValueString("userId");
|
||||
if(recipient == null || recipient.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QAlerts.notify(new NotificationSignal().withTitle("Upload " + status.toLowerCase(Locale.ROOT))
|
||||
.withBody(outcome.getValueString("resultSummary") == null ? outcome.getValueString("errorMessage") : outcome.getValueString("resultSummary"))
|
||||
.withSourceName("file-gateway").withSourceKey(job.getValueString("id"))
|
||||
.withSourceTableName(FileGatewayTables.UPLOAD_JOB).withSourceRecordId(job.getValueString("id"))
|
||||
.withAudience(NotificationAudience.user(recipient))
|
||||
.withLink(AlertLink.toRecord(FileGatewayTables.UPLOAD_JOB, job.getValue("id"), "Upload job")));
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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.filegateway.upload;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.filegateway.permissions.FileGatewayPermissions;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInProcessSessions;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
|
||||
|
||||
/*******************************************************************************
|
||||
** Independent, short-lived account-user credentials for an upload worker.
|
||||
*******************************************************************************/
|
||||
public final class WorkerUserSession implements AutoCloseable
|
||||
{
|
||||
private final QInstance instance;
|
||||
private final UiApiInvoker invoker;
|
||||
private final String userId;
|
||||
private final String detail;
|
||||
private final Clock clock;
|
||||
private String token;
|
||||
private Instant minted;
|
||||
|
||||
/*******************************************************************************
|
||||
** The caller owns restoration of its QContext after the job.
|
||||
*******************************************************************************/
|
||||
public WorkerUserSession(QInstance instance, UiApiInvoker invoker, String userId, String detail, Clock clock) throws QException
|
||||
{
|
||||
this.instance = instance;
|
||||
this.invoker = invoker;
|
||||
this.userId = userId;
|
||||
this.detail = detail;
|
||||
this.clock = clock;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-mint permissions even after the originating SSH session has disconnected.
|
||||
*******************************************************************************/
|
||||
private void refresh() throws QException
|
||||
{
|
||||
if(minted == null || !minted.plusSeconds(300).isAfter(clock.instant()))
|
||||
{
|
||||
var module = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication());
|
||||
var user = new AuditWriter(instance).system(() -> module.createAutomatedSessionForUser(instance, userId));
|
||||
if(user == null || !user.hasPermission(FileGatewayPermissions.CONNECT))
|
||||
{
|
||||
throw (new QException("Missing file_gateway.connect"));
|
||||
}
|
||||
user.setClientChannel("sftp/" + detail);
|
||||
String replacement = UiApiInProcessSessions.register(user, Duration.ofMinutes(10));
|
||||
UiApiInProcessSessions.revoke(token);
|
||||
token = replacement;
|
||||
minted = clock.instant();
|
||||
QContext.init(instance, user);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only published operations, with fixed channel identity and user credentials.
|
||||
*******************************************************************************/
|
||||
public JsonNode invoke(UiApiRequest request) throws IOException, QException
|
||||
{
|
||||
refresh();
|
||||
if(invoker.findOperation(request.getMethod(), request.getPath()).isEmpty())
|
||||
{
|
||||
throw (new IOException("Gateway requires a spec-defined UI API operation"));
|
||||
}
|
||||
var response = invoker.invoke(request.withCredentials(UiApiCredentials.ofInProcessSession(token))
|
||||
.withInvokedBy("file-gateway").withClientDetail(detail));
|
||||
if(!response.isSuccess())
|
||||
{
|
||||
throw (new IOException("UI API request failed with status " + response.getStatus()));
|
||||
}
|
||||
return (response.getJson());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Never retain worker credentials after a job.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
UiApiInProcessSessions.revoke(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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.filegateway.vfs;
|
||||
|
||||
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.nio.file.attribute.GroupPrincipal;
|
||||
import java.nio.file.attribute.PosixFileAttributes;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.nio.file.attribute.UserPrincipal;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attributes: generated exports have size zero; stored objects report persisted size.
|
||||
*******************************************************************************/
|
||||
public record GatewayFileAttributes(VirtualTree.Node node, FileTime time) implements PosixFileAttributes
|
||||
{
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute lastModifiedTime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileTime lastModifiedTime()
|
||||
{
|
||||
return (time);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute lastAccessTime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileTime lastAccessTime()
|
||||
{
|
||||
return (time);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute creationTime.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileTime creationTime()
|
||||
{
|
||||
return (time);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute isRegularFile.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isRegularFile()
|
||||
{
|
||||
return (!node.directory());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute isDirectory.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isDirectory()
|
||||
{
|
||||
return (node.directory());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute isSymbolicLink.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isSymbolicLink()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute isOther.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isOther()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute size.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public long size()
|
||||
{
|
||||
return (node.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute fileKey.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Object fileKey()
|
||||
{
|
||||
return (node.path());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute owner.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPrincipal owner()
|
||||
{
|
||||
return (() -> "gateway");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute group.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public GroupPrincipal group()
|
||||
{
|
||||
return (() -> "gateway");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual attribute permissions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<PosixFilePermission> permissions()
|
||||
{
|
||||
if(node.directory() && "PROFILE".equals(node.kind()) && node.objectId() != null)
|
||||
{
|
||||
return (Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE));
|
||||
}
|
||||
return (node.directory() ? Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE) : Set.of(PosixFilePermission.OWNER_READ));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.filegateway.vfs;
|
||||
|
||||
|
||||
import java.nio.file.ClosedFileSystemException;
|
||||
import java.nio.file.FileStore;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.nio.file.WatchService;
|
||||
import java.nio.file.attribute.UserPrincipalLookupService;
|
||||
import java.nio.file.spi.FileSystemProvider;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session-private NIO filesystem over a UI API virtual tree, with no native mounts.
|
||||
*******************************************************************************/
|
||||
public class GatewayFileSystem extends FileSystem
|
||||
{
|
||||
private final VirtualTree tree;
|
||||
private final GatewayFileSystemProvider provider;
|
||||
private boolean open = true;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a purely virtual filesystem.
|
||||
*******************************************************************************/
|
||||
public GatewayFileSystem(VirtualTree tree)
|
||||
{
|
||||
this.tree = tree;
|
||||
this.provider = new GatewayFileSystemProvider(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Access the live virtual tree.
|
||||
*******************************************************************************/
|
||||
public VirtualTree tree()
|
||||
{
|
||||
if(!open)
|
||||
{
|
||||
throw (new ClosedFileSystemException());
|
||||
}
|
||||
return (tree);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem provider.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileSystemProvider provider()
|
||||
{
|
||||
return (provider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem close.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
open = false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem isOpen.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isOpen()
|
||||
{
|
||||
return (open);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem isReadOnly.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isReadOnly()
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getSeparator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getSeparator()
|
||||
{
|
||||
return ("/");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getRootDirectories.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Iterable<Path> getRootDirectories()
|
||||
{
|
||||
return (List.of(getPath("/")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getFileStores.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Iterable<FileStore> getFileStores()
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem supportedFileAttributeViews.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Set<String> supportedFileAttributeViews()
|
||||
{
|
||||
return (Set.of("basic", "posix"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getPath.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getPath(String first, String... more)
|
||||
{
|
||||
return (new GatewayPath(this, first + (more.length == 0 ? "" : "/" + String.join("/", more))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getPathMatcher.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public PathMatcher getPathMatcher(String syntaxAndPattern)
|
||||
{
|
||||
throw (new UnsupportedOperationException("Path matching is unavailable"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem getUserPrincipalLookupService.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPrincipalLookupService getUserPrincipalLookupService()
|
||||
{
|
||||
throw (new UnsupportedOperationException("Native users are unavailable"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual filesystem newWatchService.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public WatchService newWatchService()
|
||||
{
|
||||
throw (new UnsupportedOperationException("Watching is unavailable"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* 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.filegateway.vfs;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.file.AccessMode;
|
||||
import java.nio.file.CopyOption;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.FileStore;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.FileSystemAlreadyExistsException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.OpenOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.ProviderMismatchException;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.FileAttribute;
|
||||
import java.nio.file.attribute.FileAttributeView;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.nio.file.spi.FileSystemProvider;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** NIO operations resolve against the virtual tree and its explicitly supported upload artifacts.
|
||||
*******************************************************************************/
|
||||
public class GatewayFileSystemProvider extends FileSystemProvider
|
||||
{
|
||||
private final GatewayFileSystem fileSystem;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind one virtual filesystem.
|
||||
*******************************************************************************/
|
||||
public GatewayFileSystemProvider(GatewayFileSystem fileSystem)
|
||||
{
|
||||
this.fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Canonical virtual path in this session.
|
||||
*******************************************************************************/
|
||||
private String name(Path path)
|
||||
{
|
||||
if(path.getFileSystem() != fileSystem)
|
||||
{
|
||||
throw (new ProviderMismatchException());
|
||||
}
|
||||
return (path.toAbsolutePath().normalize().toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider getScheme.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getScheme()
|
||||
{
|
||||
return ("gateway");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider newFileSystem.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileSystem newFileSystem(URI uri, Map<String, ?> env)
|
||||
{
|
||||
throw (new FileSystemAlreadyExistsException());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider getFileSystem.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileSystem getFileSystem(URI uri)
|
||||
{
|
||||
return (fileSystem);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider getPath.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getPath(URI uri)
|
||||
{
|
||||
return (fileSystem.getPath(uri.getPath()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider newByteChannel.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException
|
||||
{
|
||||
boolean write = options.contains(StandardOpenOption.WRITE) || options.contains(StandardOpenOption.APPEND) || options.contains(StandardOpenOption.CREATE) || options.contains(StandardOpenOption.CREATE_NEW);
|
||||
if(options.contains(StandardOpenOption.APPEND))
|
||||
{
|
||||
fileSystem.tree().deny(name(path), "UPLOAD", "Appending to stored uploads is unsupported");
|
||||
}
|
||||
return (write ? fileSystem.tree().write(name(path)) : fileSystem.tree().read(name(path)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider newDirectoryStream.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public DirectoryStream<Path> newDirectoryStream(Path dir, DirectoryStream.Filter<? super Path> filter) throws IOException
|
||||
{
|
||||
List<Path> entries = new ArrayList<>();
|
||||
for(VirtualTree.Node node : fileSystem.tree().list(name(dir)))
|
||||
{
|
||||
Path path = fileSystem.getPath(node.path());
|
||||
if(filter.accept(path))
|
||||
{
|
||||
entries.add(path);
|
||||
}
|
||||
}
|
||||
return (new GatewayDirectoryStream(entries));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider createDirectory.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException
|
||||
{
|
||||
fileSystem.tree().deny(name(dir), "UPLOAD", "mkdir is not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider delete.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void delete(Path path) throws IOException
|
||||
{
|
||||
fileSystem.tree().remove(name(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider copy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void copy(Path source, Path target, CopyOption... options) throws IOException
|
||||
{
|
||||
fileSystem.tree().deny(name(target), "UPLOAD", "Copy is not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider move.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void move(Path source, Path target, CopyOption... options) throws IOException
|
||||
{
|
||||
fileSystem.tree().rename(name(source), name(target));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider isSameFile.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isSameFile(Path path, Path path2)
|
||||
{
|
||||
return (name(path).equals(name(path2)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider isHidden.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isHidden(Path path)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider getFileStore.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileStore getFileStore(Path path)
|
||||
{
|
||||
throw (new UnsupportedOperationException("No native store"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider checkAccess.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkAccess(Path path, AccessMode... modes) throws IOException
|
||||
{
|
||||
fileSystem.tree().stat(name(path));
|
||||
for(AccessMode mode : modes)
|
||||
{
|
||||
if(mode == AccessMode.WRITE)
|
||||
{
|
||||
VirtualTree.Node node = fileSystem.tree().stat(name(path));
|
||||
if(!node.directory() || !"PROFILE".equals(node.kind()) || node.objectId() == null)
|
||||
{
|
||||
fileSystem.tree().deny(name(path), "UPLOAD", "Writes require a profile folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider getFileAttributeView.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider readAttributes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type, LinkOption... options) throws IOException
|
||||
{
|
||||
return (type.cast(new GatewayFileAttributes(fileSystem.tree().stat(name(path)), FileTime.from(Instant.now()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider readAttributes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException
|
||||
{
|
||||
var attrs = readAttributes(path, GatewayFileAttributes.class, options);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("isDirectory", attrs.isDirectory());
|
||||
result.put("isRegularFile", attrs.isRegularFile());
|
||||
result.put("isSymbolicLink", false);
|
||||
result.put("isOther", false);
|
||||
result.put("size", attrs.size());
|
||||
result.put("fileKey", attrs.fileKey());
|
||||
result.put("lastModifiedTime", attrs.lastModifiedTime());
|
||||
result.put("lastAccessTime", attrs.lastAccessTime());
|
||||
result.put("creationTime", attrs.creationTime());
|
||||
result.put("permissions", attrs.permissions());
|
||||
result.put("owner", attrs.owner());
|
||||
result.put("group", attrs.group());
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider setAttribute.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException
|
||||
{
|
||||
fileSystem.tree().deny(name(path), "UPLOAD", "setattr is not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider createSymbolicLink.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void createSymbolicLink(Path link, Path target, FileAttribute<?>... attrs) throws IOException
|
||||
{
|
||||
fileSystem.tree().deny(name(link), "UPLOAD", "Links are not supported");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual provider createLink.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void createLink(Path link, Path existing) throws IOException
|
||||
{
|
||||
fileSystem.tree().deny(name(link), "UPLOAD", "Links are not supported");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Single-use directory snapshot held entirely in memory.
|
||||
*******************************************************************************/
|
||||
private static class GatewayDirectoryStream implements DirectoryStream<Path>
|
||||
{
|
||||
private final List<Path> entries;
|
||||
private boolean used;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Directory snapshot lifecycle.
|
||||
*******************************************************************************/
|
||||
GatewayDirectoryStream(List<Path> entries)
|
||||
{
|
||||
this.entries = entries;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Directory snapshot lifecycle.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Iterator<Path> iterator()
|
||||
{
|
||||
if(used)
|
||||
{
|
||||
throw (new IllegalStateException("Directory stream is closed or already iterated"));
|
||||
}
|
||||
used = true;
|
||||
return (entries.iterator());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Directory snapshot lifecycle.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
used = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* 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.filegateway.vfs;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.ProviderMismatchException;
|
||||
import java.nio.file.WatchEvent;
|
||||
import java.nio.file.WatchKey;
|
||||
import java.nio.file.WatchService;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Deque;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A path made solely from virtual name segments; never delegates to a native filesystem.
|
||||
*******************************************************************************/
|
||||
public final class GatewayPath implements Path
|
||||
{
|
||||
private final GatewayFileSystem fileSystem;
|
||||
private final String value;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a virtual path.
|
||||
*******************************************************************************/
|
||||
public GatewayPath(GatewayFileSystem fileSystem, String value)
|
||||
{
|
||||
this.fileSystem = fileSystem;
|
||||
this.value = value.replaceAll("/+", "/").replaceAll("(?<=.)/$", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Individual name segments.
|
||||
*******************************************************************************/
|
||||
private List<String> parts()
|
||||
{
|
||||
return (value.isEmpty() || "/".equals(value) ? List.of() : Arrays.asList(value.replaceFirst("^/", "").split("/")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getFileSystem.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public FileSystem getFileSystem()
|
||||
{
|
||||
return (fileSystem);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path isAbsolute.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isAbsolute()
|
||||
{
|
||||
return (value.startsWith("/"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getRoot.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getRoot()
|
||||
{
|
||||
return (isAbsolute() ? new GatewayPath(fileSystem, "/") : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getFileName.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getFileName()
|
||||
{
|
||||
List<String> parts = parts();
|
||||
return (parts.isEmpty() ? null : new GatewayPath(fileSystem, parts.getLast()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getParent.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getParent()
|
||||
{
|
||||
int slash = value.lastIndexOf('/');
|
||||
return (slash < 0 || "/".equals(value) ? null : new GatewayPath(fileSystem, slash == 0 ? "/" : value.substring(0, slash)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getNameCount.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int getNameCount()
|
||||
{
|
||||
return (parts().size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path getName.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path getName(int index)
|
||||
{
|
||||
return (new GatewayPath(fileSystem, parts().get(index)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path subpath.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path subpath(int beginIndex, int endIndex)
|
||||
{
|
||||
return (new GatewayPath(fileSystem, String.join("/", parts().subList(beginIndex, endIndex))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path startsWith.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean startsWith(Path other)
|
||||
{
|
||||
return (other instanceof GatewayPath p && p.fileSystem == fileSystem && (value.equals(p.value) || value.startsWith(p.value.endsWith("/") ? p.value : p.value + "/")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path endsWith.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean endsWith(Path other)
|
||||
{
|
||||
return (other instanceof GatewayPath p && p.fileSystem == fileSystem && (value.equals(p.value) || (!p.isAbsolute() && value.endsWith("/" + p.value))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path normalize.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path normalize()
|
||||
{
|
||||
Deque<String> names = new ArrayDeque<>();
|
||||
for(String part : parts())
|
||||
{
|
||||
if(part.equals(".") || part.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(part.equals("..") && !names.isEmpty() && !names.getLast().equals(".."))
|
||||
{
|
||||
names.removeLast();
|
||||
}
|
||||
else if(!part.equals("..") || !isAbsolute())
|
||||
{
|
||||
names.addLast(part);
|
||||
}
|
||||
}
|
||||
return (new GatewayPath(fileSystem, (isAbsolute() ? "/" : "") + String.join("/", names)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path resolve.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path resolve(Path other)
|
||||
{
|
||||
if(!(other instanceof GatewayPath p) || p.fileSystem != fileSystem)
|
||||
{
|
||||
throw (new ProviderMismatchException());
|
||||
}
|
||||
return (other.isAbsolute() ? other : new GatewayPath(fileSystem, value + (value.endsWith("/") || value.isEmpty() ? "" : "/") + other));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path relativize.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path relativize(Path other)
|
||||
{
|
||||
if(!(other instanceof GatewayPath p) || p.fileSystem != fileSystem || isAbsolute() != p.isAbsolute())
|
||||
{
|
||||
throw (new IllegalArgumentException("Incompatible virtual paths"));
|
||||
}
|
||||
List<String> left = ((GatewayPath) normalize()).parts();
|
||||
List<String> right = ((GatewayPath) p.normalize()).parts();
|
||||
int common = 0;
|
||||
while(common < left.size() && common < right.size() && left.get(common).equals(right.get(common)))
|
||||
{
|
||||
common++;
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for(int i = common; i < left.size(); i++)
|
||||
{
|
||||
result.add("..");
|
||||
}
|
||||
result.addAll(right.subList(common, right.size()));
|
||||
return (new GatewayPath(fileSystem, String.join("/", result)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path toUri.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public URI toUri()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new URI("gateway", null, toAbsolutePath().toString(), null));
|
||||
}
|
||||
catch(java.net.URISyntaxException e)
|
||||
{
|
||||
throw (new IllegalArgumentException(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path toAbsolutePath.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path toAbsolutePath()
|
||||
{
|
||||
return (isAbsolute() ? this : new GatewayPath(fileSystem, "/" + value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path toRealPath.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Path toRealPath(LinkOption... options) throws IOException
|
||||
{
|
||||
Path path = toAbsolutePath().normalize();
|
||||
fileSystem.tree().stat(path.toString());
|
||||
return (path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path toFile.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public File toFile()
|
||||
{
|
||||
throw (new UnsupportedOperationException("Virtual paths have no native file"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path register.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public WatchKey register(WatchService watcher, WatchEvent.Kind<?>[] events, WatchEvent.Modifier... modifiers)
|
||||
{
|
||||
throw (new UnsupportedOperationException("Virtual paths cannot be watched"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path iterator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Iterator<Path> iterator()
|
||||
{
|
||||
return (parts().stream().map(name -> (Path) new GatewayPath(fileSystem, name)).iterator());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path compareTo.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int compareTo(Path other)
|
||||
{
|
||||
return (value.compareTo(other.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path equals.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean equals(Object other)
|
||||
{
|
||||
return (other instanceof GatewayPath path && path.fileSystem == fileSystem && path.value.equals(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path hashCode.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return (31 * System.identityHashCode(fileSystem) + value.hashCode());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Virtual path toString.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.filegateway.vfs;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.kingsrook.qbits.filegateway.sftp.GatewaySession;
|
||||
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.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiStreamResponse;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every visible object comes from a spec-defined UI API operation as the signed-in user.
|
||||
*******************************************************************************/
|
||||
public class GatewayUiApiClient
|
||||
{
|
||||
private final UiApiInvoker invoker;
|
||||
private final GatewaySession session;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind this client to one SFTP identity.
|
||||
*******************************************************************************/
|
||||
public GatewayUiApiClient(UiApiInvoker invoker, GatewaySession session)
|
||||
{
|
||||
this.invoker = invoker;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get the permission-filtered app tree.
|
||||
*******************************************************************************/
|
||||
public JsonNode metadata() throws IOException
|
||||
{
|
||||
return (invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath("/metaData")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query visible saved objects of a table.
|
||||
*******************************************************************************/
|
||||
public JsonNode savedObjects(String source, String table) throws IOException
|
||||
{
|
||||
var records = JsonNodeFactory.instance.arrayNode();
|
||||
int pageSize = 250;
|
||||
for(int skip = 0; ; skip += pageSize)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, table))
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(pageSize).withSkip(skip);
|
||||
JsonNode page = invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/query")
|
||||
.withPathParam("tableName", source).withJsonBody(java.util.Map.of("filter", filter))).path("records");
|
||||
page.forEach(records::add);
|
||||
if(page.size() < pageSize)
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the fixed channel identity, credentials and contract check on every call.
|
||||
*******************************************************************************/
|
||||
public JsonNode invoke(UiApiRequest request) throws IOException
|
||||
{
|
||||
if(invoker.findOperation(request.getMethod(), request.getPath()).isEmpty())
|
||||
{
|
||||
throw (new IOException("Gateway requires a spec-defined UI API operation"));
|
||||
}
|
||||
try
|
||||
{
|
||||
var response = invoker.invoke(request.withCredentials(UiApiCredentials.ofInProcessSession(session.token()))
|
||||
.withInvokedBy("file-gateway").withClientDetail(session.id));
|
||||
if(response.getStatus() == 404)
|
||||
{
|
||||
throw (new NoSuchFileException(request.getPath()));
|
||||
}
|
||||
if(response.getStatus() == 401 || response.getStatus() == 403)
|
||||
{
|
||||
throw (new AccessDeniedException(request.getPath(), null, response.getBody()));
|
||||
}
|
||||
if(!response.isSuccess())
|
||||
{
|
||||
throw (new IOException("UI API request failed with status " + response.getStatus() + ": " + response.getBody()));
|
||||
}
|
||||
return (response.getJson());
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
|
||||
{
|
||||
throw (new IOException("User session unavailable", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a binary response as this user, retaining only a bounded error body.
|
||||
*******************************************************************************/
|
||||
public UiApiStreamResponse stream(UiApiRequest request) throws IOException
|
||||
{
|
||||
if(invoker.findOperation(request.getMethod(), request.getPath()).isEmpty())
|
||||
{
|
||||
throw (new IOException("Gateway requires a spec-defined UI API operation"));
|
||||
}
|
||||
try
|
||||
{
|
||||
UiApiStreamResponse response = invoker.invokeForStream(request.withCredentials(UiApiCredentials.ofInProcessSession(session.token()))
|
||||
.withInvokedBy("file-gateway").withClientDetail(session.id));
|
||||
if(response.isSuccess())
|
||||
{
|
||||
return (response);
|
||||
}
|
||||
try(response)
|
||||
{
|
||||
String error = new String(response.getInputStream().readNBytes(65536), StandardCharsets.UTF_8);
|
||||
if(response.getStatus() == 401 || response.getStatus() == 403)
|
||||
{
|
||||
throw (new AccessDeniedException(request.getPath(), null, error));
|
||||
}
|
||||
throw (new IOException("UI API request failed with status " + response.getStatus() + ": " + error));
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
|
||||
{
|
||||
throw (new IOException("User session unavailable", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 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.filegateway.vfs;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.filegateway.FileGatewayQBitConfig;
|
||||
import com.kingsrook.qbits.filegateway.metadata.FileGatewayTables;
|
||||
import com.kingsrook.qbits.filegateway.sftp.AuditWriter;
|
||||
import com.kingsrook.qbits.filegateway.sftp.GatewaySession;
|
||||
import com.kingsrook.qbits.filegateway.storage.GeneratedReadChannel;
|
||||
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.QFilterCriteria;
|
||||
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.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiRequest;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiStreamResponse;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Generate user-authorized exports and reports through the UI API, with durable quotas.
|
||||
*******************************************************************************/
|
||||
public final class GeneratedDownloads
|
||||
{
|
||||
private final GatewayUiApiClient client;
|
||||
private final GatewaySession session;
|
||||
private final AuditWriter audit;
|
||||
private final FileGatewayQBitConfig config;
|
||||
private final Clock clock;
|
||||
private final boolean reportsAvailable;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect only host configuration for report storage; visibility still uses user metadata.
|
||||
*******************************************************************************/
|
||||
public GeneratedDownloads(GatewayUiApiClient client, GatewaySession session, AuditWriter audit, FileGatewayQBitConfig config, Clock clock, QInstance instance)
|
||||
{
|
||||
this.client = client;
|
||||
this.session = session;
|
||||
this.audit = audit;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
var process = instance.getProcess("renderSavedReport");
|
||||
reportsAvailable = process != null && process.getInputFields().stream().anyMatch(field -> "storageTableName".equals(field.getName())
|
||||
&& field.getDefaultValue() != null && instance.getTable(field.getDefaultValue().toString()) != null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A render process without its configured storage table cannot provide downloads.
|
||||
*******************************************************************************/
|
||||
public boolean reportsAvailable()
|
||||
{
|
||||
return (reportsAvailable);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start generation at open, owning the HTTP stream until the SFTP handle closes.
|
||||
*******************************************************************************/
|
||||
public SeekableByteChannel open(VirtualTree.Node node) throws IOException
|
||||
{
|
||||
Transfer transfer = new Transfer(node);
|
||||
try
|
||||
{
|
||||
if(!session.operationAllowed())
|
||||
{
|
||||
transfer.limit = true;
|
||||
throw (new IOException("Operation limit exceeded"));
|
||||
}
|
||||
int window = Math.toIntExact(config.getDownloadReadAheadMb() * 1024L * 1024L);
|
||||
if(window < 1)
|
||||
{
|
||||
throw (new IOException("Read-ahead window must be positive"));
|
||||
}
|
||||
String prefix = node.path().substring(0, node.path().lastIndexOf('/'));
|
||||
prefix = prefix.substring(0, prefix.lastIndexOf('/'));
|
||||
String table = node.job() == null ? prefix.substring(prefix.lastIndexOf('/') + 1) : node.job().getValueString("tableName");
|
||||
JsonNode record = null;
|
||||
for(JsonNode row : client.savedObjects("VIEW".equals(node.kind()) ? "savedView" : "savedReport", table))
|
||||
{
|
||||
if(node.objectId().equals(row.path("values").path("id").asText()))
|
||||
{
|
||||
record = row.path("values");
|
||||
}
|
||||
}
|
||||
if(record == null)
|
||||
{
|
||||
throw (new NoSuchFileException(node.path()));
|
||||
}
|
||||
UiApiStreamResponse response = "VIEW".equals(node.kind()) ? export(table, record, transfer.format) : report(node.objectId(), transfer.format);
|
||||
return (new GeneratedReadChannel(response.getInputStream(), window, transfer));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
IOException failure = e instanceof IOException io ? io : new IOException("Unable to generate download", e);
|
||||
transfer.finish(failure);
|
||||
throw (failure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the Next/Material saved-view schema, retaining filter ordering and visible columns.
|
||||
*******************************************************************************/
|
||||
private UiApiStreamResponse export(String table, JsonNode record, String format) throws IOException
|
||||
{
|
||||
JsonNode view = mapper.readTree(record.path("viewJson").asText());
|
||||
if(view == null || !view.isObject())
|
||||
{
|
||||
throw (new IOException("Saved view has invalid viewJson"));
|
||||
}
|
||||
ObjectNode body = mapper.createObjectNode().put("format", format).put("includeHeaderRow", true);
|
||||
JsonNode filter = view.has("queryFilter") ? view.path("queryFilter") : view.path("filter");
|
||||
if(filter.isObject())
|
||||
{
|
||||
ObjectNode query = filter.deepCopy();
|
||||
query.remove(List.of("skip", "limit"));
|
||||
body.set("filter", query);
|
||||
}
|
||||
JsonNode columns = view.path("queryColumns").path("columns");
|
||||
if(columns.isArray())
|
||||
{
|
||||
var names = body.putArray("fieldNames");
|
||||
for(JsonNode column : columns)
|
||||
{
|
||||
String name = column.path("name").asText();
|
||||
if(column.path("isVisible").asBoolean(true) && !name.isEmpty() && !name.equals("__check__"))
|
||||
{
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
if(names.isEmpty())
|
||||
{
|
||||
throw (new IOException("Saved view has no visible export columns"));
|
||||
}
|
||||
}
|
||||
else if(view.path("fieldNames").isArray())
|
||||
{
|
||||
body.set("fieldNames", view.path("fieldNames"));
|
||||
}
|
||||
return (client.stream(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/table/{tableName}/export")
|
||||
.withPathParam("tableName", table).withJsonBody(body)));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Select one record, submit the format input, then follow asynchronous render status.
|
||||
*******************************************************************************/
|
||||
private UiApiStreamResponse report(String id, String format) throws IOException
|
||||
{
|
||||
String values = mapper.writeValueAsString(Map.of("reportFormat", format));
|
||||
JsonNode response = client.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/processes/{processName}/init")
|
||||
.withPathParam("processName", "renderSavedReport").withFormParam("recordsParam", "recordIds").withFormParam("recordIds", id)
|
||||
.withFormParam("values", values));
|
||||
String uuid = response.path("processUUID").asText();
|
||||
String job = response.path("jobUUID").asText();
|
||||
for(int poll = 0; poll < 3600; poll++)
|
||||
{
|
||||
String type = response.path("type").asText();
|
||||
if("ERROR".equals(type))
|
||||
{
|
||||
throw (new IOException(response.path("userFacingError").asText("Report generation failed")));
|
||||
}
|
||||
if("JOB_STARTED".equals(type) || "RUNNING".equals(type))
|
||||
{
|
||||
if(response.hasNonNull("jobUUID"))
|
||||
{
|
||||
job = response.path("jobUUID").asText();
|
||||
}
|
||||
try
|
||||
{
|
||||
Thread.sleep(100);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new IOException("Report generation interrupted", e));
|
||||
}
|
||||
response = client.invoke(new UiApiRequest().withMethod(HttpMethod.GET).withPath("/processes/{processName}/{processUUID}/status/{jobUUID}")
|
||||
.withPathParam("processName", "renderSavedReport").withPathParam("processUUID", uuid).withPathParam("jobUUID", job));
|
||||
continue;
|
||||
}
|
||||
JsonNode output = response.path("values");
|
||||
if(output.hasNonNull("storageReference") && output.hasNonNull("downloadFileName") && output.hasNonNull("storageTableName"))
|
||||
{
|
||||
return (client.stream(new UiApiRequest().withMethod(HttpMethod.GET).withPath("/download/{file}")
|
||||
.withPathParam("file", output.path("downloadFileName").asText())
|
||||
.withQueryParam("storageTableName", output.path("storageTableName").asText())
|
||||
.withQueryParam("storageReference", output.path("storageReference").asText())));
|
||||
}
|
||||
if(!"input".equals(response.path("nextStep").asText()))
|
||||
{
|
||||
throw (new IOException("Unexpected report step: " + response.path("nextStep").asText()));
|
||||
}
|
||||
response = client.invoke(new UiApiRequest().withMethod(HttpMethod.POST).withPath("/processes/{processName}/{processUUID}/step/{stepName}")
|
||||
.withPathParam("processName", "renderSavedReport").withPathParam("processUUID", uuid).withPathParam("stepName", "input")
|
||||
.withFormParam("values", values));
|
||||
}
|
||||
throw (new IOException("Report generation timed out"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One durable row per handle, updated under the account lock as bytes flow.
|
||||
*******************************************************************************/
|
||||
private final class Transfer implements GeneratedReadChannel.Audit
|
||||
{
|
||||
private final long began = System.nanoTime();
|
||||
private final String format;
|
||||
private final QRecord row;
|
||||
private long bytes;
|
||||
private boolean limit;
|
||||
|
||||
/*******************************************************************************
|
||||
** Default unfinished transfers to ERROR, including a process terminated mid-read.
|
||||
*******************************************************************************/
|
||||
private Transfer(VirtualTree.Node node) throws IOException
|
||||
{
|
||||
format = node.job() == null ? node.path().substring(node.path().lastIndexOf('.') + 1) : node.job().getValueString("format");
|
||||
try
|
||||
{
|
||||
row = audit.insert(FileGatewayTables.TRANSFER_LOG, new QRecord().withValue("accountId", session.account.getValue("id"))
|
||||
.withValue("connectionLogId", session.connectionId).withValue("operation", "DOWNLOAD").withValue("path", node.path())
|
||||
.withValue("objectKind", node.kind()).withValue("objectId", node.objectId()).withValue("format", format)
|
||||
.withValue("bytes", 0L).withValue("outcome", "ERROR").withValue("clientChannel", "sftp/" + session.id));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to persist download audit", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Filter by account, operation and UTC day; include in-progress and failed transfers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void charge(int count) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
boolean allowed = audit.withAccountLock("id", session.account.getValue("id"), () ->
|
||||
{
|
||||
long maximum = config.getMaxGeneratedFileMb() * 1024L * 1024L;
|
||||
Long dailyMb = session.account.getValueLong("maxDownloadMbPerDay");
|
||||
long total = 0;
|
||||
if(dailyMb != null)
|
||||
{
|
||||
var start = clock.instant().atZone(ZoneOffset.UTC).toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||
for(int skip = 0; ; skip += 250)
|
||||
{
|
||||
var rows = audit.query(FileGatewayTables.TRANSFER_LOG, new QQueryFilter()
|
||||
.withCriteria(new QFilterCriteria("accountId", QCriteriaOperator.EQUALS, session.account.getValue("id")))
|
||||
.withCriteria(new QFilterCriteria("operation", QCriteriaOperator.EQUALS, "DOWNLOAD"))
|
||||
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, start))
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withSkip(skip).withLimit(250));
|
||||
for(QRecord record : rows)
|
||||
{
|
||||
Long value = record.getValueLong("bytes");
|
||||
total += value == null ? 0 : value;
|
||||
}
|
||||
if(rows.size() < 250)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(bytes + count > maximum || (dailyMb != null && total + count > dailyMb * 1024L * 1024L))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
audit.update(FileGatewayTables.TRANSFER_LOG, new QRecord().withValue("id", row.getValue("id")).withValue("bytes", bytes + count));
|
||||
return (true);
|
||||
});
|
||||
if(!allowed)
|
||||
{
|
||||
limit = true;
|
||||
throw (new IOException("Generated file or daily download limit exceeded"));
|
||||
}
|
||||
bytes += count;
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to enforce download limits", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve API denial and limit outcomes instead of collapsing them to generic errors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void finish(IOException failure) throws IOException
|
||||
{
|
||||
try
|
||||
{
|
||||
audit.update(FileGatewayTables.TRANSFER_LOG, new QRecord().withValue("id", row.getValue("id"))
|
||||
.withValue("durationMs", (System.nanoTime() - began) / 1_000_000)
|
||||
.withValue("outcome", limit ? "LIMIT" : failure instanceof AccessDeniedException ? "DENIED" : failure == null ? "OK" : "ERROR")
|
||||
.withValue("message", failure == null ? null : failure.getMessage()));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (new IOException("Unable to finalize download audit", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user