Compare commits

...
Author SHA1 Message Date
james.maesandCodex 19bc4c14b4 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 09:34:32 -05:00
james.maesandCodex c5e1dd7e14 ci: adopt central orb 0.7.12 and stable gates (#26)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 03:32:34 -05:00
james.maesandCodex d6ebb60de6 ci: centralize rc release completion in qqq orb (#25)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:27 -05:00
james.maesandCodex e41bf1147b ci: adopt shared orb 0.7.11 (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:54:13 -05:00
james.maes d4ca427445 Merge pull request #22 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:19:13 -05:00
james.maesandCodex 2542dc0f89 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:08:07 -05:00
james.maes c6954d86f1 Merge pull request #20 from QRun-IO/feature/permissions-in-catalog
feat(permissions): file_gateway.view/admin/connect in the permission catalog
2026-10-16 12:01:44 -05:00
james.maesandClaude Opus 5.5 05bfcd8d4b feat(permissions): list file_gateway.view/admin/connect in the permission catalog
The gateway checked these names only in code, so getAllAvailablePermissions
never listed them: the qbit-authentik mapper expanded them to nothing and
Identity policies could not grant them. FileGatewayPermissionChecker now
implements buildAvailablePermission, collapsing every gateway object onto
the three names (reads/widgets -> view, writes/processes -> admin, app ->
connect). Adds a test that all three are in the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:58:21 -05:00
james.maes d3cc594b0e Merge pull request #19 from QRun-IO/fix/file-gateway-increase-is-good
fix(home): File Gateway tile directions and reasoned empty tables
2026-10-16 10:19:54 -05:00
james.maesandClaude Opus 5.5 ff182b6d7f fix(home): File Gateway tile directions and reasoned empty tables
Failed jobs and Locked accounts now declare increaseIsGood=false (volume tiles
stay true). The four home tables (Recent failures, Top partners, Active
sessions, Server) set noRowsFoundHTML saying why they are empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:10:36 -05:00
james.maes 7482050234 Merge pull request #18 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:05:09 -05:00
james.maesandClaude Opus 5.5 27324a4159 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:17 -05:00
james.maes 37ee9028f2 Merge pull request #17 from QRun-IO/fix/no-log-before-auth
fix(sftp): no connection-log row for connections that never sign in
2026-10-15 07:19:19 -05:00
james.maesandClaude Opus 5.5 c8a231f5f8 fix(sftp): no connection-log row for connections that never sign in
The row was inserted at TCP accept with a provisional AUTH_FAILED, so the
kubelet's TCP readiness probe on the SFTP port wrote a "failed sign-in"
row every 5 seconds per pod in kof22 prod (about 35k a day). The row is
now written at the first authentication attempt or a pre-auth refusal
(the connection rate limit); probes and port scans leave nothing. The
per-IP connection rate is still counted at accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:15:38 -05:00
james.maes 0a8c95e0ad Merge pull request #16 from QRun-IO/fix/home-tables-four-columns
fix(home): home tables have at most four columns
2026-10-14 17:40:24 -05:00
james.maesandClaude Opus 5.5 f0e9836a89 fix(home): home tables have at most four columns
The Active sessions table had five (the pod is on the connection record
and in the Server table). kof22's readability guard allows four, so
tables stay readable at phone widths; a test now checks every home table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:37:48 -05:00
james.maes dd4643e60d Merge pull request #14 from QRun-IO/fix/home-table-no-widths
fix(home): home tables declare no column widths
2026-10-14 17:07:24 -05:00
james.maesandClaude Opus 5.5 483c856594 fix(home): home tables declare no column widths
Fixed 1fr widths made Next lay the home tables out at a minimum width
(the kof22 readability guard, docs/admin-dashboards.md). Columns now
declare no width; a test keeps it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:00:03 -05:00
james.maes 275cc26b6b Merge pull request #13 from QRun-IO/fix/optional-saved-object-pickers
fix: endpoint pickers only for saved-object sources the host registers
2026-10-14 16:56:13 -05:00
james.maesandClaude Opus 5.5 3e5cec20d2 fix: endpoint pickers only for saved-object sources the host registers
A host without saved reports (kof22) failed instance validation:
file_gateway_alias.savedReportId named the unregistered possible-value
source savedReport (and its bulkEdit process inherited it). The producer
now drops the picker and its contingent filter for savedView, savedReport
and savedBulkLoadProfile when the host has not registered that source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:45:02 -05:00
james.maes 1713d18fd7 Merge pull request #10 from QRun-IO/feature/preferences-file-transfer
feat(preferences): the File transfer section (SSH keys self-service)
2026-10-14 16:28:20 -05:00
james.maes 6073091434 Merge pull request #12 from QRun-IO/feature/pin-qqq-rc7
build: pin QQQ 4.1.0-RC.7 (first release with QRun-IO/qqq#1170)
2026-10-14 16:26:10 -05:00
james.maes db241b03bc Merge feature/pin-qqq-rc7 into feature/preferences-file-transfer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	CLAUDE.md
#	README.md
#	docs/DESIGN.md
2026-10-14 16:25:24 -05:00
james.maesandClaude Opus 5.5 10eb8807ba Merge develop into feature/pin-qqq-rc7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:20:51 -05:00
james.maes ac55e437bd Merge pull request #11 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:46:06 -05:00
james.maesandClaude Opus 5.5 3ba219d668 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:07:05 -05:00
james.maesandClaude Opus 5.5 d0572402a9 ci: re-run against the develop SNAPSHOT that includes QRun-IO/qqq#1162
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:57:19 -05:00
james.maesandClaude Opus 5.5 e6ff62181b feat(preferences): the "File transfer" section (SSH keys self-service)
A user-preferences section (QRun-IO/qqq#1162) for anyone with
file_gateway.connect: the SFTP username, host and host-key fingerprint,
and the user's own keys. "Add key" creates the user's account on first use
and inserts the key through the table (validation and audits apply);
"Revoke" sets revoked_at; labels and expiry are editable. The provider
resolves the user only from the request context and touches only that
user's account and keys. Duplicate fingerprints are rejected up front.

99 tests (11 new); JaCoCo 92.6% instructions, 97.7% classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:29:31 -05:00
james.maesandClaude Opus 5.5 dc8ac5f089 Merge develop into feature/preferences-file-transfer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:29:30 -05:00
james.maesandClaude Opus 5.5 7a5e42495d build: pin QQQ 4.1.0-RC.7 (first release with QRun-IO/qqq#1170)
Replaces the 4.1.0-SNAPSHOT stand-in (guardrail G7) and drops the private
FG-SNAPSHOT build notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:18:38 -05:00
james.maes 5098152460 Merge pull request #8 from QRun-IO/feature/phase-e-endpoints-home
feat: endpoints, outbox, PGP, app home and retention (phase e)
2026-10-14 09:17:42 -05:00
james.maes ab64cbf040 Merge pull request #7 from QRun-IO/feature/phase-c-downloads
feat: downloads of saved views and saved reports (phase c)
2026-10-14 09:17:29 -05:00
james.maes 6261e197d0 Merge pull request #6 from QRun-IO/feature/phase-b-upload
feat: upload to bulk load through object storage (phase b)
2026-10-14 09:17:14 -05:00
james.maes 3672bb513d Merge pull request #5 from QRun-IO/feature/phase-a-sftp-server
feat: SFTP server, virtual tree, SSH-key auth and listing (phase a)
2026-10-14 09:17:00 -05:00
james.maesandClaude Opus 5.5 182fb46a66 Merge feature/phase-c-downloads into feature/phase-e-endpoints-home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:32 -05:00
james.maesandClaude Opus 5.5 516c250d4f Merge feature/phase-b-upload into feature/phase-c-downloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:29 -05:00
james.maesandClaude Opus 5.5 c0646db521 Merge feature/phase-c-downloads into feature/phase-e-endpoints-home
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:20 -05:00
james.maesandClaude Opus 5.5 5fcf0d1f8d Merge feature/phase-b-upload into feature/phase-c-downloads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:20 -05:00
james.maesandClaude Opus 5.5 89978cf3d9 Merge feature/phase-a-sftp-server into feature/phase-b-upload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:19 -05:00
james.maesandClaude Opus 5.5 f0281057d7 fix(channels): the sftp wall owns its deny list
Core #1157 made the qbit prefixes in the assistant defaults transitional
and turned audit* into a write-only deny for the assistant. The sftp
channel now denies file_gateway_*, audit*, authentik_*, webhooks_*,
worm_audit_*, secured_data_* and assistant_* itself, plus core's generic
secret/credential/permission/session patterns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:13:15 -05:00
james.maesandClaude Opus 5.5 c1b78d3077 ci: re-run against the QQQ 4.1.0-SNAPSHOT that includes QRun-IO/qqq#1170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:08:47 -05:00
james.maesandClaude Opus 5.5 00a20eb5c8 ci: re-run against the QQQ 4.1.0-SNAPSHOT that includes QRun-IO/qqq#1170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:08:45 -05:00
james.maesandClaude Opus 5.5 8aca382843 ci: re-run against the QQQ 4.1.0-SNAPSHOT that includes QRun-IO/qqq#1170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:08:43 -05:00
james.maesandClaude Opus 5.5 02f4e993d1 ci: re-run against the QQQ 4.1.0-SNAPSHOT that includes QRun-IO/qqq#1170
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:08:41 -05:00
james.maesandClaude Opus 5.5 24f1cbec33 docs(k8s): note the older io.cilium/lb-ipam-ips annotation (k8s-galaxy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:02:59 -05:00
james.maesandClaude Opus 5.5 415d0bdd0a feat(ddl): indexes for the job queue, dedupe, live sessions and retention
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:56:01 -05:00
james.maesandClaude Opus 5.5 89e468ca45 feat: endpoints, outbox, PGP, app home and retention (phase e)
Phase (e) of docs/DESIGN.md:
- endpoints (aliases) with path validation and the ENDPOINTS_ONLY chroot;
  BULK_LOAD, INBOX (consumer by name), VIEW, REPORT and OUTBOX kinds;
- the outbox provider (FileGatewayOutbox.put) in every deployment, outbox
  files, pickup tracking and onDownloaded;
- optional per-account PGP: decrypt uploads, encrypt downloads (keys in
  memory, BouncyCastle);
- the typed app home (KPIs, recent failures, top partners, active
  sessions, gateway servers via file_gateway_server heartbeats);
- Unlock/Disable account, Revoke key and End session record actions;
- the daily retention process; FileGatewayIntegrations (ESB, Quick
  Search, API);
- docs/k8s example manifests (SFTP port 22 only, LB IP 10.100.22.65);
- plain labels for fields, enum values and child tabs; a test-scope demo
  server for screenshots.

88 tests; JaCoCo 92% instructions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:52:41 -05:00
james.maesandClaude Opus 5.5 3296cb9ca4 feat: downloads of saved views and saved reports (phase c)
Phase (c) of docs/DESIGN.md:
- views/<slug>.<csv|xlsx|json>: the saved view's filter, columns and order
  exported as the user through /qqq/v1 table export, streamed;
- reports/<slug>.<fmt>: renderSavedReport run as the user, then the file
  streamed from /download (listed only when a report storage table exists);
- a generated read channel with a bounded in-memory read-ahead window (no
  disk), maxGeneratedFileMb and the daily download cap, DOWNLOAD transfer
  rows.

Masking is what /qqq/v1 returns for the user: tested with a
FieldSecurityLock DENY field, byte-compared with a direct export.
77 tests; JaCoCo 92.6% instructions, 97.2% classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:13 -05:00
james.maesandClaude Opus 5.5 23896eb7f8 feat: upload to bulk load through object storage (phase b)
Phase (b) of docs/DESIGN.md:
- SFTP puts into upload/<profile>/ stream to qbit-object-storage with
  SHA-256 and size computed in flight, a bounded in-memory reorder window,
  size and daily quota enforcement, temporary-suffix and rename handling;
- upload jobs: dedupe, optional scanner (quarantine), a bounded worker pool
  with exclusive claims, stale requeue and the 1m..6h retry schedule;
- the consumer pipeline (FileGatewayConsumersMetaData) with the built-in
  bulk-load consumer, which runs <table>.bulkInsert as the user through
  /qqq/v1 with the saved profile (fails when value mappings are missing);
- sidecars (.processing/.done/.error), results CSVs and the archive, all
  in object storage, readable over SFTP; rm acknowledges a job;
- QAlerts.notify on failures (and success when asked);
- the "Retry upload" record action.

64 tests; JaCoCo 93% instructions, 97% classes; no-disk guard on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:55:43 -05:00
james.maesandClaude Opus 5.5 1cd1caa8c5 feat: SFTP server, virtual tree, SSH-key auth and listing (phase a)
Phase (a) of docs/DESIGN.md:
- the seven file_gateway_* tables (entities, PVSs, joins, child lists,
  DDL), contingent possible-value filters on endpoint profile/view/report,
  SSH key validation with server-side fingerprints, permissions
  file_gateway.view/admin/connect, the sftp client channel and walls;
- Apache MINA SSHD 2.16 SFTP server: publickey only (no password or
  keyboard-interactive), no shell/exec/forwarding, restricted algorithms,
  in-memory host key from an env var name;
- SshKeyAuthenticator: account and key checks, CIDR allowlists, expiry,
  DB-backed lockouts with an alert, per-IP rate limit, concurrent session
  cap, live revocation; sessions minted with createAutomatedSessionForUser
  and registered as UiApiInProcessSessions, requiring file_gateway.connect;
- a virtual NIO filesystem over /qqq/v1 (channel sftp): apps, tables,
  upload/<profile>/, views/, reports/, results/, archive/; writes and
  reads denied until phases (b) and (c);
- connection and transfer logs;
- the com.kingsrook.qbits.filegateway.api consumer/outbox contract types.

54 tests including a no-disk guard; JaCoCo 95% instructions, 98% classes.
Builds against QRun-IO/qqq#1170 (UiApiInProcessSessions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:09:03 -05:00
james.maes 852d474722 Merge pull request #4 from QRun-IO/feature/spec
docs(spec): qbit-file-gateway design
2026-10-13 19:43:04 -05:00
james.maesandClaude Opus 5.5 afc9d4d29d docs(spec): gateway LoadBalancer IP 10.100.22.65 (owner, 2026-10-13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:39:38 -05:00
james.maesandClaude Opus 5.5 0d2943a764 docs(spec): file gateway design (SFTP-only v1, no disk, consumer API)
The approved spec for qbit-file-gateway: a virtual SFTP tree generated
from metadata, uploads into saved bulk-load profiles, downloads of saved
views and reports, SSH-key accounts, audit tables, the consumer and
outbox API agreed with qbit-edi, the core UiApiInvoker additions, the
k8s example and the acceptance tests.

Owner decisions (2026-10-12): all recommendations accepted.
Owner decisions (2026-10-13): separate deployment, /qqq/v1 only,
SSH keys, SFTP only (FTPS future), no files or filesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:37:59 -05:00
james.maes ec8092e468 Merge pull request #1 from QRun-IO/feature/scaffold
chore: repo-standard scaffold for qbit-file-gateway
2026-10-13 19:33:18 -05:00
james.maesandClaude Opus 5.5 a26c518fb2 chore: repo-standard scaffold for qbit-file-gateway
Replaces the application template example with the qbit-file-gateway
skeleton per QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md: pom on
qbit-build-parent with checkstyle and coverage gates, CircleCI on
qqq-orb 0.6.8 with the GA-only release tag filter, RC automation,
dependabot, governance files, Maven wrapper and a minimal producer
and config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:30:36 -05:00
121 changed files with 24266 additions and 1074 deletions

No files matched your search

+11 -18
View File
@@ -1,21 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.5.0
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
build:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+20
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+25
View File
@@ -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]
+31
View File
@@ -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:
- "*"
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@17c94592d94c7d56c440277be6fe3526692887a6
+74
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release-no-integration
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+104
View File
@@ -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.
+65
View File
@@ -0,0 +1,65 @@
# 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
- 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.
+96 -17
View File
@@ -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.
+3
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+6
View File
@@ -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).
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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.
+360 -41
View File
@@ -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.
+4
View File
@@ -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.
+246
View File
@@ -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>
+19
View File
@@ -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.
*/
-30
View File
@@ -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.
-50
View File
@@ -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.
-7
View File
@@ -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.
-23
View File
@@ -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
View File
@@ -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.
+191
View File
@@ -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);
+91
View File
@@ -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
+20
View File
@@ -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
Vendored Executable
+295
View File
@@ -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 "$@"
Vendored
+189
View File
@@ -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"
+202 -35
View File
@@ -1,37 +1,134 @@
<?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>
<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.0.0</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.2</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.14</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 +137,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.7</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;
}
}
@@ -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<>());
}
}
@@ -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;
}
@@ -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").withRecordVersionField("status")
.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")));
}
}
@@ -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));
}
}
}
@@ -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,274 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.filegateway.sftp;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import java.util.Map;
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())));
}
/*******************************************************************************
** Optimistic status transition, called under withAccountLock because core checks
** expected versions before SQL execution rather than in the SQL WHERE clause.
*******************************************************************************/
public boolean transitionUpload(QRecord changes, String expectedStatus) throws QException
{
QRecord result = system(() -> new UpdateAction().execute(new UpdateInput(FileGatewayTables.UPLOAD_JOB)
.withRecords(List.of(changes)).withTransaction(transaction.get())
.withExpectedVersions(Map.of(changes.getValue("id"), expectedStatus))).getRecords().getFirst());
if(result.getErrors() != null && !result.getErrors().isEmpty())
{
return (false);
}
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());
}
}
@@ -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));
}
}
}
}
@@ -0,0 +1,894 @@
/*
* 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.ByteArrayInputStream;
import java.io.IOException;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
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.qbits.filegateway.storage.ObjectReadChannel;
import com.kingsrook.qbits.filegateway.upload.UploadJobs;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Permission-filtered virtual app and table tree with per-session in-memory caches.
*******************************************************************************/
public class VirtualTree
{
/*******************************************************************************
** A virtual directory or generated file.
*******************************************************************************/
public record Node(String path, boolean directory, String kind, String objectId, long size, QRecord job)
{
/*******************************************************************************
** Metadata-backed nodes have no stored size or job.
*******************************************************************************/
public Node(String path, boolean directory, String kind, String objectId)
{
this(path, directory, kind, objectId, 0, null);
}
}
private final GatewayUiApiClient client;
private final GatewaySession session;
private final AuditWriter audit;
private final Clock clock;
private UploadJobs uploads;
private GatewayObjectStore store;
private GeneratedDownloads downloads;
private com.kingsrook.qbits.filegateway.outbox.OutboxService outbox;
private JsonNode metadata;
private Instant metadataAt;
private final Map<String, JsonNode> saved = new HashMap<>();
private final Map<String, Instant> savedAt = new HashMap<>();
/*******************************************************************************
** Create a session-private tree and caches.
*******************************************************************************/
public VirtualTree(GatewayUiApiClient client, GatewaySession session, AuditWriter audit, Clock clock)
{
this.client = client;
this.session = session;
this.audit = audit;
this.clock = clock;
}
/*******************************************************************************
** Attach the runtime's upload pipeline; endpoint targets will reuse this service.
*******************************************************************************/
public VirtualTree withUploads(UploadJobs uploads, GatewayObjectStore store)
{
this.uploads = uploads;
this.store = store;
return (this);
}
/*******************************************************************************
** Attach generated downloads and the host's report-storage availability.
*******************************************************************************/
public VirtualTree withDownloads(GeneratedDownloads downloads)
{
this.downloads = downloads;
return (this);
}
/*******************************************************************************
** Use the same provider in the web deployment and the virtual outbox folders.
*******************************************************************************/
public VirtualTree withOutbox(com.kingsrook.qbits.filegateway.outbox.OutboxService outbox)
{
this.outbox = outbox;
return (this);
}
/*******************************************************************************
** Cache user metadata for sixty seconds.
*******************************************************************************/
private JsonNode metadata() throws IOException
{
if(metadataAt == null || !metadataAt.plusSeconds(60).isAfter(clock.instant()))
{
metadata = client.metadata();
metadataAt = clock.instant();
}
return (metadata);
}
/*******************************************************************************
** Cache saved objects for thirty seconds.
*******************************************************************************/
private JsonNode saved(String source, String table) throws IOException
{
String key = source + "/" + table;
if(!savedAt.containsKey(key) || !savedAt.get(key).plusSeconds(30).isAfter(clock.instant()))
{
saved.put(key, client.savedObjects(source, table));
savedAt.put(key, clock.instant());
}
return (saved.get(key));
}
/*******************************************************************************
** Resolve only through visible parents; hidden paths are indistinguishable from missing ones.
*******************************************************************************/
public synchronized Node stat(String path) throws IOException
{
sessionToken();
if("/".equals(path))
{
return (new Node("/", true, "APP", null));
}
int split = path.lastIndexOf('/');
String parent = split == 0 ? "/" : path.substring(0, split);
return (children(parent).stream().filter(node -> node.path().equals(path)).findFirst()
.orElseThrow(() -> new NoSuchFileException(path)));
}
/*******************************************************************************
** Refresh permissions even when a listing uses cached metadata.
*******************************************************************************/
private void sessionToken() throws IOException
{
try
{
session.token();
}
catch(Exception e)
{
throw (new AccessDeniedException("User session unavailable"));
}
}
/*******************************************************************************
** Audit each directory open once.
*******************************************************************************/
public synchronized List<Node> list(String path) throws IOException
{
long started = System.nanoTime();
try
{
sessionToken();
if(!session.operationAllowed())
{
log(path, "LIST", "LIMIT", null, started);
throw (new IOException("Operation limit exceeded"));
}
List<Node> nodes = children(path);
log(path, "LIST", "OK", null, started);
return (nodes);
}
catch(NoSuchFileException e)
{
log(path, "LIST", "NOT_FOUND", null, started);
throw (e);
}
catch(AccessDeniedException e)
{
log(path, "LIST", "DENIED", null, started);
throw (e);
}
}
/*******************************************************************************
** Resolve permitted app and table folders from UI API metadata.
*******************************************************************************/
private List<Node> children(String path) throws IOException
{
List<Node> nodes = rawChildren(path);
if(Boolean.TRUE.equals(session.account.getValueBoolean("pgpEncryptDownloads")))
{
return (nodes.stream().map(node -> node.directory() || "TEMPORARY".equals(node.kind()) ? node
: new Node(node.path() + ".pgp", false, node.kind(), node.objectId(), 0, node.job())).toList());
}
return (nodes);
}
/*******************************************************************************
** Resolve canonical plaintext paths before applying download encryption names.
*******************************************************************************/
private List<Node> rawChildren(String path) throws IOException
{
JsonNode meta = metadata();
List<Node> result = new ArrayList<>();
List<Node> endpoints = endpointChildren(path, meta);
if(!endpoints.isEmpty() || endpointMount(path, meta) != null)
{
result.addAll(endpoints);
if(!"/".equals(path))
{
return (result);
}
}
if("ENDPOINTS_ONLY".equals(session.account.getValueString("rootMode")))
{
if("/".equals(path))
{
return (result);
}
throw (new NoSuchFileException(path));
}
if("/".equals(path))
{
addAppChildren(result, "", meta.path("appTree"), meta);
return (result);
}
String[] segments = path.substring(1).split("/");
JsonNode children = meta.path("appTree");
String prefix = "";
String table = null;
int index = 0;
for(; index < segments.length; index++)
{
JsonNode found = null;
for(JsonNode child : children)
{
if(segments[index].equals(child.path("name").asText()))
{
found = child;
break;
}
}
if(found == null)
{
throw (new NoSuchFileException(path));
}
prefix += "/" + segments[index];
if("TABLE".equalsIgnoreCase(found.path("type").asText()) && meta.path("tables").has(segments[index]))
{
table = segments[index++];
break;
}
if(!"APP".equalsIgnoreCase(found.path("type").asText()))
{
throw (new NoSuchFileException(path));
}
children = found.path("children");
}
if(table == null)
{
addAppChildren(result, prefix, children, meta);
return (result);
}
List<String> folders = new ArrayList<>(List.of("results", "archive"));
boolean profiles = meta.path("tables").path(table).path("insertPermission").asBoolean(false) && meta.path("tables").has("savedBulkLoadProfile") && meta.path("processes").has(table + ".bulkInsert");
if(profiles && !saved("savedBulkLoadProfile", table).isEmpty())
{
folders.add("upload");
}
if(meta.path("tables").has("savedView"))
{
folders.add("views");
}
if(downloads != null && downloads.reportsAvailable() && meta.path("tables").has("savedReport") && meta.path("processes").has("renderSavedReport"))
{
folders.add("reports");
}
if(index == segments.length)
{
for(String folder : folders)
{
result.add(new Node(prefix + "/" + folder, true, folderKind(folder), null));
}
return (result);
}
String folder = segments[index++];
if(!folders.contains(folder))
{
throw (new NoSuchFileException(path));
}
if("results".equals(folder) || "archive".equals(folder))
{
return (artifacts(prefix, table, path, folder));
}
String source = "upload".equals(folder) ? "savedBulkLoadProfile" : "views".equals(folder) ? "savedView" : "savedReport";
JsonNode records = saved(source, table);
Map<String, Integer> counts = new HashMap<>();
for(JsonNode row : records)
{
counts.merge(slug(row.path("values").path("label").asText()), 1, Integer::sum);
}
for(JsonNode row : records)
{
String id = row.path("values").path("id").asText();
String name = slug(row.path("values").path("label").asText());
if(counts.get(name) > 1)
{
name += "-" + id;
}
String item = prefix + "/" + folder + "/" + name;
if("upload".equals(folder))
{
result.add(new Node(item, true, "PROFILE", id));
}
else
{
String formats = session.account.getValueString("formats");
for(String format : (formats == null ? "csv,xlsx" : formats).split("[,\\s]+"))
{
if(List.of("csv", "xlsx", "json").contains(format))
{
result.add(new Node(item + "." + format, false, folderKind(folder), id));
}
}
}
}
if(index == segments.length)
{
return (result);
}
if(index + 1 == segments.length && "upload".equals(folder) && result.stream().anyMatch(node -> node.path().equals(path)))
{
return (artifacts(prefix, table, path, "upload"));
}
throw (new NoSuchFileException(path));
}
/*******************************************************************************
** Endpoint targets are resolved from the same user-scoped saved-object queries.
*******************************************************************************/
private List<QRecord> visibleEndpoints(JsonNode meta) throws IOException
{
try
{
List<QRecord> visible = new ArrayList<>();
for(QRecord alias : audit.query(FileGatewayTables.ALIAS, UploadJobs.equal("isActive", true)))
{
if(alias.getValue("accountId") != null && !java.util.Objects.equals(alias.getValue("accountId"), session.account.getValue("id")))
{
continue;
}
String kind = alias.getValueString("kind");
if(List.of("INBOX", "OUTBOX").contains(kind))
{
visible.add(alias);
continue;
}
String table = alias.getValueString("tableName");
if(table == null || !meta.path("tables").has(table))
{
continue;
}
String source = "BULK_LOAD".equals(kind) ? "savedBulkLoadProfile" : "VIEW".equals(kind) ? "savedView" : "savedReport";
if(!meta.path("tables").has(source)
|| ("BULK_LOAD".equals(kind) && (!meta.path("tables").path(table).path("insertPermission").asBoolean(false) || !meta.path("processes").has(table + ".bulkInsert")))
|| ("REPORT".equals(kind) && (downloads == null || !downloads.reportsAvailable() || !meta.path("processes").has("renderSavedReport"))))
{
continue;
}
for(JsonNode row : saved(source, table))
{
if(row.path("values").path("id").asText().equals(alias.getValueString(source + "Id")))
{
alias.withValue("_label", row.path("values").path("label").asText());
visible.add(alias);
break;
}
}
}
return (visible);
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IOException("Unable to resolve endpoints", e));
}
}
/*******************************************************************************
** An exact visible mount, including an empty upload or outbox folder.
*******************************************************************************/
private QRecord endpointMount(String path, JsonNode meta) throws IOException
{
return (visibleEndpoints(meta).stream().filter(alias -> path.equals(alias.getValueString("path"))).findFirst().orElse(null));
}
/*******************************************************************************
** Mount ancestors reveal only the next segment, preserving endpoint chroots.
*******************************************************************************/
private List<Node> endpointChildren(String path, JsonNode meta) throws IOException
{
List<Node> nodes = new ArrayList<>();
String prefix = "/".equals(path) ? "/" : path + "/";
for(QRecord alias : visibleEndpoints(meta))
{
String mount = alias.getValueString("path");
if(mount.startsWith(prefix))
{
String suffix = mount.substring(prefix.length()).split("/")[0];
String child = prefix + suffix;
if(nodes.stream().noneMatch(node -> child.equals(node.path())))
{
nodes.add(new Node(child, true, "ENDPOINT", alias.getValueString("id"), 0, mount.equals(child) ? alias : null));
}
}
if(!mount.equals(path))
{
continue;
}
String kind = alias.getValueString("kind");
if(List.of("BULK_LOAD", "INBOX").contains(kind))
{
nodes.addAll(artifacts("", alias.getValueString("tableName"), path, "upload"));
}
else if("OUTBOX".equals(kind) && outbox != null)
{
try
{
for(QRecord row : outbox.list(alias.getValueInteger("id")))
{
nodes.add(new Node(prefix + row.getValueString("fileName"), false, "OUTBOX", row.getValueString("id"), row.getValueLong("sizeBytes"), row));
}
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IOException("Unable to list outbox", e));
}
}
else if(List.of("VIEW", "REPORT").contains(kind))
{
String format = alias.getValueString("format");
String formats = session.account.getValueString("formats");
if(format != null && List.of((formats == null ? "csv,xlsx" : formats).split("[,\\s]+")).contains(format))
{
String name = alias.getValueString("fileName");
if(name == null || name.isBlank())
{
name = slug(alias.getValueString("_label")) + "." + format;
}
nodes.add(new Node(prefix + name, false, kind, alias.getValueString("VIEW".equals(kind) ? "savedViewId" : "savedReportId"), 0, alias));
}
}
}
return (nodes);
}
/*******************************************************************************
** Sidecars and results expire after seven days; archive objects remain until retention.
*******************************************************************************/
private List<Node> artifacts(String prefix, String table, String path, String folder) throws IOException
{
List<Node> nodes = new ArrayList<>();
if("results".equals(folder) && !path.equals(prefix + "/results"))
{
throw (new NoSuchFileException(path));
}
if(uploads == null)
{
return (nodes);
}
for(QRecord job : uploads.forTable(session.account.getValueInteger("id"), table))
{
String status = job.getValueString("status");
boolean active = List.of("RECEIVING", "QUEUED", "RUNNING").contains(status);
boolean visible = job.getValueInstant("acknowledgedAt") == null
&& job.getValueInstant("createDate").plusSeconds(7 * 86400).isAfter(clock.instant());
String name = job.getValueString("fileName");
String id = job.getValueString("id");
if("upload".equals(folder) && visible && job.getValueString("path").startsWith(path + "/"))
{
String suffix = active ? ".processing" : List.of("SUCCEEDED", "DUPLICATE").contains(status) ? ".done" : ".error";
String item = path + "/" + name + suffix;
nodes.removeIf(node -> node.path().equals(item));
nodes.add(new Node(item, false, "SIDECAR", id, summary(job).length, job));
if("RECEIVING".equals(status) && uploads.temporary(name))
{
nodes.add(new Node(path + "/" + name, false, "TEMPORARY", id, job.getValueLong("sizeBytes"), job));
}
}
if("results".equals(folder) && visible && job.getValueString("resultsStorageKey") != null)
{
nodes.add(new Node(prefix + "/results/" + id + "-" + name + ".results.csv", false, "RESULT", id,
job.getValueLong("resultsSizeBytes") == null ? 0 : job.getValueLong("resultsSizeBytes"), job));
}
if("archive".equals(folder) && !active && job.getValueString("sha256") != null
&& job.getValueString("storageKey") != null && !job.getValueString("storageKey").startsWith("file-gateway/quarantine/"))
{
String date = job.getValueInstant("createDate").atZone(ZoneOffset.UTC).toLocalDate().toString();
String directory = prefix + "/archive/" + date;
if(path.equals(prefix + "/archive"))
{
if(nodes.stream().noneMatch(node -> node.path().equals(directory)))
{
nodes.add(new Node(directory, true, "ARCHIVE", null));
}
}
else if(path.equals(directory))
{
nodes.add(new Node(directory + "/" + id + "-" + name, false, "ARCHIVE", id, job.getValueLong("sizeBytes"), job));
}
}
}
return (nodes);
}
/*******************************************************************************
** Sidecar text is generated solely from durable job state.
*******************************************************************************/
private byte[] summary(QRecord job)
{
String text = job.getValueString("status") + "\n";
if(job.getValueString("resultSummary") != null)
{
text += job.getValueString("resultSummary") + "\n";
}
else if(job.getValueString("errorMessage") != null)
{
text += job.getValueString("errorMessage") + "\n";
}
return (text.getBytes(StandardCharsets.UTF_8));
}
/*******************************************************************************
** Writes require a visible profile, never a guessed table or profile identifier.
*******************************************************************************/
public SeekableByteChannel write(String path) throws IOException
{
String parent = path.substring(0, path.lastIndexOf('/'));
Node target = stat(parent);
if(uploads != null && target.job() != null && "ENDPOINT".equals(target.kind())
&& List.of("BULK_LOAD", "INBOX").contains(target.job().getValueString("kind")))
{
QRecord alias = target.job();
return (uploads.receive(session, path, alias.getValueString("tableName"), alias.getValueInteger("savedBulkLoadProfileId"), alias));
}
if(uploads == null || !target.directory() || !"PROFILE".equals(target.kind()) || target.objectId() == null)
{
deny(path, "UPLOAD", "Uploads require a bulk load profile folder");
}
String table = parent.substring(0, parent.lastIndexOf("/upload/"));
table = table.substring(table.lastIndexOf('/') + 1);
return (uploads.receive(session, path, table, Integer.valueOf(target.objectId())));
}
/*******************************************************************************
** Open generated views/reports or stored artifacts without using the filesystem.
*******************************************************************************/
public SeekableByteChannel read(String path) throws IOException
{
Node node = stat(path);
if(Boolean.TRUE.equals(session.account.getValueBoolean("pgpEncryptDownloads")) && !node.directory() && path.endsWith(".pgp"))
{
String plainPath = path.substring(0, path.length() - 4);
Node plain = rawChildren(plainPath.substring(0, plainPath.lastIndexOf('/'))).stream()
.filter(candidate -> candidate.path().equals(plainPath)).findFirst().orElseThrow(() -> new NoSuchFileException(path));
return (encrypted(plain));
}
return (readNode(node));
}
/*******************************************************************************
** Encrypt lazily into the existing fixed rolling window, without a producer thread.
*******************************************************************************/
private SeekableByteChannel encrypted(Node node) throws IOException
{
SeekableByteChannel source = readNode(node);
try
{
var encrypted = com.kingsrook.qbits.filegateway.pgp.PgpStreams.encrypt(java.nio.channels.Channels.newInputStream(source),
session.account.getValueString("pgpPublicKey"), node.path().substring(node.path().lastIndexOf('/') + 1));
boolean[] complete = {false};
var tracked = new java.io.FilterInputStream(encrypted)
{
@Override
public int read(byte[] bytes, int offset, int length) throws IOException
{
int count = in.read(bytes, offset, length);
if(count < 0)
{
complete[0] = true;
}
return (count);
}
};
return (new com.kingsrook.qbits.filegateway.storage.GeneratedReadChannel(tracked, 65536, new com.kingsrook.qbits.filegateway.storage.GeneratedReadChannel.Audit()
{
@Override
public void charge(int bytes)
{
// The underlying transfer accounts for plaintext bytes and applies its limits.
}
@Override
public void finish(IOException failure) throws IOException
{
if(complete[0] && failure == null && "OUTBOX".equals(node.kind()))
{
try
{
outbox.downloaded(node.job());
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IOException("Unable to acknowledge encrypted outbox", e));
}
}
}
}));
}
catch(IOException | RuntimeException e)
{
source.close();
throw (e);
}
}
/*******************************************************************************
** Open a resolved plaintext node through its original authorized delivery path.
*******************************************************************************/
private SeekableByteChannel readNode(Node node) throws IOException
{
String path = node.path();
if(downloads != null && !node.directory() && List.of("VIEW", "REPORT").contains(node.kind()))
{
return (downloads.open(node));
}
if(node.job() == null || node.directory() || "TEMPORARY".equals(node.kind()))
{
deny(path, "DOWNLOAD", "This file is not downloadable");
}
if(!session.operationAllowed())
{
throw (new IOException("Operation limit exceeded"));
}
long began = System.nanoTime();
ObjectReadChannel.Completion completion = (bytes, failed) -> downloadLog(node, bytes, failed, began);
if("SIDECAR".equals(node.kind()))
{
byte[] content = summary(node.job());
return (new ObjectReadChannel(content.length, (start, end) -> new ByteArrayInputStream(content, (int) start, (int) (end - start + 1)), completion));
}
String key = node.job().getValueString("RESULT".equals(node.kind()) ? "resultsStorageKey" : "storageKey");
ObjectReadChannel channel = new ObjectReadChannel(node.size(), (start, end) -> store.openRange(key, start, end), completion);
if("OUTBOX".equals(node.kind()) && !Boolean.TRUE.equals(session.account.getValueBoolean("pgpEncryptDownloads")))
{
channel.withFullDownload((bytes, failed) ->
{
try
{
outbox.downloaded(node.job());
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IOException("Unable to acknowledge outbox download", e));
}
});
}
return (channel);
}
/*******************************************************************************
** Record actual object-download bytes, including ranged reads, once per handle.
*******************************************************************************/
private void downloadLog(Node node, long bytes, boolean failed, long began) throws IOException
{
try
{
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", "SIDECAR".equals(node.kind()) ? "RESULT" : node.kind()).withValue("objectId", node.objectId())
.withValue("uploadJobId", "OUTBOX".equals(node.kind()) ? null : node.job().getValue("id")).withValue("bytes", bytes)
.withValue("sha256", "ARCHIVE".equals(node.kind()) ? node.job().getValue("sha256") : null)
.withValue("durationMs", (System.nanoTime() - began) / 1_000_000).withValue("outcome", failed ? "ERROR" : "OK")
.withValue("clientChannel", "sftp/" + session.id));
}
catch(Exception e)
{
throw (new IOException("Unable to persist download audit", e));
}
}
/*******************************************************************************
** Acknowledge visible job artifacts; archive removal remains denied.
*******************************************************************************/
public void remove(String path) throws IOException
{
Node node = stat(path);
if("OUTBOX".equals(node.kind()) && outbox != null)
{
try
{
outbox.remove(node.job());
log(path, "REMOVE", "OK", null, System.nanoTime());
return;
}
catch(com.kingsrook.qqq.backend.core.exceptions.QException e)
{
throw (new IOException("Unable to remove outbox file", e));
}
}
if(node.job() == null || !List.of("SIDECAR", "RESULT").contains(node.kind()))
{
deny(path, "REMOVE", "Only sidecars and results can be acknowledged");
}
uploads.acknowledge(node.job());
}
/*******************************************************************************
** Only completed temporary uploads may be renamed within the same visible folder.
*******************************************************************************/
public void rename(String source, String target) throws IOException
{
Node node = stat(source);
if(!"TEMPORARY".equals(node.kind()))
{
deny(source, "RENAME", "Only temporary uploads can be renamed");
}
stat(target.substring(0, target.lastIndexOf('/')));
uploads.rename(session.account.getValueInteger("id"), source, target);
}
/*******************************************************************************
** Expose only app and readable table nodes.
*******************************************************************************/
private void addAppChildren(List<Node> result, String prefix, JsonNode children, JsonNode meta)
{
for(JsonNode child : children)
{
String type = child.path("type").asText().toUpperCase(Locale.ROOT);
String name = child.path("name").asText();
if(("APP".equals(type) || (!prefix.isEmpty() && "TABLE".equals(type) && meta.path("tables").has(name)))
&& !name.isEmpty() && !name.contains("/") && !name.equals(".") && !name.equals(".."))
{
result.add(new Node(prefix + "/" + name, true, type, name));
}
}
}
/*******************************************************************************
** Map virtual folders to persisted audit kinds.
*******************************************************************************/
private String folderKind(String folder)
{
return (switch(folder)
{
case "upload" -> "PROFILE";
case "views" -> "VIEW";
case "reports" -> "REPORT";
case "results" -> "RESULT";
default -> "ARCHIVE";
});
}
/*******************************************************************************
** Stable lowercase label slug, at most eighty characters before a collision suffix.
*******************************************************************************/
public static String slug(String label)
{
String value = label.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
if(value.length() > 80)
{
value = value.substring(0, 80);
}
return (value.isEmpty() ? "item" : value);
}
/*******************************************************************************
** Audit and deny unsupported operations.
*******************************************************************************/
public void deny(String path, String operation, String message) throws IOException
{
session.operationAllowed();
log(path, operation, "DENIED", message, System.nanoTime());
throw (new AccessDeniedException(path, null, message));
}
/*******************************************************************************
** Persist the operation through the system audit writer.
*******************************************************************************/
private void log(String path, String operation, String outcome, String message, long started) throws IOException
{
String kind = "TABLE";
String id = null;
try
{
Node node = stat(path);
kind = List.of("SIDECAR", "TEMPORARY").contains(node.kind()) ? "RESULT" : node.kind();
id = node.objectId();
}
catch(IOException ignored)
{
// Missing targets have no object id.
}
try
{
audit.insert(FileGatewayTables.TRANSFER_LOG, new QRecord().withValue("accountId", session.account.getValue("id"))
.withValue("connectionLogId", session.connectionId).withValue("operation", operation).withValue("path", path)
.withValue("objectKind", kind).withValue("objectId", id).withValue("bytes", 0L)
.withValue("durationMs", (System.nanoTime() - started) / 1_000_000).withValue("outcome", outcome)
.withValue("message", message).withValue("clientChannel", "sftp/" + session.id));
}
catch(Exception e)
{
throw (new IOException("Unable to persist operation audit", e));
}
}
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more