Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf7296bf5 | |||
| 620fdda2fc | |||
| 58c0441763 | |||
| ba7fb2def7 | |||
| 4e7c77135c | |||
| 13fd7fc3bd | |||
| 18e6c3eb9b | |||
| a5c24fe73e | |||
| ee5b06b1e7 |
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "Bug"
|
||||||
|
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||||
|
title: "[Bug] "
|
||||||
|
labels:
|
||||||
|
- type/bug
|
||||||
|
- status/triage
|
||||||
|
- module/files
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Area/module:
|
||||||
|
- Affected version or commit:
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
Actual:
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Logs, screenshots, traces, or failing test output:
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or workflow that should pass when fixed:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "Docs / workflow"
|
||||||
|
about: "Request documentation, process, or developer workflow changes"
|
||||||
|
title: "[Docs] "
|
||||||
|
labels:
|
||||||
|
- type/docs
|
||||||
|
- status/triage
|
||||||
|
- module/files
|
||||||
|
- area/docs
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Repository:
|
||||||
|
- Document or workflow:
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
What is missing, unclear, duplicated, or stale?
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
What should the docs or workflow make clear?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
How should this be checked?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "Feature"
|
||||||
|
about: "Propose new user-visible behavior or platform capability"
|
||||||
|
title: "[Feature] "
|
||||||
|
labels:
|
||||||
|
- type/feature
|
||||||
|
- status/triage
|
||||||
|
- module/files
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
What user, operator, or developer problem should this solve?
|
||||||
|
|
||||||
|
## Proposed Capability
|
||||||
|
|
||||||
|
What should exist when this is done?
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- Related module repositories:
|
||||||
|
- Extension point or integration boundary:
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command, scenario, or UI flow that should prove completion:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Task"
|
||||||
|
about: "Track implementation, maintenance, or migration work"
|
||||||
|
title: "[Task] "
|
||||||
|
labels:
|
||||||
|
- type/task
|
||||||
|
- status/triage
|
||||||
|
- module/files
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
What needs to be completed?
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Owning repository:
|
||||||
|
- In-scope:
|
||||||
|
- Out-of-scope:
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Command or manual check:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Tech debt"
|
||||||
|
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||||
|
title: "[Debt] "
|
||||||
|
labels:
|
||||||
|
- type/debt
|
||||||
|
- status/triage
|
||||||
|
- module/files
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Cost
|
||||||
|
|
||||||
|
What does this make harder, riskier, slower, or more fragile?
|
||||||
|
|
||||||
|
## Desired Shape
|
||||||
|
|
||||||
|
What should the code, tests, or architecture look like afterwards?
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
What behavior, compatibility, or module boundary must be preserved?
|
||||||
|
|
||||||
|
## Verification Target
|
||||||
|
|
||||||
|
Focused checks that should pass:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
@@ -327,3 +327,21 @@ dist
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
# GovOPlaN shared ignore rules from govoplan-core
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
*.db
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
For combined checks, run:
|
For combined checks, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan
|
||||||
./scripts/check-focused.sh
|
tools/checks/check-focused.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
For WebUI permutation checks that include files:
|
For WebUI permutation checks that include files:
|
||||||
|
|||||||
74
README.md
74
README.md
@@ -1,5 +1,9 @@
|
|||||||
# govoplan-files
|
# govoplan-files
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module.
|
GovOPlaN Files is the managed file module. It bundles backend storage APIs and the Files WebUI package so file features can be installed as one module.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
@@ -46,10 +50,78 @@ Frontend package:
|
|||||||
@govoplan/files-webui
|
@govoplan/files-webui
|
||||||
```
|
```
|
||||||
|
|
||||||
The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing.
|
The campaign module can integrate with files when both modules are installed,
|
||||||
|
for example for managed attachment selection and campaign file sharing. Files
|
||||||
|
does not import campaign internals; campaign share/existence checks use the core
|
||||||
|
`campaigns.access` capability registered by the campaign module.
|
||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||||
|
|
||||||
|
Managed files can carry source provenance for connector and import workflows.
|
||||||
|
Upload callers may provide `source_provenance_json` and `source_revision`; the
|
||||||
|
module stores those values under file metadata, returns normalized
|
||||||
|
`source_provenance` and `source_revision` fields in file responses, and carries
|
||||||
|
them into managed campaign attachment matches for frozen execution evidence.
|
||||||
|
|
||||||
|
Connector policy preflight is available through
|
||||||
|
`POST /api/v1/files/connector-policy/evaluate`, and provenance-bearing uploads
|
||||||
|
can pass `connector_policy_json` to enforce the same policy before file content
|
||||||
|
is read. Policy payloads contain ordered `sources`; each source has
|
||||||
|
`scope_type`, optional `scope_id`, optional `label`, and a `policy` object. The
|
||||||
|
policy object supports `allow`/`allowlist`/`whitelist` and
|
||||||
|
`deny`/`denylist`/`blacklist` rules for `connectors`, `providers`,
|
||||||
|
`external_ids`, `external_paths`, and `external_urls`. Deny rules win across the
|
||||||
|
hierarchy; allow rules narrow access at each source that defines them.
|
||||||
|
|
||||||
|
Connector endpoint settings are exposed through governed connector profiles.
|
||||||
|
Profiles can be supplied as JSON through
|
||||||
|
`GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON`, or from a JSON file path through
|
||||||
|
`GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE`. Each profile has an `id`, `provider`,
|
||||||
|
`endpoint_url`, governance `scope_type`/`scope_id`, optional `capabilities`, and
|
||||||
|
credential references such as `password_env`, `token_env`, or `secret_ref`.
|
||||||
|
`GET /api/v1/files/connectors/profiles` returns only profiles visible to the
|
||||||
|
current principal (system, tenant, user, group, or accessible campaign scope) and
|
||||||
|
redacts secret values and environment variable names. Use the returned
|
||||||
|
`policy_sources` with connector policy preflight before importing files.
|
||||||
|
`GET /api/v1/files/connectors/providers` exposes provider descriptors for
|
||||||
|
Seafile, Nextcloud, WebDAV, SMB, NFS, and local filesystem connectors. The
|
||||||
|
descriptor declares implementation status, optional dependencies, permission
|
||||||
|
mapping, sync/index strategy, conflict handling, preview behavior, and audit
|
||||||
|
events so provider coverage remains visible without forcing every optional
|
||||||
|
protocol dependency to be installed.
|
||||||
|
`GET /api/v1/files/connectors/profiles/{profile_id}/browse` provides read-only
|
||||||
|
connector browsing. Browse entries use a shared `library`/`folder`/`file` shape,
|
||||||
|
run profile policy with the `browse` operation, and support Seafile,
|
||||||
|
WebDAV/Nextcloud, and SMB when the optional `smb` extra is installed. Seafile
|
||||||
|
profiles browse libraries and directories via Seafile's read-only API; profiles
|
||||||
|
can still opt into WebDAV browsing by setting `metadata.webdav_endpoint_url` or
|
||||||
|
`metadata.browse_protocol` to `webdav`.
|
||||||
|
`POST /api/v1/files/connectors/profiles/{profile_id}/import` imports a Seafile
|
||||||
|
or WebDAV/Nextcloud/SMB file into managed storage through the same governance,
|
||||||
|
conflict handling, source provenance, and connector audit path as direct
|
||||||
|
uploads. The Seafile provider uses account-token auth and the native file
|
||||||
|
download-link API; Nextcloud and generic WebDAV profiles use authenticated `GET`
|
||||||
|
requests against the configured WebDAV endpoint. SMB profiles use
|
||||||
|
`smb://server[:port]/share[/path]` endpoints and environment-backed credentials
|
||||||
|
through `smbprotocol`.
|
||||||
|
|
||||||
|
Local connector development assets live in `dev/connectors/`. The compose stack
|
||||||
|
boots Nextcloud, Seafile, WebDAV, and SMB endpoints for provider development and
|
||||||
|
manual interoperability testing.
|
||||||
|
|
||||||
|
Connector and collaboration ownership boundaries are documented in
|
||||||
|
`docs/CONNECTOR_BOUNDARY.md` and `docs/DOCUMENT_COLLABORATION_BOUNDARY.md`.
|
||||||
|
|
||||||
|
ZIP uploads are processed without buffering the whole archive in memory. The API
|
||||||
|
spools incoming ZIP request bodies to a bounded temporary file, then extracts
|
||||||
|
members with per-file and total extracted-size limits before storing managed
|
||||||
|
files.
|
||||||
|
|
||||||
|
Bulk rename and transfer APIs are owner-scoped: callers must provide the active
|
||||||
|
user or group file space with `owner_type` and `owner_id`. The storage layer
|
||||||
|
keeps a named legacy file-only helper for historical callers that lack owner
|
||||||
|
context, and regression tests cover its write-access checks.
|
||||||
|
|
||||||
## Release packaging
|
## Release packaging
|
||||||
|
|
||||||
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.
|
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths.
|
||||||
|
|||||||
19
dev/connectors/.env.example
Normal file
19
dev/connectors/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
NEXTCLOUD_HOST_PORT=9081
|
||||||
|
NEXTCLOUD_DB_ROOT_PASSWORD=govoplan-nextcloud-root
|
||||||
|
NEXTCLOUD_DB_PASSWORD=govoplan-nextcloud
|
||||||
|
NEXTCLOUD_ADMIN_USER=admin
|
||||||
|
NEXTCLOUD_ADMIN_PASSWORD=govoplan-nextcloud-admin
|
||||||
|
|
||||||
|
SEAFILE_HOST_PORT=9082
|
||||||
|
SEAFILE_MYSQL_ROOT_PASSWORD=govoplan-seafile-root
|
||||||
|
SEAFILE_ADMIN_EMAIL=admin@example.local
|
||||||
|
SEAFILE_ADMIN_PASSWORD=govoplan-seafile-admin
|
||||||
|
|
||||||
|
WEBDAV_HOST_PORT=9083
|
||||||
|
WEBDAV_USER=govoplan
|
||||||
|
WEBDAV_PASSWORD=govoplan-webdav
|
||||||
|
|
||||||
|
SMB_HOST_PORT=1445
|
||||||
|
SMB_SHARE_NAME=files
|
||||||
|
SMB_USER=govoplan
|
||||||
|
SMB_PASSWORD=govoplan-smb
|
||||||
130
dev/connectors/README.md
Normal file
130
dev/connectors/README.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Connector Dev Stack
|
||||||
|
|
||||||
|
This directory keeps local Docker Compose assets for GovOPlaN file connector
|
||||||
|
development. The stack is intentionally separate from production deployment and
|
||||||
|
binds services to localhost high ports.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-files/dev/connectors
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d nextcloud nextcloud-db webdav smb
|
||||||
|
docker compose up -d seafile-db seafile-memcached seafile
|
||||||
|
```
|
||||||
|
|
||||||
|
If the SMB service was already running before a compose change, recreate it so
|
||||||
|
the image and share configuration are applied:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose rm -sf smb
|
||||||
|
docker compose up -d --build smb
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- Nextcloud: `http://127.0.0.1:9081`, WebDAV root
|
||||||
|
`http://127.0.0.1:9081/remote.php/dav/files/admin/`
|
||||||
|
- Seafile: `http://127.0.0.1:9082`, WebDAV root
|
||||||
|
`http://127.0.0.1:9082/seafdav/`
|
||||||
|
- WebDAV: `http://127.0.0.1:9083`
|
||||||
|
- SMB: `smb://127.0.0.1:1445/files`
|
||||||
|
|
||||||
|
The local fixture data under `data/` is ignored by git.
|
||||||
|
|
||||||
|
## GovOPlaN Profile Config
|
||||||
|
|
||||||
|
Connector profiles are read from `GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON` or
|
||||||
|
`GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE`. Credentials should be referenced by
|
||||||
|
secret refs or environment variables; profile API responses only expose the
|
||||||
|
credential source and configured state.
|
||||||
|
|
||||||
|
Example local profile file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": "dev-seafile",
|
||||||
|
"label": "Dev Seafile",
|
||||||
|
"provider": "seafile",
|
||||||
|
"endpoint_url": "http://127.0.0.1:9082",
|
||||||
|
"scope_type": "system",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": "admin@example.local",
|
||||||
|
"password_env": "SEAFILE_ADMIN_PASSWORD",
|
||||||
|
"capabilities": ["browse", "import"],
|
||||||
|
"metadata": { "webdav_endpoint_url": "http://127.0.0.1:9082/seafdav/" },
|
||||||
|
"policy": { "allow": { "providers": ["seafile"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-nextcloud",
|
||||||
|
"label": "Dev Nextcloud",
|
||||||
|
"provider": "nextcloud",
|
||||||
|
"endpoint_url": "http://127.0.0.1:9081/remote.php/dav/files/admin/",
|
||||||
|
"scope_type": "system",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": "admin",
|
||||||
|
"password_env": "NEXTCLOUD_ADMIN_PASSWORD",
|
||||||
|
"capabilities": ["browse", "import"],
|
||||||
|
"policy": { "allow": { "providers": ["nextcloud", "webdav"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-webdav",
|
||||||
|
"label": "Dev WebDAV",
|
||||||
|
"provider": "webdav",
|
||||||
|
"endpoint_url": "http://127.0.0.1:9083",
|
||||||
|
"scope_type": "system",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": "govoplan",
|
||||||
|
"password_env": "WEBDAV_PASSWORD",
|
||||||
|
"capabilities": ["browse", "import"],
|
||||||
|
"policy": { "allow": { "providers": ["webdav"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-smb",
|
||||||
|
"label": "Dev SMB",
|
||||||
|
"provider": "smb",
|
||||||
|
"endpoint_url": "smb://127.0.0.1:1445/files",
|
||||||
|
"scope_type": "system",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": "govoplan",
|
||||||
|
"password_env": "SMB_PASSWORD",
|
||||||
|
"capabilities": ["browse", "import"],
|
||||||
|
"policy": { "allow": { "providers": ["smb"] } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Start GovOPlaN with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE=/mnt/DATA/git/govoplan-files/dev/connectors/profiles.local.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke Test
|
||||||
|
|
||||||
|
Run the connector helper smoke checks from an environment where
|
||||||
|
`govoplan-files` and `httpx` are importable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-files/dev/connectors
|
||||||
|
/mnt/DATA/git/govoplan-core/.venv/bin/python smoke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The script reads `.env` from this directory when present, seeds tiny WebDAV,
|
||||||
|
Nextcloud, and SMB fixtures, then browses and imports them through the connector
|
||||||
|
helper layer. Pass `--require-smb` after recreating the SMB container to fail on
|
||||||
|
SMB access errors instead of reporting them as an optional skip.
|
||||||
|
|
||||||
|
SMB smoke checks need the optional Python dependency in the environment running
|
||||||
|
the script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/mnt/DATA/git/govoplan-core/.venv/bin/python -m pip install -e /mnt/DATA/git/govoplan-files[smb]
|
||||||
|
```
|
||||||
|
|
||||||
|
The SMB service is built from `dev/connectors/smb/` so the development share is
|
||||||
|
deterministic: one `files` share backed by `data/smb`, with the credentials from
|
||||||
|
`.env`.
|
||||||
2
dev/connectors/data/.gitignore
vendored
Normal file
2
dev/connectors/data/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
106
dev/connectors/docker-compose.yml
Normal file
106
dev/connectors/docker-compose.yml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
name: govoplan-files-connectors
|
||||||
|
|
||||||
|
services:
|
||||||
|
nextcloud-db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${NEXTCLOUD_DB_ROOT_PASSWORD:-govoplan-nextcloud-root}
|
||||||
|
MYSQL_DATABASE: nextcloud
|
||||||
|
MYSQL_USER: nextcloud
|
||||||
|
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-govoplan-nextcloud}
|
||||||
|
volumes:
|
||||||
|
- nextcloud-db:/var/lib/mysql
|
||||||
|
|
||||||
|
nextcloud:
|
||||||
|
image: nextcloud:apache
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- nextcloud-db
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${NEXTCLOUD_HOST_PORT:-9081}:80"
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: nextcloud-db
|
||||||
|
MYSQL_DATABASE: nextcloud
|
||||||
|
MYSQL_USER: nextcloud
|
||||||
|
MYSQL_PASSWORD: ${NEXTCLOUD_DB_PASSWORD:-govoplan-nextcloud}
|
||||||
|
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER:-admin}
|
||||||
|
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD:-govoplan-nextcloud-admin}
|
||||||
|
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1"
|
||||||
|
volumes:
|
||||||
|
- nextcloud:/var/www/html
|
||||||
|
|
||||||
|
seafile-db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${SEAFILE_MYSQL_ROOT_PASSWORD:-govoplan-seafile-root}
|
||||||
|
MARIADB_AUTO_UPGRADE: "1"
|
||||||
|
MYSQL_LOG_CONSOLE: "true"
|
||||||
|
volumes:
|
||||||
|
- seafile-db:/var/lib/mysql
|
||||||
|
|
||||||
|
seafile-memcached:
|
||||||
|
image: memcached:1.6
|
||||||
|
restart: unless-stopped
|
||||||
|
command: memcached -m 256
|
||||||
|
|
||||||
|
seafile:
|
||||||
|
image: seafileltd/seafile-mc:11.0-latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- seafile-db
|
||||||
|
- seafile-memcached
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${SEAFILE_HOST_PORT:-9082}:80"
|
||||||
|
environment:
|
||||||
|
DB_HOST: seafile-db
|
||||||
|
DB_ROOT_PASSWD: ${SEAFILE_MYSQL_ROOT_PASSWORD:-govoplan-seafile-root}
|
||||||
|
TIME_ZONE: Etc/UTC
|
||||||
|
SEAFILE_ADMIN_EMAIL: ${SEAFILE_ADMIN_EMAIL:-admin@example.local}
|
||||||
|
SEAFILE_ADMIN_PASSWORD: ${SEAFILE_ADMIN_PASSWORD:-govoplan-seafile-admin}
|
||||||
|
SEAFILE_SERVER_LETSENCRYPT: "false"
|
||||||
|
SEAFILE_SERVER_HOSTNAME: "127.0.0.1:${SEAFILE_HOST_PORT:-9082}"
|
||||||
|
volumes:
|
||||||
|
- seafile-data:/shared
|
||||||
|
|
||||||
|
webdav:
|
||||||
|
image: rclone/rclone:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- serve
|
||||||
|
- webdav
|
||||||
|
- /data
|
||||||
|
- --addr
|
||||||
|
- :8080
|
||||||
|
- --user
|
||||||
|
- ${WEBDAV_USER:-govoplan}
|
||||||
|
- --pass
|
||||||
|
- ${WEBDAV_PASSWORD:-govoplan-webdav}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${WEBDAV_HOST_PORT:-9083}:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data/webdav:/data
|
||||||
|
|
||||||
|
smb:
|
||||||
|
build:
|
||||||
|
context: ./smb
|
||||||
|
image: govoplan-files-samba-dev:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SMB_SHARE_NAME: ${SMB_SHARE_NAME:-files}
|
||||||
|
SMB_USER: ${SMB_USER:-govoplan}
|
||||||
|
SMB_PASSWORD: ${SMB_PASSWORD:-govoplan-smb}
|
||||||
|
SMB_UID: ${SMB_UID:-1000}
|
||||||
|
SMB_GID: ${SMB_GID:-1000}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${SMB_HOST_PORT:-1445}:445"
|
||||||
|
volumes:
|
||||||
|
- ./data/smb:/storage
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nextcloud-db:
|
||||||
|
nextcloud:
|
||||||
|
seafile-db:
|
||||||
|
seafile-data:
|
||||||
10
dev/connectors/smb/Dockerfile
Normal file
10
dev/connectors/smb/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache samba-server samba-common-tools
|
||||||
|
|
||||||
|
COPY entrypoint.sh /usr/local/bin/govoplan-samba-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/govoplan-samba-entrypoint
|
||||||
|
|
||||||
|
EXPOSE 445
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/govoplan-samba-entrypoint"]
|
||||||
50
dev/connectors/smb/entrypoint.sh
Normal file
50
dev/connectors/smb/entrypoint.sh
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
share_name="${SMB_SHARE_NAME:-files}"
|
||||||
|
user_name="${SMB_USER:-govoplan}"
|
||||||
|
password="${SMB_PASSWORD:-govoplan-smb}"
|
||||||
|
uid="${SMB_UID:-1000}"
|
||||||
|
gid="${SMB_GID:-1000}"
|
||||||
|
|
||||||
|
mkdir -p /storage /var/lib/samba/private /run/samba
|
||||||
|
|
||||||
|
if ! getent group "$user_name" >/dev/null 2>&1; then
|
||||||
|
addgroup -g "$gid" "$user_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! id "$user_name" >/dev/null 2>&1; then
|
||||||
|
adduser -D -H -s /sbin/nologin -u "$uid" -G "$user_name" "$user_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R "$user_name:$user_name" /storage
|
||||||
|
|
||||||
|
cat > /etc/samba/smb.conf <<EOF
|
||||||
|
[global]
|
||||||
|
server role = standalone server
|
||||||
|
workgroup = WORKGROUP
|
||||||
|
security = user
|
||||||
|
map to guest = Never
|
||||||
|
server min protocol = SMB2
|
||||||
|
load printers = no
|
||||||
|
printing = bsd
|
||||||
|
disable spoolss = yes
|
||||||
|
log level = 1
|
||||||
|
passdb backend = tdbsam
|
||||||
|
|
||||||
|
[$share_name]
|
||||||
|
path = /storage
|
||||||
|
browseable = yes
|
||||||
|
read only = no
|
||||||
|
guest ok = no
|
||||||
|
valid users = $user_name
|
||||||
|
force user = $user_name
|
||||||
|
force group = $user_name
|
||||||
|
create mask = 0664
|
||||||
|
directory mask = 0775
|
||||||
|
EOF
|
||||||
|
|
||||||
|
printf '%s\n%s\n' "$password" "$password" | smbpasswd -s -a "$user_name" >/dev/null
|
||||||
|
smbpasswd -e "$user_name" >/dev/null
|
||||||
|
|
||||||
|
exec smbd --foreground --no-process-group --debug-stdout
|
||||||
215
dev/connectors/smoke.py
Normal file
215
dev/connectors/smoke.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from govoplan_files.backend.storage.connector_browse import browse_connector_profile
|
||||||
|
from govoplan_files.backend.storage.connector_imports import read_connector_file
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import connector_profiles_from_payload
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Smoke-test GovOPlaN connector helpers against the local dev compose stack.")
|
||||||
|
parser.add_argument("--require-smb", action="store_true", help="Fail if the SMB connector cannot browse/import.")
|
||||||
|
parser.add_argument("--debug-smb", action="store_true", help="Print direct smbclient probes before the connector smoke check.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_load_dotenv()
|
||||||
|
_default_env()
|
||||||
|
preflight_failures = _preflight_services(require_smb=args.require_smb)
|
||||||
|
if preflight_failures:
|
||||||
|
for failure in preflight_failures:
|
||||||
|
print(f"FAIL {failure}")
|
||||||
|
print("Start or recreate the connector stack from this directory with: docker compose up -d nextcloud nextcloud-db webdav smb")
|
||||||
|
return 1
|
||||||
|
_seed_webdav_fixture()
|
||||||
|
_seed_smb_fixture()
|
||||||
|
try:
|
||||||
|
_seed_nextcloud_fixture()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
print(f"FAIL dev-nextcloud seed failed: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
profiles = {profile.id: profile for profile in connector_profiles_from_payload({"profiles": _profile_payloads()})}
|
||||||
|
if args.debug_smb:
|
||||||
|
_debug_smb(profiles["dev-smb"])
|
||||||
|
failures: list[str] = []
|
||||||
|
for profile_id, folder, file_path, expected in (
|
||||||
|
("dev-webdav", "GovOPlaN", "GovOPlaN/webdav-live.txt", "webdav live fixture"),
|
||||||
|
("dev-nextcloud", "GovOPlaN", "GovOPlaN/nextcloud-live.txt", "nextcloud live fixture"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
_exercise_profile(profiles[profile_id], folder=folder, file_path=file_path, expected=expected)
|
||||||
|
except Exception as exc:
|
||||||
|
failures.append(f"{profile_id}: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_exercise_profile(profiles["dev-smb"], folder="GovOPlaN", file_path="GovOPlaN/smb-live.txt", expected="smb live fixture")
|
||||||
|
except Exception as exc:
|
||||||
|
message = f"dev-smb: {exc}"
|
||||||
|
if args.require_smb:
|
||||||
|
failures.append(message)
|
||||||
|
else:
|
||||||
|
print(f"SKIP {message}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAIL {failure}")
|
||||||
|
return 1
|
||||||
|
print("OK connector dev stack smoke checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _default_env() -> None:
|
||||||
|
defaults = {
|
||||||
|
"NEXTCLOUD_ADMIN_USER": "admin",
|
||||||
|
"NEXTCLOUD_ADMIN_PASSWORD": "govoplan-nextcloud-admin",
|
||||||
|
"WEBDAV_USER": "govoplan",
|
||||||
|
"WEBDAV_PASSWORD": "govoplan-webdav",
|
||||||
|
"SMB_SHARE_NAME": "files",
|
||||||
|
"SMB_USER": "govoplan",
|
||||||
|
"SMB_PASSWORD": "govoplan-smb",
|
||||||
|
}
|
||||||
|
for key, value in defaults.items():
|
||||||
|
os.environ.setdefault(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
path = ROOT / ".env"
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
text = line.strip()
|
||||||
|
if not text or text.startswith("#") or "=" not in text:
|
||||||
|
continue
|
||||||
|
key, value = text.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if not key or key in os.environ:
|
||||||
|
continue
|
||||||
|
os.environ[key] = value.strip().strip("\"'")
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_services(*, require_smb: bool) -> list[str]:
|
||||||
|
failures: list[str] = []
|
||||||
|
nextcloud_url = f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/status.php"
|
||||||
|
try:
|
||||||
|
response = httpx.get(nextcloud_url, timeout=3.0)
|
||||||
|
if response.status_code != 200:
|
||||||
|
failures.append(f"dev-nextcloud expected HTTP 200 at {nextcloud_url}, got HTTP {response.status_code}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
failures.append(f"dev-nextcloud is not reachable at {nextcloud_url}: {exc}")
|
||||||
|
|
||||||
|
webdav_url = f"http://127.0.0.1:{os.getenv('WEBDAV_HOST_PORT', '9083')}/"
|
||||||
|
try:
|
||||||
|
response = httpx.get(webdav_url, timeout=3.0)
|
||||||
|
if response.status_code not in {200, 401}:
|
||||||
|
failures.append(f"dev-webdav expected HTTP 200/401 at {webdav_url}, got HTTP {response.status_code}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
failures.append(f"dev-webdav is not reachable at {webdav_url}: {exc}")
|
||||||
|
|
||||||
|
if require_smb:
|
||||||
|
smb_port = int(os.getenv("SMB_HOST_PORT", "1445"))
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", smb_port), timeout=3.0):
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
failures.append(f"dev-smb is not reachable at 127.0.0.1:{smb_port}: {exc}")
|
||||||
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_payloads() -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "dev-webdav",
|
||||||
|
"provider": "webdav",
|
||||||
|
"endpoint_url": f"http://127.0.0.1:{os.getenv('WEBDAV_HOST_PORT', '9083')}/",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": os.getenv("WEBDAV_USER", "govoplan"),
|
||||||
|
"password_env": "WEBDAV_PASSWORD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-nextcloud",
|
||||||
|
"provider": "nextcloud",
|
||||||
|
"endpoint_url": f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/remote.php/dav/files/{os.getenv('NEXTCLOUD_ADMIN_USER', 'admin')}/",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": os.getenv("NEXTCLOUD_ADMIN_USER", "admin"),
|
||||||
|
"password_env": "NEXTCLOUD_ADMIN_PASSWORD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dev-smb",
|
||||||
|
"provider": "smb",
|
||||||
|
"endpoint_url": f"smb://127.0.0.1:{os.getenv('SMB_HOST_PORT', '1445')}/{os.getenv('SMB_SHARE_NAME', 'files')}",
|
||||||
|
"credential_mode": "basic",
|
||||||
|
"username": os.getenv("SMB_USER", "govoplan"),
|
||||||
|
"password_env": "SMB_PASSWORD",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _exercise_profile(profile, *, folder: str, file_path: str, expected: str) -> None:
|
||||||
|
items = browse_connector_profile(profile, path=folder)
|
||||||
|
paths = {item.path for item in items}
|
||||||
|
if file_path not in paths:
|
||||||
|
raise RuntimeError(f"{file_path!r} not found; saw {sorted(paths)!r}")
|
||||||
|
downloaded = read_connector_file(profile, library_id="", path=file_path, max_bytes=1024 * 1024)
|
||||||
|
text = downloaded.data.decode("utf-8").strip()
|
||||||
|
if text != expected:
|
||||||
|
raise RuntimeError(f"{file_path!r} content mismatch: {text!r}")
|
||||||
|
print(f"OK {profile.id} browse/import {file_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_smb(profile) -> None:
|
||||||
|
try:
|
||||||
|
import smbclient
|
||||||
|
except ImportError as exc:
|
||||||
|
print(f"DEBUG smbclient import failed: {exc}")
|
||||||
|
return
|
||||||
|
from govoplan_files.backend.storage.connector_browse import _smb_client_kwargs, _smb_location, _smb_unc_path
|
||||||
|
|
||||||
|
location = _smb_location(profile)
|
||||||
|
kwargs = _smb_client_kwargs(profile, location)
|
||||||
|
print(f"DEBUG SMB endpoint=//{location.server}:{location.port}/{location.share} root_path={location.root_path!r}")
|
||||||
|
print(f"DEBUG SMB user={kwargs.get('username')!r} require_signing={kwargs.get('require_signing')!r} auth_protocol={kwargs.get('auth_protocol')!r}")
|
||||||
|
try:
|
||||||
|
smbclient.reset_connection_cache()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"DEBUG SMB reset cache failed: {exc}")
|
||||||
|
for path in (_smb_unc_path(location, ""), _smb_unc_path(location, "GovOPlaN")):
|
||||||
|
try:
|
||||||
|
print(f"DEBUG SMB list {path}: {smbclient.listdir(path, **kwargs)}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"DEBUG SMB list {path} failed: {type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_webdav_fixture() -> None:
|
||||||
|
path = ROOT / "data" / "webdav" / "GovOPlaN"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / "webdav-live.txt").write_text("webdav live fixture\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_smb_fixture() -> None:
|
||||||
|
path = ROOT / "data" / "smb" / "GovOPlaN"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
(path / "smb-live.txt").write_text("smb live fixture\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_nextcloud_fixture() -> None:
|
||||||
|
base_url = f"http://127.0.0.1:{os.getenv('NEXTCLOUD_HOST_PORT', '9081')}/remote.php/dav/files/{os.getenv('NEXTCLOUD_ADMIN_USER', 'admin')}/GovOPlaN"
|
||||||
|
auth = (os.getenv("NEXTCLOUD_ADMIN_USER", "admin"), os.getenv("NEXTCLOUD_ADMIN_PASSWORD", "govoplan-nextcloud-admin"))
|
||||||
|
response = httpx.request("MKCOL", base_url, auth=auth, timeout=15.0)
|
||||||
|
if response.status_code not in {201, 405}:
|
||||||
|
raise RuntimeError(f"Nextcloud MKCOL failed with HTTP {response.status_code}: {response.text[:200]}")
|
||||||
|
response = httpx.put(f"{base_url}/nextcloud-live.txt", content=b"nextcloud live fixture\n", auth=auth, timeout=15.0)
|
||||||
|
if response.status_code not in {200, 201, 204}:
|
||||||
|
raise RuntimeError(f"Nextcloud PUT failed with HTTP {response.status_code}: {response.text[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
65
docs/CONNECTOR_BOUNDARY.md
Normal file
65
docs/CONNECTOR_BOUNDARY.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# File Connector Boundary
|
||||||
|
|
||||||
|
GovOPlaN Files owns the managed-file boundary: frozen blobs, versions, shares,
|
||||||
|
campaign attachment evidence, provenance, connector policy, connector profiles,
|
||||||
|
and read-only browse/import APIs that turn external files into managed
|
||||||
|
GovOPlaN files.
|
||||||
|
|
||||||
|
The built-in connector layer is intentionally on-demand. It does not run remote
|
||||||
|
indexing, background sync, remote mutation, or upstream permission management.
|
||||||
|
Connectors may browse an external source, import one selected file, freeze it as
|
||||||
|
a managed file, record provenance, and audit the access.
|
||||||
|
|
||||||
|
## Built-In Baseline
|
||||||
|
|
||||||
|
The files module may keep small baseline providers when they are needed for
|
||||||
|
normal product workflows and can share the same governance model:
|
||||||
|
|
||||||
|
- Seafile through the native read/download API
|
||||||
|
- Nextcloud through WebDAV
|
||||||
|
- generic WebDAV
|
||||||
|
- SMB through the optional `smb` extra
|
||||||
|
|
||||||
|
These providers are surfaced through connector descriptors at
|
||||||
|
`GET /api/v1/files/connectors/providers`. Provider descriptors declare whether
|
||||||
|
the provider is implemented, whether its optional dependency is installed, and
|
||||||
|
which browse/import behaviors are available.
|
||||||
|
|
||||||
|
## Separate Connector Module Candidates
|
||||||
|
|
||||||
|
A separate connector module becomes appropriate when integration needs exceed
|
||||||
|
on-demand browse/import:
|
||||||
|
|
||||||
|
- background indexing or synchronization
|
||||||
|
- bidirectional remote mutation
|
||||||
|
- long-running transfer workers
|
||||||
|
- provider-specific credential lifecycle or OAuth flows
|
||||||
|
- DMS/eAkte metadata models, registers, retention, or filing plans
|
||||||
|
- S3-compatible object store administration
|
||||||
|
- NFS host-mount lifecycle or sidecar coordination
|
||||||
|
- provider-specific WebUI administration beyond profile fields
|
||||||
|
|
||||||
|
In that model, `govoplan-files` should keep the managed-file import contract,
|
||||||
|
policy checks, provenance model, and audit events. A connector module should own
|
||||||
|
provider-specific discovery, synchronization, credentials, health checks, and
|
||||||
|
any remote write behavior.
|
||||||
|
|
||||||
|
## Provider Responsibilities
|
||||||
|
|
||||||
|
Every provider must:
|
||||||
|
|
||||||
|
- enforce GovOPlaN profile visibility and connector policy before browse/import
|
||||||
|
- keep credentials as environment variables or secret references, never API
|
||||||
|
response values
|
||||||
|
- import external files into managed storage before they are used in campaigns
|
||||||
|
or workflows
|
||||||
|
- preserve source provenance and revision metadata
|
||||||
|
- emit connector audit events for imported or accessed files
|
||||||
|
- treat remote ACLs as upstream checks, not as a replacement for GovOPlaN policy
|
||||||
|
|
||||||
|
## Non-Goals For Files
|
||||||
|
|
||||||
|
Files does not own collaborative editing, comments, document review workflows,
|
||||||
|
remote lock orchestration, DMS records management, or external system data
|
||||||
|
models. Those belong to future document, workflow, DMS, or connector modules and
|
||||||
|
should integrate through capabilities and managed-file imports.
|
||||||
147
docs/CONNECTOR_SPACES.md
Normal file
147
docs/CONNECTOR_SPACES.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Connector Spaces
|
||||||
|
|
||||||
|
GovOPlaN Files should treat external file shares as two separate product
|
||||||
|
objects:
|
||||||
|
|
||||||
|
1. A governed connection profile defines a reusable remote endpoint such as a
|
||||||
|
WebDAV server, Nextcloud account, Seafile server, or SMB share. Profiles are
|
||||||
|
administered in settings at system or tenant scope, with the same
|
||||||
|
inheritance and limit semantics used by mail-server profiles.
|
||||||
|
2. A governed credential profile defines reusable authentication material for
|
||||||
|
one provider or for any compatible provider. Credentials are administered
|
||||||
|
separately from connection profiles and can carry their own allow/deny
|
||||||
|
policy.
|
||||||
|
3. A linked file space binds one concrete remote folder or library path from an
|
||||||
|
allowed profile to a user or group. Linked spaces appear beside "My files"
|
||||||
|
and group file spaces in the files module.
|
||||||
|
|
||||||
|
This keeps secrets, endpoint governance, and tenant limits in settings, while
|
||||||
|
keeping user/group workspaces and concrete folder choices in the files module.
|
||||||
|
|
||||||
|
## Model
|
||||||
|
|
||||||
|
Connection profile:
|
||||||
|
|
||||||
|
- provider: `seafile`, `nextcloud`, `webdav`, `smb`, or a future provider
|
||||||
|
- endpoint URL and optional base path
|
||||||
|
- scope: `system` or `tenant` for administered profiles; user/group profiles
|
||||||
|
may be allowed later only when policy explicitly permits them
|
||||||
|
- optional credential profile id
|
||||||
|
- capabilities: browse, sync, import, optional write when a provider supports it
|
||||||
|
- profile-local policy for allow/deny rules
|
||||||
|
|
||||||
|
Credential profile:
|
||||||
|
|
||||||
|
- provider: a specific provider or any provider
|
||||||
|
- scope: `system` or `tenant` for administered credentials
|
||||||
|
- credential mode: anonymous, environment reference, secret reference, or
|
||||||
|
encrypted stored password/token
|
||||||
|
- username and redacted secret configuration
|
||||||
|
- credential-local policy for allow/deny rules
|
||||||
|
|
||||||
|
Connector policy:
|
||||||
|
|
||||||
|
- system policy is the baseline
|
||||||
|
- tenant policy inherits system policy and may narrow it unless system allows
|
||||||
|
lower-level relaxation
|
||||||
|
- future user/group policy may narrow tenant policy for self-service links
|
||||||
|
- policies can allow or block providers, profile ids, endpoint URLs, external
|
||||||
|
path prefixes, credential ids, credential inheritance, and local linked-space
|
||||||
|
creation
|
||||||
|
|
||||||
|
Linked connector space:
|
||||||
|
|
||||||
|
- owner type: user or group
|
||||||
|
- display name
|
||||||
|
- connector profile id
|
||||||
|
- remote library id or share id
|
||||||
|
- remote root path
|
||||||
|
- sync mode: manual initially; background sync is future work
|
||||||
|
- read-only flag from provider/policy
|
||||||
|
- active/deleted state
|
||||||
|
|
||||||
|
The linked space should be addressable as a normal file space in the files UI.
|
||||||
|
For the first implementation slice, actions may use the existing connector
|
||||||
|
browse/sync APIs and managed file storage. A linked space can therefore browse
|
||||||
|
the remote folder and sync selected files into managed storage. Later slices can
|
||||||
|
add a native remote listing view or background sync jobs.
|
||||||
|
|
||||||
|
## Policy Semantics
|
||||||
|
|
||||||
|
Use mail-profile terminology because administrators already see it there:
|
||||||
|
|
||||||
|
- must use: lower scopes are restricted to selected profile ids or providers
|
||||||
|
- can use: lower scopes may choose from inherited allowed profiles
|
||||||
|
- shall not use anything outside: endpoint URLs and path prefixes are enforced
|
||||||
|
by deny/allow rules before browse, import, or sync
|
||||||
|
- may create local links: controls whether users/groups can add linked spaces
|
||||||
|
from inherited profiles
|
||||||
|
|
||||||
|
Connector policy should provide an explainable effective policy response with
|
||||||
|
source path entries: system, tenant, user, group, campaign when applicable.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- provider descriptors for Seafile, Nextcloud, WebDAV, and SMB
|
||||||
|
- database-backed connector profiles with system and tenant scope
|
||||||
|
- database-backed connector credentials with system and tenant scope
|
||||||
|
- encrypted stored password/token support for database credentials
|
||||||
|
- JSON/environment-defined connector profiles with system, tenant, user, group,
|
||||||
|
and campaign visibility
|
||||||
|
- connector allow/deny policy enforcement before browse/import/sync, including
|
||||||
|
provider, connection profile, credential profile, and path checks
|
||||||
|
- read-only browse endpoints
|
||||||
|
- import and sync into managed files with provenance and revision metadata
|
||||||
|
- audit events for connector import, sync, and access
|
||||||
|
- settings/admin UI sections for system and tenant file connections and
|
||||||
|
credentials
|
||||||
|
- linked connector-space rows owned by users or groups
|
||||||
|
- file-space API responses that include linked connector spaces
|
||||||
|
- Files UI entry point to create linked connector spaces from a browsed remote
|
||||||
|
profile/folder
|
||||||
|
- Files UI sync dialog for choosing a profile, browsing a remote folder, and
|
||||||
|
syncing a selected file into a managed destination folder
|
||||||
|
- Files UI connector-space view with provider/read-only/manual-sync state
|
||||||
|
- Docker dev stack smoke checks for WebDAV, Nextcloud, and SMB
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
|
||||||
|
- explicit connector profile policy rows equivalent to mail-profile policies
|
||||||
|
- effective connector policy/explain UI equivalent to mail-profile policies
|
||||||
|
- edit/manage actions for existing linked connector spaces
|
||||||
|
- optional background sync or remote-write semantics
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
1. Persist governed connector profiles and policies.
|
||||||
|
- `file_connector_profiles` exists for system and tenant profiles.
|
||||||
|
- `file_connector_credentials` exists for system and tenant credentials.
|
||||||
|
- Environment JSON profiles remain bootstrap/compatibility profiles.
|
||||||
|
- Profile and credential CRUD endpoints exist for database-backed records.
|
||||||
|
- Remaining: `file_connector_policies` plus effective policy/explain output.
|
||||||
|
|
||||||
|
2. Add linked connector spaces.
|
||||||
|
- `file_connector_spaces` exists with owner user/group, profile id, library
|
||||||
|
id, remote path, display label, sync mode, and active state.
|
||||||
|
- Connector policy is enforced before creating or using a link.
|
||||||
|
- Linked connector spaces are returned from `/api/v1/files/spaces`.
|
||||||
|
|
||||||
|
3. Surface linked spaces in the Files UI.
|
||||||
|
- Linked spaces appear beside managed user/group spaces.
|
||||||
|
- The add-space dialog browses an allowed profile and links the current
|
||||||
|
remote folder/library root.
|
||||||
|
- The connector browser/sync flow is reused when a linked space is open.
|
||||||
|
- Remaining: edit/manage actions for existing linked spaces.
|
||||||
|
|
||||||
|
4. Add sync orchestration.
|
||||||
|
- Manual sync selected file is already available.
|
||||||
|
- Add folder-level manual sync.
|
||||||
|
- Add optional scheduled/background sync with conflict reporting.
|
||||||
|
|
||||||
|
5. Add provider-specific expansion.
|
||||||
|
- OAuth/secret-store credentials.
|
||||||
|
- Remote write and delete only where policy and provider support it.
|
||||||
|
- DMS/eAkte metadata integrations in a separate connector or documents
|
||||||
|
module.
|
||||||
54
docs/DOCUMENT_COLLABORATION_BOUNDARY.md
Normal file
54
docs/DOCUMENT_COLLABORATION_BOUNDARY.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Document Collaboration Boundary
|
||||||
|
|
||||||
|
GovOPlaN Files is the governed managed-file module. It stores uploaded/imported
|
||||||
|
blobs, versions, shares, provenance, ZIP imports/exports, connector imports, and
|
||||||
|
campaign attachment evidence. It should stay reliable, auditable, and simple.
|
||||||
|
|
||||||
|
Collaborative document behavior should be owned by a future documents module or
|
||||||
|
by provider-specific connector modules when the behavior is delegated to systems
|
||||||
|
such as Nextcloud, Seafile, Collabora, OnlyOffice, OpenDesk, or DMS/eAkte
|
||||||
|
platforms.
|
||||||
|
|
||||||
|
## Files Owns
|
||||||
|
|
||||||
|
- managed blobs and immutable version evidence
|
||||||
|
- owner-scoped user/group file spaces
|
||||||
|
- shares and access checks for managed files
|
||||||
|
- connector browse/import into managed storage
|
||||||
|
- source provenance, source revision, and audit events
|
||||||
|
- freeze-before-send campaign attachment behavior
|
||||||
|
- previews generated from managed file versions
|
||||||
|
|
||||||
|
## Documents Or Connectors Should Own
|
||||||
|
|
||||||
|
- co-editing sessions and editor launch URLs
|
||||||
|
- comments, suggestions, document tasks, and review state
|
||||||
|
- check-in/check-out, remote locks, and lock timeouts
|
||||||
|
- semantic document versions beyond blob versions
|
||||||
|
- template merge flows and generated-document lifecycle
|
||||||
|
- external DMS metadata, filing plans, record categories, and retention classes
|
||||||
|
- provider-specific sync state and conflict resolution
|
||||||
|
- collaborative presence and notification behavior
|
||||||
|
|
||||||
|
## Integration Shape
|
||||||
|
|
||||||
|
The documents layer should integrate with files through stable contracts:
|
||||||
|
|
||||||
|
- create or import a managed file version for evidence points
|
||||||
|
- attach source provenance when a file came from an external editor or DMS
|
||||||
|
- ask files for read/download access rather than importing files internals
|
||||||
|
- emit workflow/audit events when a collaborative document reaches a governed
|
||||||
|
state such as reviewed, approved, frozen, sent, or archived
|
||||||
|
- use connector providers for provider-specific browse/import/write behavior
|
||||||
|
|
||||||
|
Files may expose links to a document or connector module, but it should not own
|
||||||
|
the collaboration state machine. A collaborative document can produce many
|
||||||
|
working states; GovOPlaN Files should persist the governed snapshots that other
|
||||||
|
modules can safely use as evidence.
|
||||||
|
|
||||||
|
## Practical Rule
|
||||||
|
|
||||||
|
If a feature is about storing, sharing, importing, downloading, or freezing a
|
||||||
|
file, it belongs in `govoplan-files`. If a feature is about people jointly
|
||||||
|
editing, reviewing, locking, commenting on, or routing a document through a
|
||||||
|
semantic process, it belongs in a documents/workflow/DMS integration module.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.1",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.1",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
|||||||
@@ -4,14 +4,21 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-files"
|
name = "govoplan-files"
|
||||||
version = "0.1.3"
|
version = "0.1.8"
|
||||||
description = "GovOPlaN files module with backend and WebUI integration."
|
description = "GovOPlaN files module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.3",
|
"govoplan-core>=0.1.8",
|
||||||
|
"defusedxml>=0.7,<1",
|
||||||
|
"python-multipart>=0.0.31,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
smb = [
|
||||||
|
"smbprotocol>=1.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
|
||||||
|
from govoplan_core.core.files import FileAccessProvider
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||||
from govoplan_files.backend.runtime import configure_runtime
|
from govoplan_files.backend.runtime import configure_runtime
|
||||||
from govoplan_files.backend.storage.campaign_attachments import (
|
from govoplan_files.backend.storage.campaign_attachments import (
|
||||||
annotate_built_messages_with_managed_files,
|
annotate_built_messages_with_managed_files,
|
||||||
@@ -12,6 +20,21 @@ from govoplan_files.backend.storage.campaign_attachments import (
|
|||||||
)
|
)
|
||||||
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
|
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
|
||||||
from govoplan_files.backend.storage.files import current_version_and_blob
|
from govoplan_files.backend.storage.files import current_version_and_blob
|
||||||
|
from govoplan_files.backend.storage.paths import normalize_folder
|
||||||
|
|
||||||
|
|
||||||
|
VIRTUAL_FOLDER_RESOURCE_PREFIX = "virtual-folder:v1"
|
||||||
|
|
||||||
|
WRITE_ACTIONS = {
|
||||||
|
"files:file:upload",
|
||||||
|
"files:file:organize",
|
||||||
|
"files:file:share",
|
||||||
|
"files:file:delete",
|
||||||
|
"files:upload",
|
||||||
|
"files:organize",
|
||||||
|
"files:share",
|
||||||
|
"files:delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FilesCampaignCapability:
|
class FilesCampaignCapability:
|
||||||
@@ -30,5 +53,249 @@ class FilesCampaignCapability:
|
|||||||
|
|
||||||
|
|
||||||
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
|
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
|
||||||
configure_runtime(settings=context.settings)
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
return FilesCampaignCapability()
|
return FilesCampaignCapability()
|
||||||
|
|
||||||
|
|
||||||
|
class FilesAccessService(FileAccessProvider):
|
||||||
|
def explain_resource_provenance(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
normalized_type = resource_type.lower().strip()
|
||||||
|
if normalized_type in {"file", "file_asset", "files:file"}:
|
||||||
|
return self._explain_file(session, principal, resource_id=resource_id, action=action)
|
||||||
|
if normalized_type in {"folder", "file_folder", "files:folder"}:
|
||||||
|
return self._explain_folder(session, principal, resource_id=resource_id, action=action)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def _explain_file(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
asset = session.get(FileAsset, resource_id) # type: ignore[attr-defined]
|
||||||
|
if asset is None or (principal.tenant_id and asset.tenant_id != principal.tenant_id):
|
||||||
|
return (_missing_resource("file", resource_id, principal.tenant_id, source="files.not_found"),)
|
||||||
|
items = [_file_resource(asset)]
|
||||||
|
items.extend(_owner_provenance(asset.owner_type, _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id), principal, source="files.owner"))
|
||||||
|
items.extend(_admin_provenance(principal, "files:file:admin", source="files.admin_scope"))
|
||||||
|
permission_values = {"write", "manage"} if _requires_write_share(action) else {"read", "write", "manage"}
|
||||||
|
shares = (
|
||||||
|
session.query(FileShare) # type: ignore[attr-defined]
|
||||||
|
.filter(
|
||||||
|
FileShare.tenant_id == asset.tenant_id,
|
||||||
|
FileShare.file_asset_id == asset.id,
|
||||||
|
FileShare.revoked_at.is_(None),
|
||||||
|
FileShare.permission.in_(sorted(permission_values)),
|
||||||
|
or_(
|
||||||
|
(FileShare.target_type == "user") & (FileShare.target_id == principal.membership_id),
|
||||||
|
(FileShare.target_type == "group") & (FileShare.target_id.in_(sorted(principal.group_ids))),
|
||||||
|
(FileShare.target_type == "tenant") & (FileShare.target_id == asset.tenant_id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(FileShare.target_type.asc(), FileShare.target_id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for share in shares:
|
||||||
|
items.append(_share_provenance(share, source="files.share"))
|
||||||
|
return tuple(items)
|
||||||
|
|
||||||
|
def _explain_folder(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
resource_id: str,
|
||||||
|
action: str,
|
||||||
|
) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
del action
|
||||||
|
folder = session.get(FileFolder, resource_id) # type: ignore[attr-defined]
|
||||||
|
if folder is None:
|
||||||
|
return self._explain_virtual_folder(session, principal, resource_id=resource_id)
|
||||||
|
if principal.tenant_id and folder.tenant_id != principal.tenant_id:
|
||||||
|
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
|
||||||
|
return tuple([
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=folder.id,
|
||||||
|
label=folder.path,
|
||||||
|
tenant_id=folder.tenant_id,
|
||||||
|
source="files.folder",
|
||||||
|
details={
|
||||||
|
"resource_type": "folder",
|
||||||
|
"path": folder.path,
|
||||||
|
"owner_type": folder.owner_type,
|
||||||
|
"deleted": folder.deleted_at is not None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*_owner_provenance(folder.owner_type, _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id), principal, source="files.owner"),
|
||||||
|
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
|
||||||
|
])
|
||||||
|
|
||||||
|
def _explain_virtual_folder(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
resource_id: str,
|
||||||
|
) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
folder_ref = parse_virtual_folder_resource_id(resource_id)
|
||||||
|
if folder_ref is None:
|
||||||
|
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
|
||||||
|
tenant_id, owner_type, owner_id, path = folder_ref
|
||||||
|
if principal.tenant_id and tenant_id != principal.tenant_id:
|
||||||
|
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
|
||||||
|
child_prefix = f"{path}/"
|
||||||
|
owner_filter = FileAsset.owner_user_id == owner_id if owner_type == "user" else FileAsset.owner_group_id == owner_id
|
||||||
|
child = (
|
||||||
|
session.query(FileAsset.id) # type: ignore[attr-defined]
|
||||||
|
.filter(
|
||||||
|
FileAsset.tenant_id == tenant_id,
|
||||||
|
FileAsset.owner_type == owner_type,
|
||||||
|
owner_filter,
|
||||||
|
FileAsset.deleted_at.is_(None),
|
||||||
|
FileAsset.display_path.like(f"{child_prefix}%"),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if child is None:
|
||||||
|
return (_missing_resource("folder", resource_id, principal.tenant_id, source="files.not_found"),)
|
||||||
|
return tuple([
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=resource_id,
|
||||||
|
label=path,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source="files.virtual_folder",
|
||||||
|
details={
|
||||||
|
"resource_type": "folder",
|
||||||
|
"path": path,
|
||||||
|
"owner_type": owner_type,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"virtual": True,
|
||||||
|
"deleted": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*_owner_provenance(owner_type, owner_id, principal, source="files.owner"),
|
||||||
|
*_admin_provenance(principal, "files:file:admin", source="files.admin_scope"),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def access_capability(context: ModuleContext) -> FilesAccessService:
|
||||||
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
|
return FilesAccessService()
|
||||||
|
|
||||||
|
|
||||||
|
def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
|
||||||
|
normalized_path = normalize_folder(path)
|
||||||
|
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
|
||||||
|
return f"{VIRTUAL_FOLDER_RESOURCE_PREFIX}:{tenant_id}:{owner_type}:{owner_id}:{encoded_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_virtual_folder_resource_id(resource_id: str) -> tuple[str, str, str, str] | None:
|
||||||
|
parts = resource_id.split(":", 5)
|
||||||
|
if len(parts) != 6 or f"{parts[0]}:{parts[1]}" != VIRTUAL_FOLDER_RESOURCE_PREFIX:
|
||||||
|
return None
|
||||||
|
_, _, tenant_id, owner_type, owner_id, encoded_path = parts
|
||||||
|
if owner_type not in {"user", "group"} or not tenant_id or not owner_id or not encoded_path:
|
||||||
|
return None
|
||||||
|
padding = "=" * (-len(encoded_path) % 4)
|
||||||
|
try:
|
||||||
|
decoded_path = base64.urlsafe_b64decode(f"{encoded_path}{padding}").decode("utf-8")
|
||||||
|
path = normalize_folder(decoded_path)
|
||||||
|
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
return tenant_id, owner_type, owner_id, path
|
||||||
|
|
||||||
|
|
||||||
|
def _file_resource(asset: FileAsset) -> AccessDecisionProvenance:
|
||||||
|
return AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=asset.id,
|
||||||
|
label=asset.filename,
|
||||||
|
tenant_id=asset.tenant_id,
|
||||||
|
source="files.file",
|
||||||
|
details={
|
||||||
|
"resource_type": "file",
|
||||||
|
"display_path": asset.display_path,
|
||||||
|
"owner_type": asset.owner_type,
|
||||||
|
"deleted": asset.deleted_at is not None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_resource(resource_type: str, resource_id: str, tenant_id: str | None, *, source: str) -> AccessDecisionProvenance:
|
||||||
|
return AccessDecisionProvenance(
|
||||||
|
kind="resource",
|
||||||
|
id=resource_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source=source,
|
||||||
|
details={"resource_type": resource_type, "found": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
|
||||||
|
return owner_user_id if owner_type == "user" else owner_group_id
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_provenance(owner_type: str, owner_id: str | None, principal: PrincipalRef, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
if owner_id is None:
|
||||||
|
return ()
|
||||||
|
matches_user = owner_type == "user" and owner_id == principal.membership_id
|
||||||
|
matches_group = owner_type == "group" and owner_id in principal.group_ids
|
||||||
|
if not (matches_user or matches_group):
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="owner",
|
||||||
|
id=owner_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source=source,
|
||||||
|
details={"owner_type": owner_type},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_provenance(principal: PrincipalRef, required_scope: str, *, source: str) -> tuple[AccessDecisionProvenance, ...]:
|
||||||
|
if not scopes_grant_compatible(principal.scopes, required_scope):
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
AccessDecisionProvenance(
|
||||||
|
kind="policy",
|
||||||
|
id=required_scope,
|
||||||
|
label=required_scope,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source=source,
|
||||||
|
details={"grant": "tenant_admin"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _share_provenance(share: FileShare, *, source: str) -> AccessDecisionProvenance:
|
||||||
|
return AccessDecisionProvenance(
|
||||||
|
kind="share",
|
||||||
|
id=share.id,
|
||||||
|
label=share.permission,
|
||||||
|
tenant_id=share.tenant_id,
|
||||||
|
source=source,
|
||||||
|
details={
|
||||||
|
"target_type": share.target_type,
|
||||||
|
"target_id": share.target_id,
|
||||||
|
"permission": share.permission,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_write_share(action: str) -> bool:
|
||||||
|
return action in WRITE_ACTIONS
|
||||||
|
|||||||
199
src/govoplan_files/backend/change_tracking.py
Normal file
199
src/govoplan_files/backend/change_tracking.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import event, inspect
|
||||||
|
from sqlalchemy.orm import Session as OrmSession
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import record_change
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare, new_uuid
|
||||||
|
|
||||||
|
FILES_MODULE_ID = "files"
|
||||||
|
FILES_ASSETS_COLLECTION = "files.assets"
|
||||||
|
FILES_FOLDERS_COLLECTION = "files.folders"
|
||||||
|
FILES_CONNECTOR_PROFILES_COLLECTION = "files.connector_profiles"
|
||||||
|
FILES_CONNECTOR_CREDENTIALS_COLLECTION = "files.connector_credentials"
|
||||||
|
FILES_CONNECTOR_POLICIES_COLLECTION = "files.connector_policies"
|
||||||
|
FILES_CONNECTOR_SPACES_COLLECTION = "files.connector_spaces"
|
||||||
|
|
||||||
|
_REGISTERED = False
|
||||||
|
|
||||||
|
|
||||||
|
def register_files_change_tracking() -> None:
|
||||||
|
global _REGISTERED
|
||||||
|
if _REGISTERED:
|
||||||
|
return
|
||||||
|
event.listen(OrmSession, "before_flush", _record_files_changes)
|
||||||
|
_REGISTERED = True
|
||||||
|
|
||||||
|
|
||||||
|
def _record_files_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
|
||||||
|
for obj in tuple(session.new) + tuple(session.dirty):
|
||||||
|
if isinstance(obj, FileAsset):
|
||||||
|
_record_asset_change(session, obj)
|
||||||
|
elif isinstance(obj, FileFolder):
|
||||||
|
_record_folder_change(session, obj)
|
||||||
|
elif isinstance(obj, FileShare):
|
||||||
|
_record_share_visibility_change(session, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_asset_change(session: OrmSession, asset: FileAsset) -> None:
|
||||||
|
operation = _operation_for_soft_deletable(
|
||||||
|
asset,
|
||||||
|
changed_attrs=(
|
||||||
|
"owner_type",
|
||||||
|
"owner_user_id",
|
||||||
|
"owner_group_id",
|
||||||
|
"current_version_id",
|
||||||
|
"display_path",
|
||||||
|
"filename",
|
||||||
|
"description",
|
||||||
|
"deleted_at",
|
||||||
|
"metadata_",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if operation is None:
|
||||||
|
return
|
||||||
|
resource_id = _ensure_id(asset)
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collection=FILES_ASSETS_COLLECTION,
|
||||||
|
resource_type="file",
|
||||||
|
resource_id=resource_id,
|
||||||
|
operation=operation,
|
||||||
|
tenant_id=asset.tenant_id,
|
||||||
|
actor_type="user" if asset.created_by_user_id else None,
|
||||||
|
actor_id=asset.created_by_user_id,
|
||||||
|
payload={
|
||||||
|
"owner_type": asset.owner_type,
|
||||||
|
"owner_id": _owner_id(asset.owner_type, asset.owner_user_id, asset.owner_group_id),
|
||||||
|
"path": asset.display_path,
|
||||||
|
"previous_path": _previous_value(asset, "display_path"),
|
||||||
|
"filename": asset.filename,
|
||||||
|
"deleted_at": _isoformat(asset.deleted_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_folder_change(session: OrmSession, folder: FileFolder) -> None:
|
||||||
|
operation = _operation_for_soft_deletable(
|
||||||
|
folder,
|
||||||
|
changed_attrs=("owner_type", "owner_user_id", "owner_group_id", "path", "deleted_at", "metadata_"),
|
||||||
|
)
|
||||||
|
if operation is None:
|
||||||
|
return
|
||||||
|
resource_id = _ensure_id(folder)
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collection=FILES_FOLDERS_COLLECTION,
|
||||||
|
resource_type="folder",
|
||||||
|
resource_id=resource_id,
|
||||||
|
operation=operation,
|
||||||
|
tenant_id=folder.tenant_id,
|
||||||
|
actor_type="user" if folder.created_by_user_id else None,
|
||||||
|
actor_id=folder.created_by_user_id,
|
||||||
|
payload={
|
||||||
|
"owner_type": folder.owner_type,
|
||||||
|
"owner_id": _owner_id(folder.owner_type, folder.owner_user_id, folder.owner_group_id),
|
||||||
|
"path": folder.path,
|
||||||
|
"previous_path": _previous_value(folder, "path"),
|
||||||
|
"deleted_at": _isoformat(folder.deleted_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_share_visibility_change(session: OrmSession, share: FileShare) -> None:
|
||||||
|
state = inspect(share)
|
||||||
|
if not share.file_asset_id:
|
||||||
|
return
|
||||||
|
if not state.pending and not _has_attr_changes(
|
||||||
|
state,
|
||||||
|
("file_asset_id", "target_type", "target_id", "permission", "revoked_at"),
|
||||||
|
):
|
||||||
|
return
|
||||||
|
_ensure_id(share)
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
module_id=FILES_MODULE_ID,
|
||||||
|
collection=FILES_ASSETS_COLLECTION,
|
||||||
|
resource_type="file",
|
||||||
|
resource_id=share.file_asset_id,
|
||||||
|
operation="updated",
|
||||||
|
tenant_id=share.tenant_id,
|
||||||
|
actor_type="user" if share.created_by_user_id else None,
|
||||||
|
actor_id=share.created_by_user_id,
|
||||||
|
payload={
|
||||||
|
"share_id": share.id,
|
||||||
|
"share_target_type": share.target_type,
|
||||||
|
"share_target_id": share.target_id,
|
||||||
|
"share_permission": share.permission,
|
||||||
|
"share_revoked_at": _isoformat(share.revoked_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _operation_for_soft_deletable(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||||
|
state = inspect(obj)
|
||||||
|
if state.pending:
|
||||||
|
return "created"
|
||||||
|
if not _has_attr_changes(state, changed_attrs):
|
||||||
|
return None
|
||||||
|
if "deleted_at" in state.attrs:
|
||||||
|
history = state.attrs.deleted_at.history
|
||||||
|
if history.has_changes():
|
||||||
|
if any(value is not None for value in history.added):
|
||||||
|
return "deleted"
|
||||||
|
if any(value is not None for value in history.deleted):
|
||||||
|
return "created"
|
||||||
|
return "updated"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
||||||
|
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def _previous_value(obj: object, attr_name: str) -> str | None:
|
||||||
|
state = inspect(obj)
|
||||||
|
if attr_name not in state.attrs:
|
||||||
|
return None
|
||||||
|
history = state.attrs[attr_name].history
|
||||||
|
if not history.has_changes() or not history.deleted:
|
||||||
|
return None
|
||||||
|
value = history.deleted[0]
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_id(obj: object) -> str:
|
||||||
|
resource_id = getattr(obj, "id", None)
|
||||||
|
if resource_id:
|
||||||
|
return str(resource_id)
|
||||||
|
resource_id = new_uuid()
|
||||||
|
setattr(obj, "id", resource_id)
|
||||||
|
return resource_id
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_id(owner_type: str, owner_user_id: str | None, owner_group_id: str | None) -> str | None:
|
||||||
|
if owner_type == "user":
|
||||||
|
return owner_user_id
|
||||||
|
if owner_type == "group":
|
||||||
|
return owner_group_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FILES_ASSETS_COLLECTION",
|
||||||
|
"FILES_CONNECTOR_CREDENTIALS_COLLECTION",
|
||||||
|
"FILES_CONNECTOR_POLICIES_COLLECTION",
|
||||||
|
"FILES_CONNECTOR_PROFILES_COLLECTION",
|
||||||
|
"FILES_CONNECTOR_SPACES_COLLECTION",
|
||||||
|
"FILES_FOLDERS_COLLECTION",
|
||||||
|
"FILES_MODULE_ID",
|
||||||
|
"register_files_change_tracking",
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from govoplan_core.db.base import Base, TimestampMixin
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
@@ -19,7 +19,7 @@ class FileBlob(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),)
|
__table_args__ = (UniqueConstraint("tenant_id", "checksum_sha256", "size_bytes", name="uq_file_blobs_tenant_checksum_size"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
storage_backend: Mapped[str] = mapped_column(String(50), nullable=False)
|
storage_backend: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
storage_bucket: Mapped[str | None] = mapped_column(String(255))
|
storage_bucket: Mapped[str | None] = mapped_column(String(255))
|
||||||
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
|
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||||
@@ -50,12 +50,12 @@ class FileFolder(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
|
path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
@@ -64,15 +64,15 @@ class FileAsset(Base, TimestampMixin):
|
|||||||
__tablename__ = "file_assets"
|
__tablename__ = "file_assets"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
|
display_path: Mapped[str] = mapped_column(String(1000), nullable=False, index=True)
|
||||||
filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
filename: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ class FileVersion(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),)
|
__table_args__ = (UniqueConstraint("file_asset_id", "version_number", name="uq_file_versions_asset_number"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
|
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True)
|
blob_id: Mapped[str] = mapped_column(ForeignKey("file_blobs.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
@@ -91,7 +91,7 @@ class FileVersion(Base, TimestampMixin):
|
|||||||
content_type: Mapped[str | None] = mapped_column(String(255))
|
content_type: Mapped[str | None] = mapped_column(String(255))
|
||||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
class FileShare(Base, TimestampMixin):
|
class FileShare(Base, TimestampMixin):
|
||||||
@@ -99,21 +99,131 @@ class FileShare(Base, TimestampMixin):
|
|||||||
__table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),)
|
__table_args__ = (UniqueConstraint("file_asset_id", "target_type", "target_id", "revoked_at", name="uq_file_shares_active_target"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
|
file_asset_id: Mapped[str] = mapped_column(ForeignKey("file_assets.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
|
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfile(Base, TimestampMixin):
|
||||||
|
__tablename__ = "file_connector_profiles"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_file_connector_profiles_scope", "scope_type", "scope_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
endpoint_url: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
credential_profile_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
credential_mode: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||||
|
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
password_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
token_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
secret_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
capabilities: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
policy: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorCredential(Base, TimestampMixin):
|
||||||
|
__tablename__ = "file_connector_credentials"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_file_connector_credentials_scope", "scope_type", "scope_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
credential_mode: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
|
||||||
|
username: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||||
|
password_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
password_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
token_env: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
secret_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
|
policy: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicy(Base, TimestampMixin):
|
||||||
|
__tablename__ = "file_connector_policies"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_file_connector_policies_scope"),
|
||||||
|
Index("ix_file_connector_policies_scope", "scope_type", "scope_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSpace(Base, TimestampMixin):
|
||||||
|
__tablename__ = "file_connector_spaces"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_file_connector_spaces_owner", "tenant_id", "owner_type", "owner_user_id", "owner_group_id"),
|
||||||
|
Index(
|
||||||
|
"uq_file_connector_spaces_active_user_label",
|
||||||
|
"tenant_id", "owner_user_id", "label",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("owner_type = 'user' AND deleted_at IS NULL"),
|
||||||
|
postgresql_where=text("owner_type = 'user' AND deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_file_connector_spaces_active_group_label",
|
||||||
|
"tenant_id", "owner_group_id", "label",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text("owner_type = 'group' AND deleted_at IS NULL"),
|
||||||
|
postgresql_where=text("owner_type = 'group' AND deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
owner_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
connector_profile_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
library_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
remote_path: Mapped[str] = mapped_column(String(1000), default="", nullable=False)
|
||||||
|
sync_mode: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
|
||||||
|
read_only: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class CampaignAttachmentUse(Base, TimestampMixin):
|
class CampaignAttachmentUse(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_attachment_uses"
|
__tablename__ = "campaign_attachment_uses"
|
||||||
__table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),)
|
__table_args__ = (UniqueConstraint("campaign_job_id", "file_version_id", "filename_used", "use_stage", name="uq_campaign_attachment_uses_job_file_stage"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
|
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
|
campaign_job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
@@ -134,6 +244,10 @@ __all__ = [
|
|||||||
"CampaignAttachmentUse",
|
"CampaignAttachmentUse",
|
||||||
"FileAsset",
|
"FileAsset",
|
||||||
"FileBlob",
|
"FileBlob",
|
||||||
|
"FileConnectorCredential",
|
||||||
|
"FileConnectorPolicy",
|
||||||
|
"FileConnectorProfile",
|
||||||
|
"FileConnectorSpace",
|
||||||
"FileFolder",
|
"FileFolder",
|
||||||
"FileShare",
|
"FileShare",
|
||||||
"FileVersion",
|
"FileVersion",
|
||||||
|
|||||||
@@ -2,10 +2,26 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
|
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
|
||||||
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||||
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
||||||
|
|
||||||
|
register_files_change_tracking()
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
@@ -54,10 +70,40 @@ ROLE_TEMPLATES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileConnectorCredential, FileConnectorPolicy, FileConnectorProfile, FileConnectorSpace
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count(),
|
||||||
|
"connector_credentials": session.query(FileConnectorCredential).filter(FileConnectorCredential.tenant_id == tenant_id).count(),
|
||||||
|
"connector_policies": session.query(FileConnectorPolicy).filter(FileConnectorPolicy.tenant_id == tenant_id).count(),
|
||||||
|
"connector_profiles": session.query(FileConnectorProfile).filter(FileConnectorProfile.tenant_id == tenant_id).count(),
|
||||||
|
"connector_spaces": session.query(FileConnectorSpace).filter(FileConnectorSpace.tenant_id == tenant_id).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileConnectorSpace, FileFolder, FileShare
|
||||||
|
|
||||||
|
owned_asset_count = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_group_id == group_id).count()
|
||||||
|
owned_folder_count = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_group_id == group_id).count()
|
||||||
|
owned_connector_space_count = session.query(FileConnectorSpace).filter(
|
||||||
|
FileConnectorSpace.tenant_id == tenant_id,
|
||||||
|
FileConnectorSpace.owner_group_id == group_id,
|
||||||
|
).count()
|
||||||
|
shared_asset_count = session.query(FileShare).filter(
|
||||||
|
FileShare.tenant_id == tenant_id,
|
||||||
|
FileShare.target_type == "group",
|
||||||
|
FileShare.target_id == group_id,
|
||||||
|
).count()
|
||||||
|
if owned_asset_count or owned_folder_count or owned_connector_space_count or shared_asset_count:
|
||||||
|
raise ValueError("Cannot remove the group while it owns files, folders, connector spaces or file shares.")
|
||||||
|
|
||||||
|
|
||||||
def _files_router(context: ModuleContext):
|
def _files_router(context: ModuleContext):
|
||||||
from govoplan_files.backend.runtime import configure_runtime
|
from govoplan_files.backend.runtime import configure_runtime
|
||||||
|
|
||||||
configure_runtime(settings=context.settings)
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
from govoplan_files.backend.router import router
|
from govoplan_files.backend.router import router
|
||||||
|
|
||||||
return router
|
return router
|
||||||
@@ -66,12 +112,26 @@ def _files_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="files",
|
id="files",
|
||||||
name="Files",
|
name="Files",
|
||||||
version="0.1.2",
|
version="0.1.8",
|
||||||
dependencies=("access",),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=(),
|
optional_dependencies=("campaigns",),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="files.access", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="campaigns.access",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_files_router,
|
route_factory=_files_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
delete_veto_providers={"group": (_veto_group_delete,)},
|
||||||
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="files",
|
module_id="files",
|
||||||
@@ -82,8 +142,39 @@ manifest = ModuleManifest(
|
|||||||
module_id="files",
|
module_id="files",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
file_models.FileBlob,
|
||||||
|
file_models.FileFolder,
|
||||||
|
file_models.FileAsset,
|
||||||
|
file_models.FileVersion,
|
||||||
|
file_models.FileShare,
|
||||||
|
file_models.FileConnectorCredential,
|
||||||
|
file_models.FileConnectorPolicy,
|
||||||
|
file_models.FileConnectorProfile,
|
||||||
|
file_models.FileConnectorSpace,
|
||||||
|
file_models.CampaignAttachmentUse,
|
||||||
|
label="Files",
|
||||||
|
),
|
||||||
|
retirement_notes="Destructive retirement drops files-owned database tables after the installer captures a database snapshot.",
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
file_models.FileBlob,
|
||||||
|
file_models.FileFolder,
|
||||||
|
file_models.FileAsset,
|
||||||
|
file_models.FileVersion,
|
||||||
|
file_models.FileShare,
|
||||||
|
file_models.FileConnectorCredential,
|
||||||
|
file_models.FileConnectorPolicy,
|
||||||
|
file_models.FileConnectorProfile,
|
||||||
|
file_models.FileConnectorSpace,
|
||||||
|
file_models.CampaignAttachmentUse,
|
||||||
|
label="Files",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
|
CAPABILITY_FILES_ACCESS: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["access_capability"]).access_capability(context),
|
||||||
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""file connector spaces
|
||||||
|
|
||||||
|
Revision ID: 4f5a6b7c8d9e
|
||||||
|
Revises: 2e3f4a5b6c7d
|
||||||
|
Create Date: 2026-07-08 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "4f5a6b7c8d9e"
|
||||||
|
down_revision = "2e3f4a5b6c7d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_fk_target(tables: set[str]) -> str:
|
||||||
|
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
scope_fk_target = _scope_fk_target(tables)
|
||||||
|
if "file_connector_spaces" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"file_connector_spaces",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("owner_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("owner_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("owner_group_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("connector_profile_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("library_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("remote_path", sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column("sync_mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("read_only", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_spaces_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["owner_group_id"], ["access_groups.id"], name=op.f("fk_file_connector_spaces_owner_group_id_groups"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_file_connector_spaces_owner_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_spaces_tenant_id_scopes"), ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_spaces")),
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_spaces")}
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"owner_type",
|
||||||
|
"owner_user_id",
|
||||||
|
"owner_group_id",
|
||||||
|
"connector_profile_id",
|
||||||
|
"provider",
|
||||||
|
"is_active",
|
||||||
|
"created_by_user_id",
|
||||||
|
"deleted_at",
|
||||||
|
):
|
||||||
|
name = op.f(f"ix_file_connector_spaces_{column}")
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "file_connector_spaces", [column], unique=False)
|
||||||
|
if "ix_file_connector_spaces_owner" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_file_connector_spaces_owner",
|
||||||
|
"file_connector_spaces",
|
||||||
|
["tenant_id", "owner_type", "owner_user_id", "owner_group_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
if "uq_file_connector_spaces_active_user_label" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"uq_file_connector_spaces_active_user_label",
|
||||||
|
"file_connector_spaces",
|
||||||
|
["tenant_id", "owner_user_id", "label"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
|
||||||
|
postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
if "uq_file_connector_spaces_active_group_label" not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
"uq_file_connector_spaces_active_group_label",
|
||||||
|
"file_connector_spaces",
|
||||||
|
["tenant_id", "owner_group_id", "label"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
|
||||||
|
postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "file_connector_spaces" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_spaces")}
|
||||||
|
for name in (
|
||||||
|
"uq_file_connector_spaces_active_group_label",
|
||||||
|
"uq_file_connector_spaces_active_user_label",
|
||||||
|
"ix_file_connector_spaces_owner",
|
||||||
|
op.f("ix_file_connector_spaces_deleted_at"),
|
||||||
|
op.f("ix_file_connector_spaces_created_by_user_id"),
|
||||||
|
op.f("ix_file_connector_spaces_is_active"),
|
||||||
|
op.f("ix_file_connector_spaces_provider"),
|
||||||
|
op.f("ix_file_connector_spaces_connector_profile_id"),
|
||||||
|
op.f("ix_file_connector_spaces_owner_group_id"),
|
||||||
|
op.f("ix_file_connector_spaces_owner_user_id"),
|
||||||
|
op.f("ix_file_connector_spaces_owner_type"),
|
||||||
|
op.f("ix_file_connector_spaces_tenant_id"),
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="file_connector_spaces")
|
||||||
|
op.drop_table("file_connector_spaces")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""file connector profiles
|
||||||
|
|
||||||
|
Revision ID: 5a6b7c8d9e0f
|
||||||
|
Revises: 4f5a6b7c8d9e
|
||||||
|
Create Date: 2026-07-08 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "5a6b7c8d9e0f"
|
||||||
|
down_revision = "4f5a6b7c8d9e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_fk_target(inspector) -> str:
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
scope_fk_target = _scope_fk_target(inspector)
|
||||||
|
if "file_connector_profiles" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"file_connector_profiles",
|
||||||
|
sa.Column("id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("endpoint_url", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("base_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("credential_mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=320), nullable=True),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("token_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("password_env", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("token_env", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("secret_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("capabilities", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("policy", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_profiles_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_profiles_tenant_id_scopes"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_profiles_updated_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_profiles")),
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"provider",
|
||||||
|
"enabled",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
):
|
||||||
|
name = op.f(f"ix_file_connector_profiles_{column}")
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "file_connector_profiles", [column], unique=False)
|
||||||
|
if "ix_file_connector_profiles_scope" not in indexes:
|
||||||
|
op.create_index("ix_file_connector_profiles_scope", "file_connector_profiles", ["scope_type", "scope_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "file_connector_profiles" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
|
||||||
|
for name in (
|
||||||
|
"ix_file_connector_profiles_scope",
|
||||||
|
op.f("ix_file_connector_profiles_updated_by_user_id"),
|
||||||
|
op.f("ix_file_connector_profiles_created_by_user_id"),
|
||||||
|
op.f("ix_file_connector_profiles_enabled"),
|
||||||
|
op.f("ix_file_connector_profiles_provider"),
|
||||||
|
op.f("ix_file_connector_profiles_scope_id"),
|
||||||
|
op.f("ix_file_connector_profiles_scope_type"),
|
||||||
|
op.f("ix_file_connector_profiles_tenant_id"),
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="file_connector_profiles")
|
||||||
|
op.drop_table("file_connector_profiles")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""file connector credentials
|
||||||
|
|
||||||
|
Revision ID: 6b7c8d9e0f1a
|
||||||
|
Revises: 5a6b7c8d9e0f
|
||||||
|
Create Date: 2026-07-08 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "6b7c8d9e0f1a"
|
||||||
|
down_revision = "5a6b7c8d9e0f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_fk_target(table_names: set[str]) -> str:
|
||||||
|
return "core_scopes.id" if "core_scopes" in table_names else "tenancy_tenants.id"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
table_names = set(inspector.get_table_names())
|
||||||
|
scope_fk_target = _scope_fk_target(table_names)
|
||||||
|
|
||||||
|
if "file_connector_profiles" in table_names:
|
||||||
|
columns = {item["name"] for item in inspector.get_columns("file_connector_profiles")}
|
||||||
|
if "credential_profile_id" not in columns:
|
||||||
|
op.add_column("file_connector_profiles", sa.Column("credential_profile_id", sa.String(length=255), nullable=True))
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
|
||||||
|
index_name = op.f("ix_file_connector_profiles_credential_profile_id")
|
||||||
|
if index_name not in indexes:
|
||||||
|
op.create_index(index_name, "file_connector_profiles", ["credential_profile_id"], unique=False)
|
||||||
|
|
||||||
|
if "file_connector_credentials" not in table_names:
|
||||||
|
op.create_table(
|
||||||
|
"file_connector_credentials",
|
||||||
|
sa.Column("id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("label", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("credential_mode", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=320), nullable=True),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("token_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("password_env", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("token_env", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("secret_ref", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("policy", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_credentials_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_credentials_tenant_id_scopes"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_credentials_updated_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_credentials")),
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_credentials")}
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"provider",
|
||||||
|
"enabled",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
):
|
||||||
|
name = op.f(f"ix_file_connector_credentials_{column}")
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "file_connector_credentials", [column], unique=False)
|
||||||
|
if "ix_file_connector_credentials_scope" not in indexes:
|
||||||
|
op.create_index("ix_file_connector_credentials_scope", "file_connector_credentials", ["scope_type", "scope_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "file_connector_credentials" in inspector.get_table_names():
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_credentials")}
|
||||||
|
for name in (
|
||||||
|
"ix_file_connector_credentials_scope",
|
||||||
|
op.f("ix_file_connector_credentials_updated_by_user_id"),
|
||||||
|
op.f("ix_file_connector_credentials_created_by_user_id"),
|
||||||
|
op.f("ix_file_connector_credentials_enabled"),
|
||||||
|
op.f("ix_file_connector_credentials_provider"),
|
||||||
|
op.f("ix_file_connector_credentials_scope_id"),
|
||||||
|
op.f("ix_file_connector_credentials_scope_type"),
|
||||||
|
op.f("ix_file_connector_credentials_tenant_id"),
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="file_connector_credentials")
|
||||||
|
op.drop_table("file_connector_credentials")
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "file_connector_profiles" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
profile_indexes = {item["name"] for item in inspector.get_indexes("file_connector_profiles")}
|
||||||
|
profile_index_name = op.f("ix_file_connector_profiles_credential_profile_id")
|
||||||
|
if profile_index_name in profile_indexes:
|
||||||
|
op.drop_index(profile_index_name, table_name="file_connector_profiles")
|
||||||
|
profile_columns = {item["name"] for item in inspector.get_columns("file_connector_profiles")}
|
||||||
|
if "credential_profile_id" in profile_columns:
|
||||||
|
op.drop_column("file_connector_profiles", "credential_profile_id")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""file connector policies
|
||||||
|
|
||||||
|
Revision ID: a7b8c9d0e1f3
|
||||||
|
Revises: 6b7c8d9e0f1a
|
||||||
|
Create Date: 2026-07-08 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a7b8c9d0e1f3"
|
||||||
|
down_revision = "6b7c8d9e0f1a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_fk_target(inspector) -> str:
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
scope_fk_target = _scope_fk_target(inspector)
|
||||||
|
if "file_connector_policies" not in inspector.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
"file_connector_policies",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("policy", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_policies_created_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_file_connector_policies_tenant_id_scopes"), ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_file_connector_policies_updated_by_user_id_users"), ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_file_connector_policies")),
|
||||||
|
sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_file_connector_policies_scope"),
|
||||||
|
)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_policies")}
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
):
|
||||||
|
name = op.f(f"ix_file_connector_policies_{column}")
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(name, "file_connector_policies", [column], unique=False)
|
||||||
|
if "ix_file_connector_policies_scope" not in indexes:
|
||||||
|
op.create_index("ix_file_connector_policies_scope", "file_connector_policies", ["scope_type", "scope_id"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "file_connector_policies" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
indexes = {item["name"] for item in inspector.get_indexes("file_connector_policies")}
|
||||||
|
for name in (
|
||||||
|
"ix_file_connector_policies_scope",
|
||||||
|
op.f("ix_file_connector_policies_updated_by_user_id"),
|
||||||
|
op.f("ix_file_connector_policies_created_by_user_id"),
|
||||||
|
op.f("ix_file_connector_policies_scope_id"),
|
||||||
|
op.f("ix_file_connector_policies_scope_type"),
|
||||||
|
op.f("ix_file_connector_policies_tenant_id"),
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="file_connector_policies")
|
||||||
|
op.drop_table("file_connector_policies")
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""v0.1.7 files baseline
|
||||||
|
|
||||||
|
Revision ID: a7b8c9d0e1f3
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-11 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'a7b8c9d0e1f3'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = '4f2a9c8e7b6d'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('file_connector_credentials',
|
||||||
|
sa.Column('id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('scope_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('scope_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('label', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('credential_mode', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('token_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('password_env', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('token_env', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('policy', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_credentials_tenant_id_scopes'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_credentials_updated_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_credentials'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_created_by_user_id'), 'file_connector_credentials', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_enabled'), 'file_connector_credentials', ['enabled'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_provider'), 'file_connector_credentials', ['provider'], unique=False)
|
||||||
|
op.create_index('ix_file_connector_credentials_scope', 'file_connector_credentials', ['scope_type', 'scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_scope_id'), 'file_connector_credentials', ['scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_scope_type'), 'file_connector_credentials', ['scope_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_tenant_id'), 'file_connector_credentials', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_credentials_updated_by_user_id'), 'file_connector_credentials', ['updated_by_user_id'], unique=False)
|
||||||
|
op.create_table('file_connector_policies',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('scope_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('scope_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('policy', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_policies_tenant_id_scopes'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_policies_updated_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_policies')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'scope_type', 'scope_id', name='uq_file_connector_policies_scope')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_connector_policies_created_by_user_id'), 'file_connector_policies', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index('ix_file_connector_policies_scope', 'file_connector_policies', ['scope_type', 'scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_policies_scope_id'), 'file_connector_policies', ['scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_policies_scope_type'), 'file_connector_policies', ['scope_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_policies_tenant_id'), 'file_connector_policies', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_policies_updated_by_user_id'), 'file_connector_policies', ['updated_by_user_id'], unique=False)
|
||||||
|
op.create_table('file_connector_profiles',
|
||||||
|
sa.Column('id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('scope_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('scope_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('label', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('endpoint_url', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('base_path', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('credential_profile_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('credential_mode', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=320), nullable=True),
|
||||||
|
sa.Column('password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('token_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('password_env', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('token_env', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('secret_ref', sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column('capabilities', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('policy', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_profiles_tenant_id_scopes'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_profiles_updated_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_profiles'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_created_by_user_id'), 'file_connector_profiles', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_credential_profile_id'), 'file_connector_profiles', ['credential_profile_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_enabled'), 'file_connector_profiles', ['enabled'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_provider'), 'file_connector_profiles', ['provider'], unique=False)
|
||||||
|
op.create_index('ix_file_connector_profiles_scope', 'file_connector_profiles', ['scope_type', 'scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_scope_id'), 'file_connector_profiles', ['scope_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_scope_type'), 'file_connector_profiles', ['scope_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_tenant_id'), 'file_connector_profiles', ['tenant_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_profiles_updated_by_user_id'), 'file_connector_profiles', ['updated_by_user_id'], unique=False)
|
||||||
|
op.create_table('file_connector_spaces',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('owner_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('owner_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('owner_group_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('label', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('connector_profile_id', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('library_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('remote_path', sa.String(length=1000), nullable=False),
|
||||||
|
sa.Column('sync_mode', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('read_only', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('metadata', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_file_connector_spaces_created_by_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_group_id'], ['access_groups.id'], name=op.f('fk_file_connector_spaces_owner_group_id_access_groups'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['owner_user_id'], ['access_users.id'], name=op.f('fk_file_connector_spaces_owner_user_id_access_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_file_connector_spaces_tenant_id_scopes'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_connector_spaces'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_connector_profile_id'), 'file_connector_spaces', ['connector_profile_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_created_by_user_id'), 'file_connector_spaces', ['created_by_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_deleted_at'), 'file_connector_spaces', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_is_active'), 'file_connector_spaces', ['is_active'], unique=False)
|
||||||
|
op.create_index('ix_file_connector_spaces_owner', 'file_connector_spaces', ['tenant_id', 'owner_type', 'owner_user_id', 'owner_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_owner_group_id'), 'file_connector_spaces', ['owner_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_owner_type'), 'file_connector_spaces', ['owner_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_owner_user_id'), 'file_connector_spaces', ['owner_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_provider'), 'file_connector_spaces', ['provider'], unique=False)
|
||||||
|
op.create_index(op.f('ix_file_connector_spaces_tenant_id'), 'file_connector_spaces', ['tenant_id'], unique=False)
|
||||||
|
op.create_index('uq_file_connector_spaces_active_group_label', 'file_connector_spaces', ['tenant_id', 'owner_group_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'group' AND deleted_at IS NULL"))
|
||||||
|
op.create_index('uq_file_connector_spaces_active_user_label', 'file_connector_spaces', ['tenant_id', 'owner_user_id', 'label'], unique=True, sqlite_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"), postgresql_where=sa.text("owner_type = 'user' AND deleted_at IS NULL"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('file_connector_spaces')
|
||||||
|
op.drop_table('file_connector_profiles')
|
||||||
|
op.drop_table('file_connector_policies')
|
||||||
|
op.drop_table('file_connector_credentials')
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_runtime_registry: object | None = None
|
||||||
_runtime_settings: object | None = None
|
_runtime_settings: object | None = None
|
||||||
|
|
||||||
|
|
||||||
def configure_runtime(*, settings: object | None = None) -> None:
|
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||||
global _runtime_settings
|
global _runtime_registry, _runtime_settings
|
||||||
|
if registry is not None:
|
||||||
|
_runtime_registry = registry
|
||||||
if settings is not None:
|
if settings is not None:
|
||||||
_runtime_settings = settings
|
_runtime_settings = settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> object | None:
|
||||||
|
return _runtime_registry
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> object:
|
def get_settings() -> object:
|
||||||
if _runtime_settings is not None:
|
if _runtime_settings is not None:
|
||||||
return _runtime_settings
|
return _runtime_settings
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
from govoplan_files.backend.storage.common import FileConflictResolution
|
from govoplan_files.backend.storage.common import FileConflictResolution
|
||||||
|
|
||||||
|
|
||||||
@@ -13,12 +14,63 @@ class FileSpaceResponse(BaseModel):
|
|||||||
owner_type: Literal["user", "group"]
|
owner_type: Literal["user", "group"]
|
||||||
owner_id: str
|
owner_id: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
space_type: Literal["managed", "connector"] = "managed"
|
||||||
|
connector_space_id: str | None = None
|
||||||
|
connector_profile_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
library_id: str | None = None
|
||||||
|
remote_path: str | None = None
|
||||||
|
sync_mode: str | None = None
|
||||||
|
read_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FileSpacesResponse(BaseModel):
|
class FileSpacesResponse(BaseModel):
|
||||||
spaces: list[FileSpaceResponse]
|
spaces: list[FileSpaceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSpaceCreateRequest(BaseModel):
|
||||||
|
owner_type: Literal["user", "group"] = "user"
|
||||||
|
owner_id: str | None = None
|
||||||
|
label: str
|
||||||
|
connector_profile_id: str
|
||||||
|
library_id: str | None = None
|
||||||
|
remote_path: str = ""
|
||||||
|
sync_mode: Literal["manual"] = "manual"
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSpaceUpdateRequest(BaseModel):
|
||||||
|
label: str | None = None
|
||||||
|
library_id: str | None = None
|
||||||
|
remote_path: str | None = None
|
||||||
|
sync_mode: Literal["manual"] | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSpaceResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
owner_type: Literal["user", "group"]
|
||||||
|
owner_id: str
|
||||||
|
label: str
|
||||||
|
connector_profile_id: str
|
||||||
|
provider: str
|
||||||
|
library_id: str | None = None
|
||||||
|
remote_path: str = ""
|
||||||
|
sync_mode: str
|
||||||
|
read_only: bool
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
deleted_at: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSpacesResponse(BaseModel):
|
||||||
|
spaces: list[FileConnectorSpaceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FileShareResponse(BaseModel):
|
class FileShareResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
target_type: str
|
target_type: str
|
||||||
@@ -28,6 +80,20 @@ class FileShareResponse(BaseModel):
|
|||||||
revoked_at: str | None = None
|
revoked_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileSourceProvenance(BaseModel):
|
||||||
|
source_type: str | None = None
|
||||||
|
connector_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
|
external_path: str | None = None
|
||||||
|
external_url: str | None = None
|
||||||
|
revision: str | None = None
|
||||||
|
revision_label: str | None = None
|
||||||
|
observed_at: str | None = None
|
||||||
|
imported_at: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class FileAssetResponse(BaseModel):
|
class FileAssetResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
@@ -45,6 +111,8 @@ class FileAssetResponse(BaseModel):
|
|||||||
deleted_at: str | None = None
|
deleted_at: str | None = None
|
||||||
audit_relevant: bool = False
|
audit_relevant: bool = False
|
||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
source_provenance: FileSourceProvenance | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
shares: list[FileShareResponse] = Field(default_factory=list)
|
shares: list[FileShareResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +129,9 @@ class FileFolderResponse(BaseModel):
|
|||||||
|
|
||||||
class FileFoldersResponse(BaseModel):
|
class FileFoldersResponse(BaseModel):
|
||||||
folders: list[FileFolderResponse]
|
folders: list[FileFolderResponse]
|
||||||
|
cursor: str | None = None
|
||||||
|
next_cursor: str | None = None
|
||||||
|
watermark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FileFolderCreateRequest(BaseModel):
|
class FileFolderCreateRequest(BaseModel):
|
||||||
@@ -83,12 +154,280 @@ class FileFolderDeleteResponse(BaseModel):
|
|||||||
|
|
||||||
class FileListResponse(BaseModel):
|
class FileListResponse(BaseModel):
|
||||||
files: list[FileAssetResponse]
|
files: list[FileAssetResponse]
|
||||||
|
cursor: str | None = None
|
||||||
|
next_cursor: str | None = None
|
||||||
|
watermark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileDeltaResponse(BaseModel):
|
||||||
|
files: list[FileAssetResponse] = Field(default_factory=list)
|
||||||
|
folders: list[FileFolderResponse] = Field(default_factory=list)
|
||||||
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
|
watermark: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class FileUploadResponse(BaseModel):
|
class FileUploadResponse(BaseModel):
|
||||||
files: list[FileAssetResponse]
|
files: list[FileAssetResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicySource(BaseModel):
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
|
||||||
|
scope_id: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicyEvaluateRequest(BaseModel):
|
||||||
|
source_provenance: FileSourceProvenance
|
||||||
|
operation: str = "access"
|
||||||
|
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicyEvaluateResponse(BaseModel):
|
||||||
|
decision: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicyUpdateRequest(BaseModel):
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicyStepResponse(BaseModel):
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
path: str
|
||||||
|
label: str
|
||||||
|
applied_fields: list[str] = Field(default_factory=list)
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorPolicyResponse(BaseModel):
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
|
scope_id: str | None = None
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
effective_policy: dict[str, Any] | None = None
|
||||||
|
parent_policy: dict[str, Any] | None = None
|
||||||
|
effective_policy_sources: list[FileConnectorPolicyStepResponse] = Field(default_factory=list)
|
||||||
|
parent_policy_sources: list[FileConnectorPolicyStepResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfileResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
provider: str
|
||||||
|
endpoint_url: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
|
||||||
|
scope_id: str | None = None
|
||||||
|
source_path: str
|
||||||
|
credential_profile_id: str | None = None
|
||||||
|
credential_profile_label: str | None = None
|
||||||
|
credential_mode: str = "none"
|
||||||
|
credential_source: str | None = None
|
||||||
|
credentials_configured: bool = False
|
||||||
|
username: str | None = None
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
source_kind: str = "settings"
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfilesResponse(BaseModel):
|
||||||
|
profiles: list[FileConnectorProfileResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorCredentialResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
provider: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "system"
|
||||||
|
scope_id: str | None = None
|
||||||
|
source_path: str
|
||||||
|
credential_mode: str = "none"
|
||||||
|
credential_secret_source: str | None = None
|
||||||
|
credentials_configured: bool = False
|
||||||
|
username: str | None = None
|
||||||
|
policy_sources: list[FileConnectorPolicySource] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
source_kind: str = "database"
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorCredentialsResponse(BaseModel):
|
||||||
|
credentials: list[FileConnectorCredentialResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSettingsDeltaResponse(BaseModel):
|
||||||
|
profiles: list[FileConnectorProfileResponse] = Field(default_factory=list)
|
||||||
|
credentials: list[FileConnectorCredentialResponse] = Field(default_factory=list)
|
||||||
|
spaces: list[FileConnectorSpaceResponse] = Field(default_factory=list)
|
||||||
|
policy: FileConnectorPolicyResponse | None = None
|
||||||
|
changed_sections: list[str] = Field(default_factory=list)
|
||||||
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
|
watermark: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfileCredentialsRequest(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
token: str | None = None
|
||||||
|
password_env: str | None = None
|
||||||
|
token_env: str | None = None
|
||||||
|
secret_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorDiscoveryCandidate(BaseModel):
|
||||||
|
endpoint_url: str
|
||||||
|
status: Literal["failed", "usable", "found", "credentials_rejected"]
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorDiscoveryRequest(BaseModel):
|
||||||
|
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"]
|
||||||
|
endpoint_url: str
|
||||||
|
base_path: str | None = None
|
||||||
|
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
|
||||||
|
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
require_valid_credentials: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorDiscoveryResponse(BaseModel):
|
||||||
|
provider: str
|
||||||
|
endpoint_url: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
|
status: Literal["usable", "found", "credentials_rejected", "not_found", "unsupported"]
|
||||||
|
message: str
|
||||||
|
candidates: list[FileConnectorDiscoveryCandidate] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfileCreateRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"]
|
||||||
|
endpoint_url: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "tenant"
|
||||||
|
scope_id: str | None = None
|
||||||
|
credential_profile_id: str | None = None
|
||||||
|
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
|
||||||
|
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
|
||||||
|
capabilities: list[str] = Field(default_factory=lambda: ["browse", "import", "sync"])
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProfileUpdateRequest(BaseModel):
|
||||||
|
label: str | None = None
|
||||||
|
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | None = None
|
||||||
|
endpoint_url: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
credential_profile_id: str | None = None
|
||||||
|
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] | None = None
|
||||||
|
credentials: FileConnectorProfileCredentialsRequest | None = None
|
||||||
|
clear_password: bool = False
|
||||||
|
clear_token: bool = False
|
||||||
|
capabilities: list[str] | None = None
|
||||||
|
policy: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorCredentialCreateRequest(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
scope_type: Literal["system", "tenant", "user", "group", "campaign"] = "tenant"
|
||||||
|
scope_id: str | None = None
|
||||||
|
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] = "none"
|
||||||
|
credentials: FileConnectorProfileCredentialsRequest = Field(default_factory=FileConnectorProfileCredentialsRequest)
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorCredentialUpdateRequest(BaseModel):
|
||||||
|
label: str | None = None
|
||||||
|
provider: Literal["seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"] | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
credential_mode: Literal["none", "anonymous", "basic", "token", "secret_ref"] | None = None
|
||||||
|
credentials: FileConnectorProfileCredentialsRequest | None = None
|
||||||
|
clear_password: bool = False
|
||||||
|
clear_token: bool = False
|
||||||
|
policy: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProviderResponse(BaseModel):
|
||||||
|
provider: str
|
||||||
|
label: str
|
||||||
|
protocol: str
|
||||||
|
implemented: bool
|
||||||
|
installed: bool
|
||||||
|
browse_supported: bool
|
||||||
|
import_supported: bool
|
||||||
|
optional_dependency: str | None = None
|
||||||
|
permission_model: str
|
||||||
|
sync_strategy: str
|
||||||
|
conflict_strategy: str
|
||||||
|
preview_strategy: str
|
||||||
|
audit_events: list[str] = Field(default_factory=list)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorProvidersResponse(BaseModel):
|
||||||
|
providers: list[FileConnectorProviderResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorBrowseItem(BaseModel):
|
||||||
|
kind: Literal["library", "folder", "file"]
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
external_id: str | None = None
|
||||||
|
external_url: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
modified_at: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorBrowseResponse(BaseModel):
|
||||||
|
profile_id: str
|
||||||
|
provider: str
|
||||||
|
path: str = ""
|
||||||
|
library_id: str | None = None
|
||||||
|
read_only: bool = True
|
||||||
|
decision: dict[str, Any]
|
||||||
|
items: list[FileConnectorBrowseItem]
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorImportRequest(BaseModel):
|
||||||
|
library_id: str
|
||||||
|
path: str
|
||||||
|
owner_type: Literal["user", "group"] = "user"
|
||||||
|
owner_id: str | None = None
|
||||||
|
target_folder: str | None = None
|
||||||
|
target_path: str | None = None
|
||||||
|
campaign_id: str | None = None
|
||||||
|
conflict_strategy: Literal["reject", "overwrite", "rename"] = "reject"
|
||||||
|
source_revision: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FileConnectorSyncResponse(BaseModel):
|
||||||
|
file: FileAssetResponse
|
||||||
|
action: Literal["created", "updated", "unchanged"]
|
||||||
|
previous_version_id: str | None = None
|
||||||
|
current_version_id: str
|
||||||
|
|
||||||
|
|
||||||
class BulkDeleteRequest(BaseModel):
|
class BulkDeleteRequest(BaseModel):
|
||||||
file_ids: list[str]
|
file_ids: list[str]
|
||||||
|
|
||||||
@@ -128,8 +467,8 @@ class BulkFileShareResponse(BaseModel):
|
|||||||
class RenameRequest(BaseModel):
|
class RenameRequest(BaseModel):
|
||||||
file_ids: list[str] = Field(default_factory=list)
|
file_ids: list[str] = Field(default_factory=list)
|
||||||
folder_paths: list[str] = Field(default_factory=list)
|
folder_paths: list[str] = Field(default_factory=list)
|
||||||
owner_type: Literal["user", "group"] | None = None
|
owner_type: Literal["user", "group"]
|
||||||
owner_id: str | None = None
|
owner_id: str
|
||||||
mode: Literal["direct", "prefix", "suffix", "replace"]
|
mode: Literal["direct", "prefix", "suffix", "replace"]
|
||||||
new_name: str | None = None
|
new_name: str | None = None
|
||||||
find: str | None = None
|
find: str | None = None
|
||||||
|
|||||||
@@ -2,33 +2,50 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.models import Group, UserGroupMembership
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_DIRECTORY,
|
||||||
|
AccessDirectory,
|
||||||
|
GroupRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
|
||||||
|
|
||||||
|
def _access_directory() -> AccessDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
|
||||||
|
raise FileStorageError("Access directory capability is not configured")
|
||||||
|
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
if not isinstance(capability, AccessDirectory):
|
||||||
|
raise FileStorageError("Access directory capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def group_refs_for_ids(*, tenant_id: str, group_ids: list[str]) -> list[GroupRef]:
|
||||||
|
if not group_ids:
|
||||||
|
return []
|
||||||
|
groups = _access_directory().get_groups(group_ids)
|
||||||
|
return sorted(
|
||||||
|
[group for group in groups.values() if group.tenant_id == tenant_id],
|
||||||
|
key=lambda group: group.name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
|
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
|
||||||
|
del session
|
||||||
|
directory = _access_directory()
|
||||||
if include_admin_groups:
|
if include_admin_groups:
|
||||||
return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()]
|
return [group.id for group in directory.groups_for_tenant(tenant_id)]
|
||||||
return [
|
return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
|
||||||
row.group_id
|
|
||||||
for row in session.query(UserGroupMembership)
|
|
||||||
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id)
|
|
||||||
.all()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
|
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
|
||||||
group = session.get(Group, group_id)
|
group = _access_directory().get_group(group_id)
|
||||||
if not group or group.tenant_id != tenant_id:
|
if not group or group.tenant_id != tenant_id:
|
||||||
raise FileStorageError("Group not found")
|
raise FileStorageError("Group not found")
|
||||||
if is_admin:
|
if is_admin:
|
||||||
return
|
return
|
||||||
membership = (
|
if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id):
|
||||||
session.query(UserGroupMembership)
|
|
||||||
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id)
|
|
||||||
.one_or_none()
|
|
||||||
)
|
|
||||||
if membership is None:
|
|
||||||
raise FileStorageError("No access to this group file space")
|
raise FileStorageError("No access to this group file space")
|
||||||
|
|
||||||
|
|
||||||
@@ -42,3 +59,21 @@ def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, ow
|
|||||||
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
|
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
|
||||||
return
|
return
|
||||||
raise FileStorageError("Files must be owned by a user or group")
|
raise FileStorageError("Files must be owned by a user or group")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_share_target_exists(*, tenant_id: str, target_type: str, target_id: str) -> None:
|
||||||
|
target_type = target_type.lower().strip()
|
||||||
|
if target_type == "user":
|
||||||
|
user = _access_directory().get_user(target_id)
|
||||||
|
if not user or user.tenant_id != tenant_id or user.status != "active":
|
||||||
|
raise FileStorageError("User not found")
|
||||||
|
return
|
||||||
|
if target_type == "group":
|
||||||
|
group = _access_directory().get_group(target_id)
|
||||||
|
if not group or group.tenant_id != tenant_id or group.status != "active":
|
||||||
|
raise FileStorageError("Group not found")
|
||||||
|
return
|
||||||
|
if target_type == "tenant":
|
||||||
|
if target_id != tenant_id:
|
||||||
|
raise FileStorageError("Tenant not found")
|
||||||
|
return
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ from __future__ import annotations
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import zipfile
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_files.backend.db.models import FileAsset
|
from govoplan_files.backend.db.models import FileAsset
|
||||||
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile
|
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile
|
||||||
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
|
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
|
||||||
from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob
|
from govoplan_files.backend.storage.files import create_file_asset, current_versions_and_blobs
|
||||||
from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path
|
from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path
|
||||||
|
|
||||||
|
|
||||||
@@ -45,9 +46,11 @@ def _read_zip_member(
|
|||||||
|
|
||||||
def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None:
|
def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: str | Path) -> None:
|
||||||
backend = get_storage_backend()
|
backend = get_storage_backend()
|
||||||
|
asset_list = list(assets)
|
||||||
|
version_blobs = current_versions_and_blobs(session, asset_list)
|
||||||
with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
for asset in assets:
|
for asset in asset_list:
|
||||||
_version, blob = current_version_and_blob(session, asset)
|
_version, blob = version_blobs[asset.id]
|
||||||
info = zipfile.ZipInfo(asset.display_path)
|
info = zipfile.ZipInfo(asset.display_path)
|
||||||
info.compress_type = zipfile.ZIP_DEFLATED
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
with archive.open(info, "w") as member:
|
with archive.open(info, "w") as member:
|
||||||
@@ -66,11 +69,12 @@ def extract_zip_upload(
|
|||||||
owner_type: str,
|
owner_type: str,
|
||||||
owner_id: str,
|
owner_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
zip_data: bytes,
|
zip_data: bytes | str | PathLike[str],
|
||||||
folder: str | None,
|
folder: str | None,
|
||||||
campaign_id: str | None,
|
campaign_id: str | None,
|
||||||
conflict_strategy: str = "reject",
|
conflict_strategy: str = "reject",
|
||||||
conflict_resolutions: Iterable[FileConflictResolution] | None = None,
|
conflict_resolutions: Iterable[FileConflictResolution] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
max_files: int = 1000,
|
max_files: int = 1000,
|
||||||
max_file_bytes: int = 50 * 1024 * 1024,
|
max_file_bytes: int = 50 * 1024 * 1024,
|
||||||
@@ -80,7 +84,8 @@ def extract_zip_upload(
|
|||||||
total = 0
|
total = 0
|
||||||
base_folder = normalize_folder(folder)
|
base_folder = normalize_folder(folder)
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(BytesIO(zip_data)) as archive:
|
source = BytesIO(zip_data) if isinstance(zip_data, bytes) else zip_data
|
||||||
|
with zipfile.ZipFile(source) as archive:
|
||||||
infos = [info for info in archive.infolist() if not info.is_dir()]
|
infos = [info for info in archive.infolist() if not info.is_dir()]
|
||||||
if len(infos) > max_files:
|
if len(infos) > max_files:
|
||||||
raise FileStorageError(f"ZIP contains too many files (limit {max_files})")
|
raise FileStorageError(f"ZIP contains too many files (limit {max_files})")
|
||||||
@@ -113,6 +118,7 @@ def extract_zip_upload(
|
|||||||
data=data,
|
data=data,
|
||||||
display_path=target_path,
|
display_path=target_path,
|
||||||
content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream",
|
content_type=mimetypes.guess_type(inner_path)[0] or "application/octet-stream",
|
||||||
|
metadata=metadata,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
conflict_strategy=conflict_strategy,
|
conflict_strategy=conflict_strategy,
|
||||||
conflict_resolutions=conflict_resolutions,
|
conflict_resolutions=conflict_resolutions,
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ from typing import Any, Iterator
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_files.backend.db.models import FileAsset
|
from govoplan_files.backend.db.models import FileAsset
|
||||||
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user, read_asset_bytes
|
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
|
||||||
from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component
|
from govoplan_files.backend.storage.paths import normalize_folder, normalize_logical_path, safe_storage_component
|
||||||
|
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata, source_revision_from_metadata
|
||||||
|
|
||||||
|
|
||||||
MANAGED_SOURCE_PREFIX = "managed:"
|
MANAGED_SOURCE_PREFIX = "managed:"
|
||||||
@@ -34,10 +37,16 @@ class ManagedAttachmentFile:
|
|||||||
checksum_sha256: str
|
checksum_sha256: str
|
||||||
size_bytes: int
|
size_bytes: int
|
||||||
content_type: str | None
|
content_type: str | None
|
||||||
|
source_provenance: dict[str, Any] | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
payload = asdict(self)
|
payload = asdict(self)
|
||||||
payload.pop("local_path", None)
|
payload.pop("local_path", None)
|
||||||
|
if payload.get("source_provenance") is None:
|
||||||
|
payload.pop("source_provenance", None)
|
||||||
|
if payload.get("source_revision") is None:
|
||||||
|
payload.pop("source_revision", None)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +181,8 @@ def prepare_campaign_snapshot(
|
|||||||
owner_id = _asset_owner_id(asset)
|
owner_id = _asset_owner_id(asset)
|
||||||
if owner_id:
|
if owner_id:
|
||||||
assets_by_owner[(asset.owner_type, owner_id)].append(asset)
|
assets_by_owner[(asset.owner_type, owner_id)].append(asset)
|
||||||
|
version_blobs = current_versions_and_blobs(session, shared_assets)
|
||||||
|
backend = get_storage_backend() if include_bytes else None
|
||||||
|
|
||||||
manifest: dict[str, ManagedAttachmentFile] = {}
|
manifest: dict[str, ManagedAttachmentFile] = {}
|
||||||
prepared_by_id: dict[str, tuple[str, str]] = {}
|
prepared_by_id: dict[str, tuple[str, str]] = {}
|
||||||
@@ -206,11 +217,14 @@ def prepare_campaign_snapshot(
|
|||||||
continue
|
continue
|
||||||
target = _safe_local_target(local_root, relative_path)
|
target = _safe_local_target(local_root, relative_path)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
version, blob = version_blobs[asset.id]
|
||||||
if include_bytes:
|
if include_bytes:
|
||||||
data, version, blob = read_asset_bytes(session, asset)
|
try:
|
||||||
|
data = backend.get_bytes(blob.storage_key) if backend else b""
|
||||||
|
except StorageBackendError as exc:
|
||||||
|
raise FileStorageError(str(exc)) from exc
|
||||||
target.write_bytes(data)
|
target.write_bytes(data)
|
||||||
else:
|
else:
|
||||||
version, blob = current_version_and_blob(session, asset)
|
|
||||||
target.touch()
|
target.touch()
|
||||||
local_key = str(target.resolve())
|
local_key = str(target.resolve())
|
||||||
manifest[local_key] = ManagedAttachmentFile(
|
manifest[local_key] = ManagedAttachmentFile(
|
||||||
@@ -226,6 +240,8 @@ def prepare_campaign_snapshot(
|
|||||||
checksum_sha256=blob.checksum_sha256,
|
checksum_sha256=blob.checksum_sha256,
|
||||||
size_bytes=blob.size_bytes,
|
size_bytes=blob.size_bytes,
|
||||||
content_type=blob.content_type,
|
content_type=blob.content_type,
|
||||||
|
source_provenance=source_provenance_from_metadata(asset.metadata_),
|
||||||
|
source_revision=source_revision_from_metadata(asset.metadata_),
|
||||||
)
|
)
|
||||||
|
|
||||||
for rule in _iter_rule_dicts(attachments, prepared_json):
|
for rule in _iter_rule_dicts(attachments, prepared_json):
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion
|
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileVersion
|
||||||
from govoplan_files.backend.storage.common import utcnow
|
from govoplan_files.backend.storage.common import utcnow
|
||||||
from govoplan_files.backend.storage.files import current_version_and_blob, list_assets_for_user
|
from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
CampaignJobLike = Any
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate_match_keys(raw_match: str) -> set[str]:
|
def _candidate_match_keys(raw_match: str) -> set[str]:
|
||||||
@@ -29,7 +28,7 @@ def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str
|
|||||||
return job_id, file_version_id, filename_used, stage
|
return job_id, file_version_id, filename_used, stage
|
||||||
|
|
||||||
|
|
||||||
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]:
|
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJobLike], *, stage: str) -> set[AttachmentUseKey]:
|
||||||
job_ids = {job.id for job in jobs if job.id}
|
job_ids = {job.id for job in jobs if job.id}
|
||||||
if not job_ids:
|
if not job_ids:
|
||||||
return set()
|
return set()
|
||||||
@@ -72,13 +71,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]:
|
def _known_use_keys(session: Session, job: CampaignJobLike, *, stage: str) -> set[AttachmentUseKey]:
|
||||||
return _known_use_keys_for_jobs(session, [job], stage=stage)
|
return _known_use_keys_for_jobs(session, [job], stage=stage)
|
||||||
|
|
||||||
|
|
||||||
def _add_use(
|
def _add_use(
|
||||||
session: Session,
|
session: Session,
|
||||||
job: CampaignJob,
|
job: CampaignJobLike,
|
||||||
*,
|
*,
|
||||||
asset: FileAsset,
|
asset: FileAsset,
|
||||||
version: FileVersion,
|
version: FileVersion,
|
||||||
@@ -118,7 +117,7 @@ def _add_use(
|
|||||||
|
|
||||||
def record_campaign_attachment_uses_for_jobs(
|
def record_campaign_attachment_uses_for_jobs(
|
||||||
session: Session,
|
session: Session,
|
||||||
jobs: Iterable[CampaignJob],
|
jobs: Iterable[CampaignJobLike],
|
||||||
*,
|
*,
|
||||||
stage: str = "built",
|
stage: str = "built",
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -204,6 +203,7 @@ def record_campaign_attachment_uses_for_jobs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {}
|
assets_by_campaign: dict[tuple[str, str], dict[str, FileAsset]] = {}
|
||||||
|
version_blobs_by_campaign: dict[tuple[str, str], dict[str, tuple[FileVersion, FileBlob]]] = {}
|
||||||
for job_id, attachments in fallback_attachments_by_job.items():
|
for job_id, attachments in fallback_attachments_by_job.items():
|
||||||
job = job_by_id[job_id]
|
job = job_by_id[job_id]
|
||||||
campaign_key = (job.tenant_id, job.campaign_id)
|
campaign_key = (job.tenant_id, job.campaign_id)
|
||||||
@@ -221,6 +221,8 @@ def record_campaign_attachment_uses_for_jobs(
|
|||||||
by_key[asset.display_path.strip("/")] = asset
|
by_key[asset.display_path.strip("/")] = asset
|
||||||
by_key.setdefault(asset.filename, asset)
|
by_key.setdefault(asset.filename, asset)
|
||||||
assets_by_campaign[campaign_key] = by_key
|
assets_by_campaign[campaign_key] = by_key
|
||||||
|
version_blobs_by_campaign[campaign_key] = current_versions_and_blobs(session, assets)
|
||||||
|
version_blobs = version_blobs_by_campaign[campaign_key]
|
||||||
|
|
||||||
for attachment in attachments:
|
for attachment in attachments:
|
||||||
matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else []
|
matches = attachment.get("matches") if isinstance(attachment.get("matches"), list) else []
|
||||||
@@ -230,7 +232,10 @@ def record_campaign_attachment_uses_for_jobs(
|
|||||||
asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None)
|
asset = next((by_key[key] for key in _candidate_match_keys(raw) if key in by_key), None)
|
||||||
if not asset:
|
if not asset:
|
||||||
continue
|
continue
|
||||||
version, blob = current_version_and_blob(session, asset)
|
version_blob = version_blobs.get(asset.id)
|
||||||
|
if not version_blob:
|
||||||
|
continue
|
||||||
|
version, blob = version_blob
|
||||||
_add_use(
|
_add_use(
|
||||||
session,
|
session,
|
||||||
job,
|
job,
|
||||||
@@ -243,13 +248,13 @@ def record_campaign_attachment_uses_for_jobs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None:
|
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJobLike, *, stage: str = "built") -> None:
|
||||||
"""Record immutable managed file versions used by one built/sent job."""
|
"""Record immutable managed file versions used by one built/sent job."""
|
||||||
|
|
||||||
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
|
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
|
||||||
|
|
||||||
|
|
||||||
def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None:
|
def mark_job_attachment_uses_sent(session: Session, job: CampaignJobLike) -> None:
|
||||||
record_campaign_attachment_uses_for_job(session, job, stage="built")
|
record_campaign_attachment_uses_for_job(session, job, stage="built")
|
||||||
# Sessions use autoflush=False. Flush any compatibility-built evidence so
|
# Sessions use autoflush=False. Flush any compatibility-built evidence so
|
||||||
# the following query can copy it to the sent stage in the same call.
|
# the following query can copy it to the sent stage in the same call.
|
||||||
|
|||||||
601
src/govoplan_files/backend/storage/connector_browse.py
Normal file
601
src/govoplan_files/backend/storage/connector_browse.py
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
from importlib import import_module
|
||||||
|
import mimetypes
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote, unquote, urljoin, urlsplit
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorBrowseError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorBrowseUnsupported(ConnectorBrowseError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorBrowseItem:
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
external_id: str | None = None
|
||||||
|
external_url: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
content_type: str | None = None
|
||||||
|
modified_at: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_response(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"name": self.name,
|
||||||
|
"path": self.path,
|
||||||
|
"external_id": self.external_id,
|
||||||
|
"external_url": self.external_url,
|
||||||
|
"size_bytes": self.size_bytes,
|
||||||
|
"content_type": self.content_type,
|
||||||
|
"modified_at": self.modified_at,
|
||||||
|
"etag": self.etag,
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def browse_connector_profile(profile: ConnectorProfile, *, path: str | None = None, library_id: str | None = None) -> list[ConnectorBrowseItem]:
|
||||||
|
browse_path = normalize_connector_browse_path(path)
|
||||||
|
static_items = _static_listing(profile, path=browse_path, library_id=library_id)
|
||||||
|
if static_items is not None:
|
||||||
|
return static_items
|
||||||
|
if profile.provider == "seafile":
|
||||||
|
if _metadata_string(profile, "browse_protocol") == "webdav" or _metadata_string(profile, "webdav_endpoint_url"):
|
||||||
|
return _browse_webdav(profile, path=browse_path)
|
||||||
|
return _browse_seafile(profile, path=browse_path, library_id=library_id)
|
||||||
|
if profile.provider in {"webdav", "nextcloud"} or _metadata_string(profile, "browse_protocol") == "webdav":
|
||||||
|
return _browse_webdav(profile, path=browse_path)
|
||||||
|
if profile.provider == "smb":
|
||||||
|
return _browse_smb(profile, path=browse_path)
|
||||||
|
raise ConnectorBrowseUnsupported(f"Read-only browsing is not implemented for {profile.provider} connector profiles yet")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_webdav_multistatus(*, root_url: str, current_path: str, payload: str | bytes) -> list[ConnectorBrowseItem]:
|
||||||
|
try:
|
||||||
|
root = SafeElementTree.fromstring(payload)
|
||||||
|
except SafeElementTree.ParseError as exc:
|
||||||
|
raise ConnectorBrowseError("Connector returned invalid WebDAV XML") from exc
|
||||||
|
root_path = _url_path_root(root_url)
|
||||||
|
current = normalize_connector_browse_path(current_path)
|
||||||
|
items: list[ConnectorBrowseItem] = []
|
||||||
|
for response in root.findall("{DAV:}response"):
|
||||||
|
href = response.findtext("{DAV:}href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
relative_path = _relative_href_path(root_path, href)
|
||||||
|
if relative_path == current:
|
||||||
|
continue
|
||||||
|
if current and not relative_path.startswith(current.rstrip("/") + "/"):
|
||||||
|
continue
|
||||||
|
parent = relative_path.rsplit("/", 1)[0] if "/" in relative_path else ""
|
||||||
|
if parent != current:
|
||||||
|
continue
|
||||||
|
prop = _webdav_prop(response)
|
||||||
|
is_collection = prop.find("{DAV:}resourcetype/{DAV:}collection") is not None if prop is not None else False
|
||||||
|
name = _webdav_display_name(prop) or _path_name(relative_path)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
ConnectorBrowseItem(
|
||||||
|
kind="folder" if is_collection else "file",
|
||||||
|
name=name,
|
||||||
|
path=relative_path,
|
||||||
|
external_id=_text(prop, "{DAV:}getetag") or relative_path,
|
||||||
|
size_bytes=None if is_collection else _int(_text(prop, "{DAV:}getcontentlength")),
|
||||||
|
content_type=None if is_collection else _text(prop, "{DAV:}getcontenttype"),
|
||||||
|
modified_at=_http_date(_text(prop, "{DAV:}getlastmodified")),
|
||||||
|
etag=_text(prop, "{DAV:}getetag"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(items, key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def seafile_libraries_from_payload(payload: object) -> list[ConnectorBrowseItem]:
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ConnectorBrowseError("Seafile library response must be a list")
|
||||||
|
libraries = [_seafile_library_item(item) for item in payload if isinstance(item, Mapping)]
|
||||||
|
return sorted(libraries, key=lambda item: item.name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def seafile_directory_items_from_payload(payload: object, *, path: str | None = None) -> list[ConnectorBrowseItem]:
|
||||||
|
if payload == "uptodate":
|
||||||
|
return []
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ConnectorBrowseError("Seafile directory response must be a list")
|
||||||
|
browse_path = normalize_connector_browse_path(path)
|
||||||
|
items = [_seafile_directory_item(item, parent_path=browse_path) for item in payload if isinstance(item, Mapping)]
|
||||||
|
return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_seafile(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem]:
|
||||||
|
repo_id, dir_path = _seafile_repo_and_path(path=path, library_id=library_id)
|
||||||
|
headers = _seafile_headers(profile)
|
||||||
|
if not repo_id:
|
||||||
|
payload = _request_json("GET", _seafile_url(profile, "api2/repos/"), headers=headers)
|
||||||
|
return seafile_libraries_from_payload(payload)
|
||||||
|
payload = _request_json(
|
||||||
|
"GET",
|
||||||
|
_seafile_url(profile, f"api2/repos/{quote(repo_id, safe='')}/dir/"),
|
||||||
|
headers=headers,
|
||||||
|
params={"p": "/" + dir_path if dir_path else "/"},
|
||||||
|
)
|
||||||
|
return seafile_directory_items_from_payload(payload, path=dir_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_webdav(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]:
|
||||||
|
root_url = _metadata_string(profile, "webdav_endpoint_url") or profile.endpoint_url
|
||||||
|
if not root_url:
|
||||||
|
raise ConnectorBrowseError("Connector profile does not define an endpoint URL")
|
||||||
|
url = _webdav_url(root_url, path)
|
||||||
|
headers = {"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}
|
||||||
|
auth: tuple[str, str] | None = None
|
||||||
|
password = _profile_password(profile)
|
||||||
|
token = _profile_token(profile)
|
||||||
|
if profile.username and password:
|
||||||
|
auth = (profile.username, password)
|
||||||
|
elif token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
|
||||||
|
raise ConnectorBrowseError("Secret-ref connector credentials need a runtime secret resolver before live browsing")
|
||||||
|
body = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop>
|
||||||
|
<displayname />
|
||||||
|
<resourcetype />
|
||||||
|
<getcontentlength />
|
||||||
|
<getcontenttype />
|
||||||
|
<getlastmodified />
|
||||||
|
<getetag />
|
||||||
|
</prop>
|
||||||
|
</propfind>"""
|
||||||
|
try:
|
||||||
|
response = httpx.request("PROPFIND", url, headers=headers, content=body, auth=auth, timeout=15.0)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise ConnectorBrowseError("Connector credentials were rejected")
|
||||||
|
if response.status_code not in {200, 207}:
|
||||||
|
raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}")
|
||||||
|
return parse_webdav_multistatus(root_url=root_url, current_path=path, payload=response.text)
|
||||||
|
|
||||||
|
|
||||||
|
def _browse_smb(profile: ConnectorProfile, *, path: str) -> list[ConnectorBrowseItem]:
|
||||||
|
location = _smb_location(profile)
|
||||||
|
unc_path = _smb_unc_path(location, path)
|
||||||
|
smbclient = _smbclient_module()
|
||||||
|
try:
|
||||||
|
entries = smbclient.scandir(unc_path, **_smb_client_kwargs(profile, location))
|
||||||
|
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
|
||||||
|
raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc
|
||||||
|
items: list[ConnectorBrowseItem] = []
|
||||||
|
try:
|
||||||
|
with entries as iterator:
|
||||||
|
for entry in iterator:
|
||||||
|
name = _clean(getattr(entry, "name", None))
|
||||||
|
if not name or name in {".", ".."}:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
is_dir = bool(entry.is_dir())
|
||||||
|
except Exception:
|
||||||
|
is_dir = False
|
||||||
|
stat_result = _smb_entry_stat(entry)
|
||||||
|
item_path = _join_browse_path(path, name)
|
||||||
|
items.append(
|
||||||
|
ConnectorBrowseItem(
|
||||||
|
kind="folder" if is_dir else "file",
|
||||||
|
name=name,
|
||||||
|
path=item_path,
|
||||||
|
external_id=f"{location.share}:{item_path}",
|
||||||
|
size_bytes=None if is_dir else _smb_stat_size(stat_result),
|
||||||
|
content_type=None if is_dir else mimetypes.guess_type(name)[0],
|
||||||
|
modified_at=_smb_stat_modified_at(stat_result),
|
||||||
|
etag=_smb_stat_revision(stat_result),
|
||||||
|
metadata={
|
||||||
|
"share": location.share,
|
||||||
|
**({"server": location.server} if _metadata_bool(profile, "expose_server_metadata", default=False) else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ConnectorBrowseError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
|
||||||
|
raise ConnectorBrowseError(f"SMB connector browse failed: {exc}") from exc
|
||||||
|
return sorted(items, key=lambda item: (item.kind != "folder", item.name.casefold(), item.path.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_headers(profile: ConnectorProfile) -> dict[str, str]:
|
||||||
|
token = _seafile_token(profile)
|
||||||
|
return {"Authorization": f"Token {token}", "Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_token(profile: ConnectorProfile) -> str:
|
||||||
|
token = _profile_token(profile)
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
password = _profile_password(profile)
|
||||||
|
if profile.username and password:
|
||||||
|
payload = _request_json(
|
||||||
|
"POST",
|
||||||
|
_seafile_url(profile, "api2/auth-token/"),
|
||||||
|
data={"username": profile.username, "password": password},
|
||||||
|
)
|
||||||
|
if not isinstance(payload, Mapping) or not _clean(payload.get("token")):
|
||||||
|
raise ConnectorBrowseError("Seafile did not return an account token")
|
||||||
|
return _clean(payload.get("token")) or ""
|
||||||
|
if profile.secret_ref:
|
||||||
|
raise ConnectorBrowseError("Secret-ref Seafile credentials need a runtime secret resolver before live browsing")
|
||||||
|
raise ConnectorBrowseError("Seafile connector profiles require token credentials or username plus password credentials")
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_url(profile: ConnectorProfile, suffix: str) -> str:
|
||||||
|
if not profile.endpoint_url:
|
||||||
|
raise ConnectorBrowseError("Seafile connector profile does not define endpoint_url")
|
||||||
|
return urljoin(profile.endpoint_url.rstrip("/") + "/", suffix.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _request_json(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
params: Mapping[str, str] | None = None,
|
||||||
|
data: Mapping[str, str] | None = None,
|
||||||
|
) -> object:
|
||||||
|
try:
|
||||||
|
response = httpx.request(method, url, headers=dict(headers or {}), params=params, data=data, timeout=15.0)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ConnectorBrowseError(f"Connector browse failed: {exc}") from exc
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise ConnectorBrowseError("Connector credentials were rejected")
|
||||||
|
if response.status_code not in {200, 201}:
|
||||||
|
raise ConnectorBrowseError(f"Connector browse failed with HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConnectorBrowseError("Connector returned invalid JSON") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_repo_and_path(*, path: str, library_id: str | None) -> tuple[str | None, str]:
|
||||||
|
repo_id = _clean(library_id)
|
||||||
|
if repo_id:
|
||||||
|
return repo_id, path
|
||||||
|
if not path:
|
||||||
|
return None, ""
|
||||||
|
first, _, rest = path.partition("/")
|
||||||
|
return first, rest
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_library_item(value: Mapping[str, Any]) -> ConnectorBrowseItem:
|
||||||
|
repo_id = _clean(value.get("id") or value.get("repo_id"))
|
||||||
|
name = _clean(value.get("name") or value.get("repo_name") or repo_id)
|
||||||
|
if not repo_id or not name:
|
||||||
|
raise ConnectorBrowseError("Seafile library entries require id and name")
|
||||||
|
return ConnectorBrowseItem(
|
||||||
|
kind="library",
|
||||||
|
name=name,
|
||||||
|
path=repo_id,
|
||||||
|
external_id=repo_id,
|
||||||
|
size_bytes=_int(value.get("size") or value.get("repo_size")),
|
||||||
|
modified_at=_timestamp(value.get("mtime")),
|
||||||
|
metadata={
|
||||||
|
key: value[key]
|
||||||
|
for key in ("type", "permission", "encrypted", "owner", "file_count")
|
||||||
|
if key in value
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seafile_directory_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem:
|
||||||
|
name = _clean(value.get("name") or value.get("obj_name"))
|
||||||
|
if not name:
|
||||||
|
raise ConnectorBrowseError("Seafile directory entries require name")
|
||||||
|
item_type = str(value.get("type") or ("dir" if value.get("is_dir") else "file")).casefold()
|
||||||
|
kind = "folder" if item_type in {"dir", "folder"} else "file"
|
||||||
|
path = _join_browse_path(parent_path, name)
|
||||||
|
content_type = None if kind == "folder" else mimetypes.guess_type(name)[0]
|
||||||
|
return ConnectorBrowseItem(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
path=path,
|
||||||
|
external_id=_clean(value.get("id") or value.get("obj_id")),
|
||||||
|
size_bytes=None if kind == "folder" else _int(value.get("size")),
|
||||||
|
content_type=content_type,
|
||||||
|
modified_at=_timestamp(value.get("mtime") or value.get("modified")),
|
||||||
|
etag=_clean(value.get("id") or value.get("obj_id")),
|
||||||
|
metadata={
|
||||||
|
key: value[key]
|
||||||
|
for key in ("permission", "modifier_email", "modifier_name")
|
||||||
|
if key in value
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _static_listing(profile: ConnectorProfile, *, path: str, library_id: str | None) -> list[ConnectorBrowseItem] | None:
|
||||||
|
listing = profile.metadata.get("static_listing")
|
||||||
|
if listing is None:
|
||||||
|
return None
|
||||||
|
if isinstance(listing, list):
|
||||||
|
raw_items = listing if path == "" else []
|
||||||
|
elif isinstance(listing, Mapping):
|
||||||
|
key = library_id or path
|
||||||
|
raw_items = listing.get(key)
|
||||||
|
if raw_items is None and key == "":
|
||||||
|
raw_items = listing.get("/")
|
||||||
|
else:
|
||||||
|
raise ConnectorBrowseError("Connector static_listing metadata must be a list or object")
|
||||||
|
if raw_items is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raise ConnectorBrowseError("Connector static_listing entries must be lists")
|
||||||
|
return sorted(
|
||||||
|
[_static_item(item, parent_path=path) for item in raw_items if isinstance(item, Mapping)],
|
||||||
|
key=lambda item: (item.kind != "library", item.kind != "folder", item.name.casefold(), item.path.casefold()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _static_item(value: Mapping[str, Any], *, parent_path: str) -> ConnectorBrowseItem:
|
||||||
|
kind = str(value.get("kind") or "file").strip().casefold()
|
||||||
|
if kind not in {"library", "folder", "file"}:
|
||||||
|
raise ConnectorBrowseError("Connector browse items must be library, folder or file")
|
||||||
|
name = str(value.get("name") or value.get("path") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ConnectorBrowseError("Connector browse items require name")
|
||||||
|
path = normalize_connector_browse_path(value.get("path"))
|
||||||
|
if not path:
|
||||||
|
path = _join_browse_path(parent_path, name)
|
||||||
|
return ConnectorBrowseItem(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
path=path,
|
||||||
|
external_id=_clean(value.get("external_id")),
|
||||||
|
external_url=_clean(value.get("external_url")),
|
||||||
|
size_bytes=_int(value.get("size_bytes")),
|
||||||
|
content_type=_clean(value.get("content_type")),
|
||||||
|
modified_at=_clean(value.get("modified_at")),
|
||||||
|
etag=_clean(value.get("etag")),
|
||||||
|
metadata=value.get("metadata") if isinstance(value.get("metadata"), Mapping) else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _webdav_url(root_url: str, path: str) -> str:
|
||||||
|
base = root_url if root_url.endswith("/") else root_url + "/"
|
||||||
|
if not path:
|
||||||
|
return base
|
||||||
|
quoted = "/".join(quote(part, safe="") for part in path.split("/") if part)
|
||||||
|
return urljoin(base, quoted + "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _url_path_root(root_url: str) -> str:
|
||||||
|
path = unquote(urlsplit(root_url).path or "/")
|
||||||
|
return path if path.endswith("/") else path + "/"
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_href_path(root_path: str, href: str) -> str:
|
||||||
|
href_path = unquote(urlsplit(href).path or href).strip()
|
||||||
|
if href_path.startswith(root_path):
|
||||||
|
return normalize_connector_browse_path(href_path[len(root_path):])
|
||||||
|
return normalize_connector_browse_path(href_path.rsplit("/", 1)[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _webdav_prop(response: ET.Element) -> ET.Element:
|
||||||
|
prop = response.find("{DAV:}propstat/{DAV:}prop")
|
||||||
|
if prop is not None:
|
||||||
|
return prop
|
||||||
|
prop = response.find(".//{DAV:}prop")
|
||||||
|
return prop if prop is not None else ET.Element("prop")
|
||||||
|
|
||||||
|
|
||||||
|
def _webdav_display_name(prop: ET.Element | None) -> str | None:
|
||||||
|
return _clean(_text(prop, "{DAV:}displayname"))
|
||||||
|
|
||||||
|
|
||||||
|
def _text(prop: ET.Element | None, tag: str) -> str | None:
|
||||||
|
if prop is None:
|
||||||
|
return None
|
||||||
|
child = prop.find(tag)
|
||||||
|
return child.text.strip() if child is not None and child.text else None
|
||||||
|
|
||||||
|
|
||||||
|
def _http_date(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return parsedate_to_datetime(value).isoformat()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(value: object) -> str | None:
|
||||||
|
number = _int(value)
|
||||||
|
if number is None:
|
||||||
|
return _clean(value)
|
||||||
|
return datetime.fromtimestamp(number, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_string(profile: ConnectorProfile, key: str) -> str | None:
|
||||||
|
return _clean(profile.metadata.get(key))
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_bool(profile: ConnectorProfile, key: str, *, default: bool = False) -> bool:
|
||||||
|
value = profile.metadata.get(key)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_required(name: str, profile_id: str) -> str:
|
||||||
|
value = _clean(os.environ.get(name))
|
||||||
|
if not value:
|
||||||
|
raise ConnectorBrowseError(f"Connector profile {profile_id} is missing required credential environment")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_connector_browse_path(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
path = str(value).replace("\\", "/").strip().strip("/")
|
||||||
|
parts = [part for part in path.split("/") if part and part not in {"."}]
|
||||||
|
if any(part == ".." for part in parts):
|
||||||
|
raise ConnectorBrowseError("Connector browse paths cannot contain parent directory segments")
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _join_browse_path(parent: str, name: str) -> str:
|
||||||
|
child = normalize_connector_browse_path(name)
|
||||||
|
if not parent:
|
||||||
|
return child
|
||||||
|
return f"{parent.rstrip('/')}/{child}"
|
||||||
|
|
||||||
|
|
||||||
|
def _path_name(path: str) -> str:
|
||||||
|
return path.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SmbLocation:
|
||||||
|
server: str
|
||||||
|
share: str
|
||||||
|
port: int
|
||||||
|
root_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_location(profile: ConnectorProfile) -> _SmbLocation:
|
||||||
|
if not profile.endpoint_url:
|
||||||
|
raise ConnectorBrowseError("SMB connector profile does not define endpoint_url")
|
||||||
|
parsed = urlsplit(profile.endpoint_url)
|
||||||
|
if parsed.scheme.casefold() != "smb":
|
||||||
|
raise ConnectorBrowseError("SMB connector endpoint_url must use smb://")
|
||||||
|
server = _clean(parsed.hostname)
|
||||||
|
if not server:
|
||||||
|
raise ConnectorBrowseError("SMB connector endpoint_url must include a server")
|
||||||
|
path_parts = [part for part in unquote(parsed.path or "").strip("/").split("/") if part]
|
||||||
|
if not path_parts:
|
||||||
|
raise ConnectorBrowseError("SMB connector endpoint_url must include a share name")
|
||||||
|
root_parts = path_parts[1:]
|
||||||
|
if profile.base_path:
|
||||||
|
root_parts.extend(normalize_connector_browse_path(profile.base_path).split("/"))
|
||||||
|
return _SmbLocation(
|
||||||
|
server=server,
|
||||||
|
share=path_parts[0],
|
||||||
|
port=parsed.port or _int(profile.metadata.get("port")) or 445,
|
||||||
|
root_path=normalize_connector_browse_path("/".join(root_parts)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_unc_path(location: _SmbLocation, path: str) -> str:
|
||||||
|
parts = [part for part in (location.root_path, normalize_connector_browse_path(path)) if part]
|
||||||
|
suffix = "\\".join(part.replace("/", "\\") for part in parts)
|
||||||
|
base = f"\\\\{location.server}\\{location.share}"
|
||||||
|
return f"{base}\\{suffix}" if suffix else base
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_client_kwargs(profile: ConnectorProfile, location: _SmbLocation) -> dict[str, object]:
|
||||||
|
kwargs: dict[str, object] = {
|
||||||
|
"port": location.port,
|
||||||
|
"require_signing": _metadata_bool(profile, "require_signing", default=True),
|
||||||
|
"auth_protocol": _metadata_string(profile, "auth_protocol") or "ntlm",
|
||||||
|
}
|
||||||
|
if "encrypt" in profile.metadata:
|
||||||
|
kwargs["encrypt"] = _metadata_bool(profile, "encrypt", default=False)
|
||||||
|
password = _profile_password(profile)
|
||||||
|
token = _profile_token(profile)
|
||||||
|
if profile.username and password:
|
||||||
|
kwargs["username"] = profile.username
|
||||||
|
kwargs["password"] = password
|
||||||
|
elif token:
|
||||||
|
raise ConnectorBrowseError("SMB connector profiles do not support bearer-token credentials")
|
||||||
|
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
|
||||||
|
raise ConnectorBrowseError("Secret-ref SMB credentials need a runtime secret resolver before live browsing")
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_password(profile: ConnectorProfile) -> str | None:
|
||||||
|
if profile.password_value:
|
||||||
|
return profile.password_value
|
||||||
|
if profile.password_env:
|
||||||
|
return _env_required(profile.password_env, profile.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_token(profile: ConnectorProfile) -> str | None:
|
||||||
|
if profile.token_value:
|
||||||
|
return profile.token_value
|
||||||
|
if profile.token_env:
|
||||||
|
return _env_required(profile.token_env, profile.id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _smbclient_module() -> Any:
|
||||||
|
try:
|
||||||
|
return import_module("smbclient")
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ConnectorBrowseUnsupported("SMB connector browsing requires the optional smbprotocol dependency") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_entry_stat(entry: object) -> object | None:
|
||||||
|
try:
|
||||||
|
return entry.stat() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_stat_size(stat_result: object | None) -> int | None:
|
||||||
|
return _int(getattr(stat_result, "st_size", None))
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_stat_modified_at(stat_result: object | None) -> str | None:
|
||||||
|
value = getattr(stat_result, "st_mtime", None)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
|
||||||
|
except (TypeError, ValueError, OSError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _smb_stat_revision(stat_result: object | None) -> str | None:
|
||||||
|
mtime_ns = getattr(stat_result, "st_mtime_ns", None)
|
||||||
|
size = getattr(stat_result, "st_size", None)
|
||||||
|
if mtime_ns is not None:
|
||||||
|
return f"{mtime_ns}:{size or 0}"
|
||||||
|
modified = getattr(stat_result, "st_mtime", None)
|
||||||
|
if modified is not None:
|
||||||
|
return f"{modified}:{size or 0}"
|
||||||
|
return None
|
||||||
340
src/govoplan_files/backend/storage/connector_credential_store.py
Normal file
340
src/govoplan_files/backend/storage/connector_credential_store.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
|
||||||
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorCredential
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorCredential:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
scope_type: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
credential_mode: str = "none"
|
||||||
|
username: str | None = None
|
||||||
|
password_env: str | None = None
|
||||||
|
token_env: str | None = None
|
||||||
|
secret_ref: str | None = None
|
||||||
|
password_value: str | None = None
|
||||||
|
token_value: str | None = None
|
||||||
|
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
||||||
|
metadata: Mapping[str, Any] | None = None
|
||||||
|
source_kind: str = "database"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_path(self) -> str:
|
||||||
|
return policy_source_path(self.scope_type, self.scope_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credential_secret_source(self) -> str | None:
|
||||||
|
if self.secret_ref:
|
||||||
|
return "secret_ref"
|
||||||
|
if self.password_value or self.token_value:
|
||||||
|
return "stored"
|
||||||
|
if self.token_env:
|
||||||
|
return "token_env"
|
||||||
|
if self.password_env:
|
||||||
|
return "password_env"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credentials_configured(self) -> bool:
|
||||||
|
if self.credential_mode.casefold() in {"", "none", "anonymous"}:
|
||||||
|
return True
|
||||||
|
return bool(self.secret_ref or self.password_value or self.token_value or self.password_env or self.token_env)
|
||||||
|
|
||||||
|
def to_response(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"provider": self.provider,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"scope_type": self.scope_type,
|
||||||
|
"scope_id": self.scope_id,
|
||||||
|
"source_path": self.source_path,
|
||||||
|
"credential_mode": self.credential_mode,
|
||||||
|
"credential_secret_source": self.credential_secret_source,
|
||||||
|
"credentials_configured": self.credentials_configured,
|
||||||
|
"username": self.username,
|
||||||
|
"policy_sources": [_policy_source_response(source) for source in self.policy_sources],
|
||||||
|
"metadata": dict(self.metadata or {}),
|
||||||
|
"source_kind": self.source_kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_database_connector_credentials(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> list[ConnectorCredential]:
|
||||||
|
return [connector_credential_from_row(row) for row in list_connector_credential_rows(session, tenant_id=tenant_id, include_disabled=include_disabled)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_connector_credential_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> list[FileConnectorCredential]:
|
||||||
|
query = session.query(FileConnectorCredential).filter(
|
||||||
|
(FileConnectorCredential.scope_type == "system")
|
||||||
|
| (FileConnectorCredential.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
if not include_disabled:
|
||||||
|
query = query.filter(FileConnectorCredential.enabled.is_(True))
|
||||||
|
return query.order_by(FileConnectorCredential.scope_type.asc(), FileConnectorCredential.label.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def connector_credential_from_row(row: FileConnectorCredential) -> ConnectorCredential:
|
||||||
|
policy_sources = []
|
||||||
|
if row.policy:
|
||||||
|
policy_sources = connector_policy_sources_from_payload({
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"label": row.label,
|
||||||
|
"policy": row.policy,
|
||||||
|
})
|
||||||
|
return ConnectorCredential(
|
||||||
|
id=row.id,
|
||||||
|
label=row.label,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
provider=row.provider,
|
||||||
|
enabled=row.enabled,
|
||||||
|
credential_mode=row.credential_mode,
|
||||||
|
username=row.username,
|
||||||
|
password_env=row.password_env,
|
||||||
|
token_env=row.token_env,
|
||||||
|
secret_ref=row.secret_ref,
|
||||||
|
password_value=decrypt_secret(row.password_encrypted),
|
||||||
|
token_value=decrypt_secret(row.token_encrypted),
|
||||||
|
policy_sources=tuple(policy_sources),
|
||||||
|
metadata=dict(row.metadata_ or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_connector_credential_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
credential_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> FileConnectorCredential:
|
||||||
|
row = session.get(FileConnectorCredential, credential_id)
|
||||||
|
if row is None or (row.scope_type != "system" and row.tenant_id != tenant_id):
|
||||||
|
raise FileStorageError("Connector credential not found")
|
||||||
|
if not include_disabled and not row.enabled:
|
||||||
|
raise FileStorageError("Connector credential not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def credential_rows_by_id(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
credential_ids: set[str],
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> dict[str, FileConnectorCredential]:
|
||||||
|
if not credential_ids:
|
||||||
|
return {}
|
||||||
|
rows = session.query(FileConnectorCredential).filter(FileConnectorCredential.id.in_(credential_ids)).all()
|
||||||
|
result: dict[str, FileConnectorCredential] = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.scope_type != "system" and row.tenant_id != tenant_id:
|
||||||
|
continue
|
||||||
|
if not include_disabled and not row.enabled:
|
||||||
|
continue
|
||||||
|
result[row.id] = row
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_connector_credential_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
credential_id: str,
|
||||||
|
label: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
enabled: bool = True,
|
||||||
|
credential_mode: str = "none",
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
password_env: str | None = None,
|
||||||
|
token_env: str | None = None,
|
||||||
|
secret_ref: str | None = None,
|
||||||
|
policy: Mapping[str, Any] | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> FileConnectorCredential:
|
||||||
|
clean_id = _normalize_id(credential_id)
|
||||||
|
if session.get(FileConnectorCredential, clean_id) is not None:
|
||||||
|
raise FileStorageError(f"Connector credential already exists: {clean_id}")
|
||||||
|
clean_scope_type, clean_scope_id, row_tenant_id = _normalize_scope(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
row = FileConnectorCredential(
|
||||||
|
id=clean_id,
|
||||||
|
tenant_id=row_tenant_id,
|
||||||
|
scope_type=clean_scope_type,
|
||||||
|
scope_id=clean_scope_id,
|
||||||
|
label=_normalize_label(label),
|
||||||
|
provider=_normalize_provider(provider),
|
||||||
|
enabled=bool(enabled),
|
||||||
|
credential_mode=_normalize_credential_mode(credential_mode),
|
||||||
|
username=_clean(username),
|
||||||
|
password_encrypted=encrypt_secret(_clean(password)),
|
||||||
|
token_encrypted=encrypt_secret(_clean(token)),
|
||||||
|
password_env=_clean(password_env),
|
||||||
|
token_env=_clean(token_env),
|
||||||
|
secret_ref=_clean(secret_ref),
|
||||||
|
policy=dict(policy or {}),
|
||||||
|
metadata_=dict(metadata or {}),
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
updated_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_connector_credential_row(
|
||||||
|
session: Session,
|
||||||
|
row: FileConnectorCredential,
|
||||||
|
*,
|
||||||
|
user_id: str | None,
|
||||||
|
label: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
credential_mode: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
password_env: str | None = None,
|
||||||
|
token_env: str | None = None,
|
||||||
|
secret_ref: str | None = None,
|
||||||
|
policy: Mapping[str, Any] | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
clear_password: bool = False,
|
||||||
|
clear_token: bool = False,
|
||||||
|
) -> FileConnectorCredential:
|
||||||
|
if label is not None:
|
||||||
|
row.label = _normalize_label(label)
|
||||||
|
if provider is not None:
|
||||||
|
row.provider = _normalize_provider(provider)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = bool(enabled)
|
||||||
|
if credential_mode is not None:
|
||||||
|
row.credential_mode = _normalize_credential_mode(credential_mode)
|
||||||
|
if username is not None:
|
||||||
|
row.username = _clean(username)
|
||||||
|
if password is not None:
|
||||||
|
row.password_encrypted = encrypt_secret(_clean(password))
|
||||||
|
elif clear_password:
|
||||||
|
row.password_encrypted = None
|
||||||
|
if token is not None:
|
||||||
|
row.token_encrypted = encrypt_secret(_clean(token))
|
||||||
|
elif clear_token:
|
||||||
|
row.token_encrypted = None
|
||||||
|
if password_env is not None:
|
||||||
|
row.password_env = _clean(password_env)
|
||||||
|
if token_env is not None:
|
||||||
|
row.token_env = _clean(token_env)
|
||||||
|
if secret_ref is not None:
|
||||||
|
row.secret_ref = _clean(secret_ref)
|
||||||
|
if policy is not None:
|
||||||
|
row.policy = dict(policy)
|
||||||
|
if metadata is not None:
|
||||||
|
row.metadata_ = dict(metadata)
|
||||||
|
row.updated_by_user_id = user_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_connector_credential_row(session: Session, row: FileConnectorCredential, *, user_id: str | None) -> FileConnectorCredential:
|
||||||
|
row.enabled = False
|
||||||
|
row.updated_by_user_id = user_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_scope(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None, str | None]:
|
||||||
|
clean_scope_type = normalize_policy_scope_type(scope_type)
|
||||||
|
clean_scope_id = _clean(scope_id)
|
||||||
|
if clean_scope_type == "system":
|
||||||
|
return "system", None, None
|
||||||
|
if clean_scope_type == "tenant":
|
||||||
|
return "tenant", tenant_id, tenant_id
|
||||||
|
if clean_scope_type in {"user", "group", "campaign"}:
|
||||||
|
if not clean_scope_id:
|
||||||
|
raise FileStorageError(f"{clean_scope_type.capitalize()} connector credentials require scope_id")
|
||||||
|
return clean_scope_type, clean_scope_id, tenant_id
|
||||||
|
raise FileStorageError("Unsupported connector credential scope")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_id(value: str) -> str:
|
||||||
|
clean = _clean(value)
|
||||||
|
if not clean:
|
||||||
|
raise FileStorageError("Connector credential id is required")
|
||||||
|
if len(clean) > 255:
|
||||||
|
raise FileStorageError("Connector credential id is too long")
|
||||||
|
if any(char.isspace() for char in clean):
|
||||||
|
raise FileStorageError("Connector credential id cannot contain whitespace")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_label(value: str) -> str:
|
||||||
|
clean = value.strip()
|
||||||
|
if not clean:
|
||||||
|
raise FileStorageError("Connector credential label is required")
|
||||||
|
if len(clean) > 255:
|
||||||
|
raise FileStorageError("Connector credential label is too long")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_provider(value: str | None) -> str | None:
|
||||||
|
clean = _clean(value)
|
||||||
|
if clean is None:
|
||||||
|
return None
|
||||||
|
clean = clean.casefold()
|
||||||
|
if clean not in supported_connector_providers():
|
||||||
|
raise FileStorageError(f"Unsupported connector credential provider: {value}")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_credential_mode(value: str) -> str:
|
||||||
|
clean = value.strip().casefold() or "none"
|
||||||
|
if clean not in {"none", "anonymous", "basic", "token", "secret_ref"}:
|
||||||
|
raise FileStorageError(f"Unsupported connector credential mode: {value}")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope_type": source.scope_type,
|
||||||
|
"scope_id": source.scope_id,
|
||||||
|
"label": source.label,
|
||||||
|
"policy": dict(source.policy or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
222
src/govoplan_files/backend/storage/connector_imports.py
Normal file
222
src/govoplan_files/backend/storage/connector_imports.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import mimetypes
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from govoplan_files.backend.storage.connector_browse import (
|
||||||
|
ConnectorBrowseError,
|
||||||
|
ConnectorBrowseUnsupported,
|
||||||
|
_clean,
|
||||||
|
_metadata_string,
|
||||||
|
_profile_password,
|
||||||
|
_profile_token,
|
||||||
|
_request_json,
|
||||||
|
_smb_client_kwargs,
|
||||||
|
_smb_location,
|
||||||
|
_smb_stat_modified_at,
|
||||||
|
_smb_stat_revision,
|
||||||
|
_smb_stat_size,
|
||||||
|
_smb_unc_path,
|
||||||
|
_smbclient_module,
|
||||||
|
_seafile_headers,
|
||||||
|
_seafile_url,
|
||||||
|
_webdav_url,
|
||||||
|
normalize_connector_browse_path,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||||
|
from govoplan_files.backend.storage.paths import filename_from_path
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorImportError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorImportUnsupported(ConnectorImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorDownloadedFile:
|
||||||
|
filename: str
|
||||||
|
data: bytes
|
||||||
|
content_type: str | None = None
|
||||||
|
revision: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
|
external_url: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def read_connector_file(
|
||||||
|
profile: ConnectorProfile,
|
||||||
|
*,
|
||||||
|
library_id: str,
|
||||||
|
path: str,
|
||||||
|
max_bytes: int,
|
||||||
|
) -> ConnectorDownloadedFile:
|
||||||
|
if profile.provider == "seafile":
|
||||||
|
if _metadata_string(profile, "browse_protocol") == "webdav" or _metadata_string(profile, "webdav_endpoint_url"):
|
||||||
|
return _read_webdav_file(profile, path=path, max_bytes=max_bytes)
|
||||||
|
return _read_seafile_file(profile, library_id=library_id, path=path, max_bytes=max_bytes)
|
||||||
|
if profile.provider in {"webdav", "nextcloud"}:
|
||||||
|
return _read_webdav_file(profile, path=path, max_bytes=max_bytes)
|
||||||
|
if profile.provider == "smb":
|
||||||
|
return _read_smb_file(profile, path=path, max_bytes=max_bytes)
|
||||||
|
raise ConnectorImportUnsupported(f"Connector file import is not implemented for {profile.provider} profiles yet")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_seafile_file(profile: ConnectorProfile, *, library_id: str, path: str, max_bytes: int) -> ConnectorDownloadedFile:
|
||||||
|
repo_id = _clean(library_id)
|
||||||
|
if not repo_id:
|
||||||
|
raise ConnectorImportError("Seafile import requires library_id")
|
||||||
|
file_path = "/" + normalize_connector_browse_path(path)
|
||||||
|
if file_path == "/":
|
||||||
|
raise ConnectorImportError("Seafile import requires a file path")
|
||||||
|
try:
|
||||||
|
headers = _seafile_headers(profile)
|
||||||
|
detail = _request_json(
|
||||||
|
"GET",
|
||||||
|
_seafile_url(profile, f"api2/repos/{repo_id}/file/detail/"),
|
||||||
|
headers=headers,
|
||||||
|
params={"p": file_path},
|
||||||
|
)
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
size = _int(detail.get("size"))
|
||||||
|
if size is not None and size > max_bytes:
|
||||||
|
raise ConnectorImportError(f"Seafile file exceeds limit of {max_bytes} bytes")
|
||||||
|
download_url = _request_json(
|
||||||
|
"GET",
|
||||||
|
_seafile_url(profile, f"api2/repos/{repo_id}/file/"),
|
||||||
|
headers=headers,
|
||||||
|
params={"p": file_path, "reuse": "1"},
|
||||||
|
)
|
||||||
|
except ConnectorBrowseError as exc:
|
||||||
|
raise ConnectorImportError(str(exc)) from exc
|
||||||
|
if not isinstance(download_url, str) or not download_url.strip():
|
||||||
|
raise ConnectorImportError("Seafile did not return a file download URL")
|
||||||
|
try:
|
||||||
|
response = httpx.request("GET", download_url, timeout=30.0)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ConnectorImportError(f"Seafile file download failed: {exc}") from exc
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ConnectorImportError(f"Seafile file download failed with HTTP {response.status_code}")
|
||||||
|
data = response.content
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise ConnectorImportError(f"Seafile file exceeds limit of {max_bytes} bytes")
|
||||||
|
detail = detail if isinstance(detail, dict) else {}
|
||||||
|
filename = filename_from_path(str(detail.get("name") or path))
|
||||||
|
content_type = response.headers.get("content-type") or mimetypes.guess_type(filename)[0]
|
||||||
|
external_id = f"{repo_id}:{normalize_connector_browse_path(path)}"
|
||||||
|
return ConnectorDownloadedFile(
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
content_type=content_type,
|
||||||
|
revision=_clean(detail.get("id") or detail.get("mtime") or detail.get("last_modified")),
|
||||||
|
external_id=external_id,
|
||||||
|
external_url=download_url,
|
||||||
|
metadata={
|
||||||
|
"library_id": repo_id,
|
||||||
|
"library_path": normalize_connector_browse_path(path),
|
||||||
|
"size": len(data),
|
||||||
|
**({key: detail[key] for key in ("permission", "last_modified", "mtime", "uploader_email", "last_modifier_email") if key in detail}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_webdav_file(profile: ConnectorProfile, *, path: str, max_bytes: int) -> ConnectorDownloadedFile:
|
||||||
|
root_url = _metadata_string(profile, "webdav_endpoint_url") or profile.endpoint_url
|
||||||
|
if not root_url:
|
||||||
|
raise ConnectorImportError("WebDAV connector profile does not define endpoint_url")
|
||||||
|
file_path = normalize_connector_browse_path(path)
|
||||||
|
if not file_path:
|
||||||
|
raise ConnectorImportError("WebDAV import requires a file path")
|
||||||
|
url = _webdav_url(root_url, file_path).rstrip("/")
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
auth: tuple[str, str] | None = None
|
||||||
|
password = _profile_password(profile)
|
||||||
|
token = _profile_token(profile)
|
||||||
|
if profile.username and password:
|
||||||
|
auth = (profile.username, password)
|
||||||
|
elif token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
elif profile.credential_mode.casefold() not in {"", "none", "anonymous"} and profile.secret_ref:
|
||||||
|
raise ConnectorImportError("Secret-ref WebDAV credentials need a runtime secret resolver before live import")
|
||||||
|
try:
|
||||||
|
response = httpx.request("GET", url, headers=headers, auth=auth, timeout=30.0)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ConnectorImportError(f"WebDAV file download failed: {exc}") from exc
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise ConnectorImportError("Connector credentials were rejected")
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ConnectorImportError(f"WebDAV file download failed with HTTP {response.status_code}")
|
||||||
|
length = _int(response.headers.get("content-length"))
|
||||||
|
if length is not None and length > max_bytes:
|
||||||
|
raise ConnectorImportError(f"WebDAV file exceeds limit of {max_bytes} bytes")
|
||||||
|
data = response.content
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise ConnectorImportError(f"WebDAV file exceeds limit of {max_bytes} bytes")
|
||||||
|
filename = filename_from_path(file_path)
|
||||||
|
revision = _clean(response.headers.get("etag") or response.headers.get("last-modified"))
|
||||||
|
return ConnectorDownloadedFile(
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
content_type=response.headers.get("content-type") or mimetypes.guess_type(filename)[0],
|
||||||
|
revision=revision,
|
||||||
|
external_id=file_path,
|
||||||
|
external_url=url,
|
||||||
|
metadata={"path": file_path, "size": len(data)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_smb_file(profile: ConnectorProfile, *, path: str, max_bytes: int) -> ConnectorDownloadedFile:
|
||||||
|
location = _smb_location(profile)
|
||||||
|
file_path = normalize_connector_browse_path(path)
|
||||||
|
if not file_path:
|
||||||
|
raise ConnectorImportError("SMB import requires a file path")
|
||||||
|
unc_path = _smb_unc_path(location, file_path)
|
||||||
|
try:
|
||||||
|
smbclient = _smbclient_module()
|
||||||
|
kwargs = _smb_client_kwargs(profile, location)
|
||||||
|
stat_result = smbclient.stat(unc_path, **kwargs)
|
||||||
|
size = _smb_stat_size(stat_result)
|
||||||
|
if size is not None and size > max_bytes:
|
||||||
|
raise ConnectorImportError(f"SMB file exceeds limit of {max_bytes} bytes")
|
||||||
|
with smbclient.open_file(unc_path, mode="rb", **kwargs) as handle:
|
||||||
|
data = handle.read(max_bytes + 1)
|
||||||
|
except ConnectorBrowseUnsupported as exc:
|
||||||
|
raise ConnectorImportUnsupported(str(exc)) from exc
|
||||||
|
except ConnectorBrowseError as exc:
|
||||||
|
raise ConnectorImportError(str(exc)) from exc
|
||||||
|
except ConnectorImportError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pragma: no cover - concrete exception types are dependency-version specific
|
||||||
|
raise ConnectorImportError(f"SMB file download failed: {exc}") from exc
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise ConnectorImportError(f"SMB file exceeds limit of {max_bytes} bytes")
|
||||||
|
filename = filename_from_path(file_path)
|
||||||
|
revision = _smb_stat_revision(stat_result)
|
||||||
|
return ConnectorDownloadedFile(
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
content_type=mimetypes.guess_type(filename)[0],
|
||||||
|
revision=revision,
|
||||||
|
external_id=f"{location.share}:{file_path}",
|
||||||
|
external_url=f"smb://{location.server}:{location.port}/{location.share}/{file_path}",
|
||||||
|
metadata={
|
||||||
|
"share": location.share,
|
||||||
|
"path": file_path,
|
||||||
|
"size": len(data),
|
||||||
|
"modified_at": _smb_stat_modified_at(stat_result),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
310
src/govoplan_files/backend/storage/connector_policy.py
Normal file
310
src/govoplan_files/backend/storage/connector_policy.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from fnmatch import fnmatchcase
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import PolicyDecision, PolicySourceStep, normalize_policy_scope_type
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOW_KEYS = ("allow", "allowlist", "whitelist")
|
||||||
|
_DENY_KEYS = ("deny", "denylist", "blacklist")
|
||||||
|
|
||||||
|
_FIELD_ALIASES = {
|
||||||
|
"connectors": ("connectors", "connector_ids", "connector_id"),
|
||||||
|
"credentials": ("credentials", "credential_ids", "credential_id"),
|
||||||
|
"providers": ("providers", "provider"),
|
||||||
|
"external_ids": ("external_ids", "external_id"),
|
||||||
|
"external_paths": ("external_paths", "paths", "path_prefixes", "external_path"),
|
||||||
|
"external_urls": ("external_urls", "urls", "external_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
CONNECTOR_POLICY_FIELDS = tuple(_FIELD_ALIASES)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorPolicyDenied(RuntimeError):
|
||||||
|
def __init__(self, decision: PolicyDecision) -> None:
|
||||||
|
super().__init__(decision.reason or "Connector policy denied")
|
||||||
|
self.decision = decision
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorAccessRequest:
|
||||||
|
connector_id: str | None = None
|
||||||
|
credential_id: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
external_id: str | None = None
|
||||||
|
external_path: str | None = None
|
||||||
|
external_url: str | None = None
|
||||||
|
operation: str = "access"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_provenance(cls, value: Mapping[str, Any] | None, *, operation: str = "access") -> "ConnectorAccessRequest":
|
||||||
|
payload = value or {}
|
||||||
|
return cls(
|
||||||
|
connector_id=_clean(payload.get("connector_id")),
|
||||||
|
credential_id=_clean(payload.get("credential_id") or payload.get("connector_credential_id")),
|
||||||
|
provider=_clean(payload.get("provider") or payload.get("source_type")),
|
||||||
|
external_id=_clean(payload.get("external_id")),
|
||||||
|
external_path=_clean(payload.get("external_path")),
|
||||||
|
external_url=_clean(payload.get("external_url")),
|
||||||
|
operation=_clean(operation) or "access",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
"connector_id": self.connector_id,
|
||||||
|
"credential_id": self.credential_id,
|
||||||
|
"provider": self.provider,
|
||||||
|
"external_id": self.external_id,
|
||||||
|
"external_path": self.external_path,
|
||||||
|
"external_url": self.external_url,
|
||||||
|
"operation": self.operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorPolicySource:
|
||||||
|
scope_type: str
|
||||||
|
label: str
|
||||||
|
scope_id: str | None = None
|
||||||
|
policy: Mapping[str, Any] | None = None
|
||||||
|
|
||||||
|
def source_step(self, applied_fields: Iterable[str]) -> PolicySourceStep:
|
||||||
|
return PolicySourceStep(
|
||||||
|
scope_type=normalize_policy_scope_type(self.scope_type),
|
||||||
|
scope_id=self.scope_id,
|
||||||
|
label=self.label,
|
||||||
|
applied_fields=tuple(dict.fromkeys(applied_fields)),
|
||||||
|
policy=dict(self.policy or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_policy_sources_from_payload(value: object) -> list[ConnectorPolicySource]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return []
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
raw_sources = value.get("sources")
|
||||||
|
if raw_sources is None:
|
||||||
|
raw_sources = [value]
|
||||||
|
elif isinstance(value, list):
|
||||||
|
raw_sources = value
|
||||||
|
else:
|
||||||
|
raise ValueError("connector_policy must be a JSON object or list")
|
||||||
|
if not isinstance(raw_sources, list):
|
||||||
|
raise ValueError("connector_policy.sources must be a list")
|
||||||
|
return [_source_from_mapping(item) for item in raw_sources if isinstance(item, Mapping)]
|
||||||
|
|
||||||
|
|
||||||
|
def connector_policy_applied_fields(policy: Mapping[str, Any] | None) -> tuple[str, ...]:
|
||||||
|
return _applied_fields(_policy_mapping(policy))
|
||||||
|
|
||||||
|
|
||||||
|
def filtered_connector_policy_source(source: ConnectorPolicySource, fields: Iterable[str]) -> ConnectorPolicySource:
|
||||||
|
allowed_fields = {field for field in fields if field in _FIELD_ALIASES}
|
||||||
|
if not allowed_fields:
|
||||||
|
return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy={})
|
||||||
|
policy = _policy_mapping(source.policy)
|
||||||
|
filtered: dict[str, Any] = {}
|
||||||
|
for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)):
|
||||||
|
rules = _merged_rule(policy, keys)
|
||||||
|
selected = {field: _string_list(rules.get(field)) for field in allowed_fields}
|
||||||
|
selected = {field: values for field, values in selected.items() if values}
|
||||||
|
if selected:
|
||||||
|
filtered[prefix] = selected
|
||||||
|
return ConnectorPolicySource(scope_type=source.scope_type, scope_id=source.scope_id, label=source.label, policy=filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_policy_sources_for_fields(
|
||||||
|
sources: Iterable[ConnectorPolicySource],
|
||||||
|
fields: Iterable[str],
|
||||||
|
) -> list[ConnectorPolicySource]:
|
||||||
|
return [filtered_connector_policy_source(source, fields) for source in sources]
|
||||||
|
|
||||||
|
|
||||||
|
def connector_policy_decision(
|
||||||
|
request: ConnectorAccessRequest,
|
||||||
|
sources: Iterable[ConnectorPolicySource],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
source_list = list(sources)
|
||||||
|
source_steps: list[PolicySourceStep] = []
|
||||||
|
deny_matches: list[dict[str, Any]] = []
|
||||||
|
allow_misses: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for source in source_list:
|
||||||
|
policy = _policy_mapping(source.policy)
|
||||||
|
applied_fields = _applied_fields(policy)
|
||||||
|
if applied_fields:
|
||||||
|
source_steps.append(source.source_step(applied_fields))
|
||||||
|
|
||||||
|
deny_policy = _merged_rule(policy, _DENY_KEYS)
|
||||||
|
for field, patterns in _rules_by_field(deny_policy).items():
|
||||||
|
if _matches_field(request, field, patterns):
|
||||||
|
deny_matches.append({
|
||||||
|
"scope": source.source_step((f"deny.{field}",)).to_dict(),
|
||||||
|
"field": field,
|
||||||
|
"patterns": patterns,
|
||||||
|
})
|
||||||
|
|
||||||
|
allow_policy = _merged_rule(policy, _ALLOW_KEYS)
|
||||||
|
for field, patterns in _rules_by_field(allow_policy).items():
|
||||||
|
if not _matches_field(request, field, patterns):
|
||||||
|
allow_misses.append({
|
||||||
|
"scope": source.source_step((f"allow.{field}",)).to_dict(),
|
||||||
|
"field": field,
|
||||||
|
"patterns": patterns,
|
||||||
|
})
|
||||||
|
|
||||||
|
details = {
|
||||||
|
"request": request.to_dict(),
|
||||||
|
"deny_matches": deny_matches,
|
||||||
|
"allow_misses": allow_misses,
|
||||||
|
}
|
||||||
|
if deny_matches:
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason="Connector access is denied by a blacklist rule.",
|
||||||
|
source_path=tuple(source_steps),
|
||||||
|
requirements=("connector_policy_denylist",),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
if allow_misses:
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason="Connector access is not allowed by the configured whitelist.",
|
||||||
|
source_path=tuple(source_steps),
|
||||||
|
requirements=("connector_policy_allowlist",),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=True,
|
||||||
|
reason=None,
|
||||||
|
source_path=tuple(source_steps),
|
||||||
|
requirements=(),
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_connector_policy_allows(
|
||||||
|
request: ConnectorAccessRequest,
|
||||||
|
sources: Iterable[ConnectorPolicySource],
|
||||||
|
) -> PolicyDecision:
|
||||||
|
decision = connector_policy_decision(request, sources)
|
||||||
|
if not decision.allowed:
|
||||||
|
raise ConnectorPolicyDenied(decision)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
def _source_from_mapping(value: Mapping[str, Any]) -> ConnectorPolicySource:
|
||||||
|
scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system"))
|
||||||
|
scope_id = _clean(value.get("scope_id"))
|
||||||
|
if scope_type == "system":
|
||||||
|
scope_id = None
|
||||||
|
elif not scope_id:
|
||||||
|
raise ValueError(f"{scope_type} connector policy sources require scope_id")
|
||||||
|
label = _clean(value.get("label")) or scope_type.capitalize()
|
||||||
|
policy = value.get("policy")
|
||||||
|
if policy is None:
|
||||||
|
policy = {key: value[key] for key in (*_ALLOW_KEYS, *_DENY_KEYS) if key in value}
|
||||||
|
if policy is not None and not isinstance(policy, Mapping):
|
||||||
|
raise ValueError("connector policy source policy must be a JSON object")
|
||||||
|
return ConnectorPolicySource(scope_type=scope_type, scope_id=scope_id, label=label, policy=policy or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_mapping(value: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||||
|
return value if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _merged_rule(policy: Mapping[str, Any], keys: Iterable[str]) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {}
|
||||||
|
for key in keys:
|
||||||
|
raw = policy.get(key)
|
||||||
|
if isinstance(raw, Mapping):
|
||||||
|
merged.update(raw)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _rules_by_field(value: Mapping[str, Any]) -> dict[str, list[str]]:
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for field, aliases in _FIELD_ALIASES.items():
|
||||||
|
items: list[str] = []
|
||||||
|
for alias in aliases:
|
||||||
|
items.extend(_string_list(value.get(alias)))
|
||||||
|
if items:
|
||||||
|
result[field] = list(dict.fromkeys(items))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _applied_fields(policy: Mapping[str, Any]) -> tuple[str, ...]:
|
||||||
|
fields: list[str] = []
|
||||||
|
for prefix, keys in (("allow", _ALLOW_KEYS), ("deny", _DENY_KEYS)):
|
||||||
|
rules = _merged_rule(policy, keys)
|
||||||
|
fields.extend(f"{prefix}.{field}" for field in _rules_by_field(rules))
|
||||||
|
return tuple(dict.fromkeys(fields))
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_field(request: ConnectorAccessRequest, field: str, patterns: list[str]) -> bool:
|
||||||
|
if field == "connectors":
|
||||||
|
return _matches_exact(request.connector_id, patterns)
|
||||||
|
if field == "credentials":
|
||||||
|
return _matches_exact(request.credential_id, patterns)
|
||||||
|
if field == "providers":
|
||||||
|
return _matches_exact(request.provider, patterns)
|
||||||
|
if field == "external_ids":
|
||||||
|
return _matches_glob(request.external_id, patterns)
|
||||||
|
if field == "external_paths":
|
||||||
|
return _matches_path(request.external_path, patterns)
|
||||||
|
if field == "external_urls":
|
||||||
|
return _matches_glob(request.external_url, patterns)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_exact(value: str | None, patterns: list[str]) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
clean = value.casefold()
|
||||||
|
return any(pattern == "*" or clean == pattern.casefold() for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_glob(value: str | None, patterns: list[str]) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
clean = value.casefold()
|
||||||
|
return any(fnmatchcase(clean, pattern.casefold()) for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_path(value: str | None, patterns: list[str]) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
clean = value.replace("\\", "/").strip()
|
||||||
|
for pattern in patterns:
|
||||||
|
normalized = pattern.replace("\\", "/").strip()
|
||||||
|
if normalized == "*":
|
||||||
|
return True
|
||||||
|
if any(token in normalized for token in "*?[]"):
|
||||||
|
if fnmatchcase(clean.casefold(), normalized.casefold()):
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
if clean.casefold() == normalized.casefold() or clean.casefold().startswith(normalized.rstrip("/").casefold() + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
values = [value]
|
||||||
|
elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)):
|
||||||
|
values = [str(item) for item in value]
|
||||||
|
else:
|
||||||
|
values = [str(value)]
|
||||||
|
return [item.strip() for item in values if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
334
src/govoplan_files/backend/storage/connector_policy_store.py
Normal file
334
src/govoplan_files/backend/storage/connector_policy_store.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import normalize_policy_scope_type
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorPolicy
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_policy import (
|
||||||
|
CONNECTOR_POLICY_FIELDS,
|
||||||
|
ConnectorPolicySource,
|
||||||
|
connector_policy_applied_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
_RULE_GROUPS = ("allow", "deny")
|
||||||
|
_FIELD_ALIASES = {
|
||||||
|
"connectors": ("connectors", "connector_ids", "connector_id"),
|
||||||
|
"credentials": ("credentials", "credential_ids", "credential_id"),
|
||||||
|
"providers": ("providers", "provider"),
|
||||||
|
"external_ids": ("external_ids", "external_id"),
|
||||||
|
"external_paths": ("external_paths", "paths", "path_prefixes", "external_path"),
|
||||||
|
"external_urls": ("external_urls", "urls", "external_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_connector_policy(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
return _normalize_connector_policy(row.policy if row is not None else None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_connector_policy(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
policy: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized = _normalize_connector_policy(policy)
|
||||||
|
parent = parent_connector_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
if parent is not None:
|
||||||
|
_validate_lower_level_limits(parent, normalized)
|
||||||
|
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=row_scope_type, scope_id=row_scope_id)
|
||||||
|
if row is None:
|
||||||
|
row = FileConnectorPolicy(
|
||||||
|
tenant_id=row_tenant_id,
|
||||||
|
scope_type=row_scope_type,
|
||||||
|
scope_id=row_scope_id,
|
||||||
|
policy=normalized,
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
updated_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.policy = normalized
|
||||||
|
row.updated_by_user_id = user_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def connector_policy_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
policy = get_connector_policy(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
|
||||||
|
effective_sources = effective_connector_policy_sources(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
|
||||||
|
parent_policy = parent_connector_policy(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
|
||||||
|
parent_sources = parent_connector_policy_sources(session, tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
|
||||||
|
return {
|
||||||
|
"scope_type": clean_scope_type,
|
||||||
|
"scope_id": clean_scope_id,
|
||||||
|
"policy": policy,
|
||||||
|
"effective_policy": merge_connector_policies(source.policy for source in effective_sources),
|
||||||
|
"parent_policy": parent_policy,
|
||||||
|
"effective_policy_sources": [_source_step(source) for source in effective_sources],
|
||||||
|
"parent_policy_sources": [_source_step(source) for source in parent_sources],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_connector_policy_sources(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> list[ConnectorPolicySource]:
|
||||||
|
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
sources: list[ConnectorPolicySource] = []
|
||||||
|
for source_scope_type, source_scope_id in _policy_source_chain(tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id):
|
||||||
|
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=source_scope_type, scope_id=source_scope_id)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
policy = _normalize_connector_policy(row.policy)
|
||||||
|
if _policy_is_empty(policy):
|
||||||
|
continue
|
||||||
|
sources.append(
|
||||||
|
ConnectorPolicySource(
|
||||||
|
scope_type=source_scope_type,
|
||||||
|
scope_id=source_scope_id,
|
||||||
|
label=_policy_source_label(source_scope_type),
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def parent_connector_policy_sources(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> list[ConnectorPolicySource]:
|
||||||
|
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
chain = _policy_source_chain(tenant_id=tenant_id, scope_type=clean_scope_type, scope_id=clean_scope_id)
|
||||||
|
parent_chain = chain[:-1] if chain else []
|
||||||
|
sources: list[ConnectorPolicySource] = []
|
||||||
|
for source_scope_type, source_scope_id in parent_chain:
|
||||||
|
row = _connector_policy_row(session, tenant_id=tenant_id, scope_type=source_scope_type, scope_id=source_scope_id)
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
policy = _normalize_connector_policy(row.policy)
|
||||||
|
if _policy_is_empty(policy):
|
||||||
|
continue
|
||||||
|
sources.append(
|
||||||
|
ConnectorPolicySource(
|
||||||
|
scope_type=source_scope_type,
|
||||||
|
scope_id=source_scope_id,
|
||||||
|
label=_policy_source_label(source_scope_type),
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def parent_connector_policy(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
sources = parent_connector_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
if not sources:
|
||||||
|
return None
|
||||||
|
return merge_connector_policies(source.policy for source in sources)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_connector_policy_allowed_by_parent(parent_policy: Mapping[str, Any] | None, local_policy: Mapping[str, Any] | None) -> None:
|
||||||
|
if parent_policy is None:
|
||||||
|
return
|
||||||
|
_validate_lower_level_limits(_normalize_connector_policy(parent_policy), _normalize_connector_policy(local_policy))
|
||||||
|
|
||||||
|
|
||||||
|
def merge_connector_policies(policies: Iterable[Mapping[str, Any] | None]) -> dict[str, Any]:
|
||||||
|
result = _empty_connector_policy()
|
||||||
|
for policy in policies:
|
||||||
|
normalized = _normalize_connector_policy(policy)
|
||||||
|
for group in _RULE_GROUPS:
|
||||||
|
rules = normalized.get(group) or {}
|
||||||
|
if not isinstance(rules, Mapping):
|
||||||
|
continue
|
||||||
|
target = result[group]
|
||||||
|
for field in CONNECTOR_POLICY_FIELDS:
|
||||||
|
values = _string_list(rules.get(field))
|
||||||
|
if values:
|
||||||
|
target[field] = _unique([*target.get(field, []), *values])
|
||||||
|
lower = normalized.get("allow_lower_level_limits") or {}
|
||||||
|
if isinstance(lower, Mapping):
|
||||||
|
for key, value in lower.items():
|
||||||
|
if isinstance(value, bool):
|
||||||
|
result["allow_lower_level_limits"][str(key)] = value
|
||||||
|
return _compact_connector_policy(result, keep_lower=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _connector_policy_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
) -> FileConnectorPolicy | None:
|
||||||
|
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
query = session.query(FileConnectorPolicy).filter(FileConnectorPolicy.scope_type == row_scope_type)
|
||||||
|
query = query.filter(FileConnectorPolicy.tenant_id.is_(None) if row_tenant_id is None else FileConnectorPolicy.tenant_id == row_tenant_id)
|
||||||
|
query = query.filter(FileConnectorPolicy.scope_id.is_(None) if row_scope_id is None else FileConnectorPolicy.scope_id == row_scope_id)
|
||||||
|
return query.order_by(FileConnectorPolicy.created_at.asc()).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]:
|
||||||
|
clean_scope_type, clean_scope_id = _policy_scope_ref(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
if clean_scope_type == "system":
|
||||||
|
return None, "system", None
|
||||||
|
return tenant_id, clean_scope_type, clean_scope_id
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_scope_ref(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None]:
|
||||||
|
clean_scope_type = normalize_policy_scope_type(scope_type)
|
||||||
|
clean_scope_id = _clean(scope_id)
|
||||||
|
if clean_scope_type == "system":
|
||||||
|
return "system", None
|
||||||
|
if clean_scope_type == "tenant":
|
||||||
|
if clean_scope_id and clean_scope_id != tenant_id:
|
||||||
|
raise FileStorageError("Tenant connector policy scope does not belong to the active tenant")
|
||||||
|
return "tenant", tenant_id
|
||||||
|
if clean_scope_type in {"user", "group", "campaign"}:
|
||||||
|
if not clean_scope_id:
|
||||||
|
raise FileStorageError(f"{clean_scope_type.capitalize()} connector policy requires scope_id")
|
||||||
|
return clean_scope_type, clean_scope_id
|
||||||
|
raise FileStorageError("Unsupported connector policy scope")
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_source_chain(*, tenant_id: str, scope_type: str, scope_id: str | None) -> list[tuple[str, str | None]]:
|
||||||
|
chain: list[tuple[str, str | None]] = [("system", None)]
|
||||||
|
if scope_type == "system":
|
||||||
|
return chain
|
||||||
|
chain.append(("tenant", tenant_id))
|
||||||
|
if scope_type == "tenant":
|
||||||
|
return chain
|
||||||
|
chain.append((scope_type, scope_id))
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_connector_policy(policy: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if policy is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(policy, Mapping):
|
||||||
|
raise FileStorageError("Connector policy must be a JSON object")
|
||||||
|
normalized = _empty_connector_policy()
|
||||||
|
for group in _RULE_GROUPS:
|
||||||
|
raw = policy.get(group)
|
||||||
|
if raw is None:
|
||||||
|
raw = policy.get(f"{group}list")
|
||||||
|
if raw is None:
|
||||||
|
raw = policy.get("whitelist" if group == "allow" else "blacklist")
|
||||||
|
if raw is not None and not isinstance(raw, Mapping):
|
||||||
|
raise FileStorageError(f"Connector policy {group} rules must be an object")
|
||||||
|
for field, aliases in _FIELD_ALIASES.items():
|
||||||
|
values: list[str] = []
|
||||||
|
if isinstance(raw, Mapping):
|
||||||
|
for alias in aliases:
|
||||||
|
values.extend(_string_list(raw.get(alias)))
|
||||||
|
if values:
|
||||||
|
normalized[group][field] = _unique(values)
|
||||||
|
lower = policy.get("allow_lower_level_limits")
|
||||||
|
if lower is not None:
|
||||||
|
if not isinstance(lower, Mapping):
|
||||||
|
raise FileStorageError("Connector policy allow_lower_level_limits must be an object")
|
||||||
|
for key, value in lower.items():
|
||||||
|
clean_key = str(key).strip()
|
||||||
|
if clean_key and isinstance(value, bool):
|
||||||
|
normalized["allow_lower_level_limits"][clean_key] = value
|
||||||
|
return _compact_connector_policy(normalized, keep_lower=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_connector_policy() -> dict[str, Any]:
|
||||||
|
return {"allow": {}, "deny": {}, "allow_lower_level_limits": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_connector_policy(policy: dict[str, Any], *, keep_lower: bool = False) -> dict[str, Any]:
|
||||||
|
compact: dict[str, Any] = {}
|
||||||
|
for group in _RULE_GROUPS:
|
||||||
|
rules = {field: _unique(_string_list(values)) for field, values in (policy.get(group) or {}).items()}
|
||||||
|
rules = {field: values for field, values in rules.items() if values}
|
||||||
|
if rules:
|
||||||
|
compact[group] = rules
|
||||||
|
lower = policy.get("allow_lower_level_limits") or {}
|
||||||
|
if isinstance(lower, Mapping):
|
||||||
|
lower_compact = {str(key): bool(value) for key, value in lower.items() if isinstance(value, bool)}
|
||||||
|
if lower_compact or keep_lower:
|
||||||
|
compact["allow_lower_level_limits"] = lower_compact
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_is_empty(policy: Mapping[str, Any]) -> bool:
|
||||||
|
return not connector_policy_applied_fields(policy) and not bool(policy.get("allow_lower_level_limits"))
|
||||||
|
|
||||||
|
|
||||||
|
def _source_step(source: ConnectorPolicySource) -> dict[str, Any]:
|
||||||
|
return source.source_step(connector_policy_applied_fields(source.policy)).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_source_label(scope_type: str) -> str:
|
||||||
|
if scope_type == "system":
|
||||||
|
return "System connector policy"
|
||||||
|
if scope_type == "tenant":
|
||||||
|
return "Tenant connector policy"
|
||||||
|
return f"{scope_type.capitalize()} connector policy"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_lower_level_limits(parent_policy: Mapping[str, Any], local_policy: Mapping[str, Any]) -> None:
|
||||||
|
limits = parent_policy.get("allow_lower_level_limits")
|
||||||
|
if not isinstance(limits, Mapping):
|
||||||
|
return
|
||||||
|
for field in connector_policy_applied_fields(local_policy):
|
||||||
|
allowed = limits.get(field)
|
||||||
|
if allowed is False:
|
||||||
|
raise FileStorageError(f"Parent connector policy does not allow lower-level {field} limits")
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
clean = value.strip()
|
||||||
|
return [clean] if clean else []
|
||||||
|
if isinstance(value, Iterable) and not isinstance(value, (str, bytes, bytearray, Mapping)):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
return [str(value).strip()] if str(value).strip() else []
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(values: Iterable[str]) -> list[str]:
|
||||||
|
return list(dict.fromkeys(value for value in values if value))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
298
src/govoplan_files/backend/storage/connector_profile_store.py
Normal file
298
src/govoplan_files/backend/storage/connector_profile_store.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import normalize_policy_scope_type
|
||||||
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
from govoplan_files.backend.storage.connector_credential_store import connector_credential_from_row, credential_rows_by_id
|
||||||
|
from govoplan_files.backend.storage.connector_policy import connector_policy_sources_from_payload
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers
|
||||||
|
|
||||||
|
|
||||||
|
def list_database_connector_profiles(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> list[ConnectorProfile]:
|
||||||
|
query = session.query(FileConnectorProfile).filter(
|
||||||
|
(FileConnectorProfile.scope_type == "system")
|
||||||
|
| (FileConnectorProfile.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
if not include_disabled:
|
||||||
|
query = query.filter(FileConnectorProfile.enabled.is_(True))
|
||||||
|
rows = query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
|
||||||
|
credential_ids = {_clean(row.credential_profile_id) for row in rows if _clean(row.credential_profile_id)}
|
||||||
|
credentials = credential_rows_by_id(session, tenant_id=tenant_id, credential_ids={item for item in credential_ids if item}, include_disabled=include_disabled)
|
||||||
|
return [connector_profile_from_row(row, credential_row=credentials.get(row.credential_profile_id or "")) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_connector_profile_rows(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> list[FileConnectorProfile]:
|
||||||
|
query = session.query(FileConnectorProfile).filter(
|
||||||
|
(FileConnectorProfile.scope_type == "system")
|
||||||
|
| (FileConnectorProfile.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
if not include_disabled:
|
||||||
|
query = query.filter(FileConnectorProfile.enabled.is_(True))
|
||||||
|
return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileConnectorCredential | None = None) -> ConnectorProfile:
|
||||||
|
policy_sources = []
|
||||||
|
if row.policy:
|
||||||
|
policy_sources = connector_policy_sources_from_payload({
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"label": row.label,
|
||||||
|
"policy": row.policy,
|
||||||
|
})
|
||||||
|
credential = connector_credential_from_row(credential_row) if credential_row is not None else None
|
||||||
|
if credential:
|
||||||
|
policy_sources.extend(credential.policy_sources)
|
||||||
|
return ConnectorProfile(
|
||||||
|
id=row.id,
|
||||||
|
label=row.label,
|
||||||
|
provider=row.provider,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
endpoint_url=row.endpoint_url,
|
||||||
|
base_path=row.base_path,
|
||||||
|
enabled=row.enabled,
|
||||||
|
credential_profile_id=row.credential_profile_id,
|
||||||
|
credential_profile_label=credential.label if credential else None,
|
||||||
|
credential_mode=credential.credential_mode if credential else row.credential_mode,
|
||||||
|
username=credential.username if credential else row.username,
|
||||||
|
password_env=credential.password_env if credential else row.password_env,
|
||||||
|
token_env=credential.token_env if credential else row.token_env,
|
||||||
|
secret_ref=credential.secret_ref if credential else row.secret_ref,
|
||||||
|
password_value=credential.password_value if credential else decrypt_secret(row.password_encrypted),
|
||||||
|
token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted),
|
||||||
|
capabilities=tuple(_string_list(row.capabilities)),
|
||||||
|
policy_sources=tuple(policy_sources),
|
||||||
|
metadata=dict(row.metadata_ or {}),
|
||||||
|
source_kind="database",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_connector_profile_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> FileConnectorProfile:
|
||||||
|
row = session.get(FileConnectorProfile, profile_id)
|
||||||
|
if row is None or (row.scope_type != "system" and row.tenant_id != tenant_id):
|
||||||
|
raise FileStorageError("Connector profile not found")
|
||||||
|
if not include_disabled and not row.enabled:
|
||||||
|
raise FileStorageError("Connector profile not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def create_connector_profile_row(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str | None,
|
||||||
|
profile_id: str,
|
||||||
|
label: str,
|
||||||
|
provider: str,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None = None,
|
||||||
|
endpoint_url: str | None = None,
|
||||||
|
base_path: str | None = None,
|
||||||
|
enabled: bool = True,
|
||||||
|
credential_profile_id: str | None = None,
|
||||||
|
credential_mode: str = "none",
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
password_env: str | None = None,
|
||||||
|
token_env: str | None = None,
|
||||||
|
secret_ref: str | None = None,
|
||||||
|
capabilities: list[str] | None = None,
|
||||||
|
policy: Mapping[str, Any] | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> FileConnectorProfile:
|
||||||
|
clean_id = _normalize_profile_id(profile_id)
|
||||||
|
if session.get(FileConnectorProfile, clean_id) is not None:
|
||||||
|
raise FileStorageError(f"Connector profile already exists: {clean_id}")
|
||||||
|
clean_scope_type, clean_scope_id, row_tenant_id = _normalize_scope(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
|
row = FileConnectorProfile(
|
||||||
|
id=clean_id,
|
||||||
|
tenant_id=row_tenant_id,
|
||||||
|
scope_type=clean_scope_type,
|
||||||
|
scope_id=clean_scope_id,
|
||||||
|
label=_normalize_label(label),
|
||||||
|
provider=_normalize_provider(provider),
|
||||||
|
endpoint_url=_clean(endpoint_url),
|
||||||
|
base_path=_clean(base_path),
|
||||||
|
enabled=bool(enabled),
|
||||||
|
credential_profile_id=_clean(credential_profile_id),
|
||||||
|
credential_mode=_normalize_credential_mode(credential_mode),
|
||||||
|
username=_clean(username),
|
||||||
|
password_encrypted=encrypt_secret(_clean(password)),
|
||||||
|
token_encrypted=encrypt_secret(_clean(token)),
|
||||||
|
password_env=_clean(password_env),
|
||||||
|
token_env=_clean(token_env),
|
||||||
|
secret_ref=_clean(secret_ref),
|
||||||
|
capabilities=_string_list(capabilities),
|
||||||
|
policy=dict(policy or {}),
|
||||||
|
metadata_=dict(metadata or {}),
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
updated_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_connector_profile_row(
|
||||||
|
session: Session,
|
||||||
|
row: FileConnectorProfile,
|
||||||
|
*,
|
||||||
|
user_id: str | None,
|
||||||
|
label: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
endpoint_url: str | None = None,
|
||||||
|
base_path: str | None = None,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
credential_profile_id: str | None = None,
|
||||||
|
credential_mode: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
password_env: str | None = None,
|
||||||
|
token_env: str | None = None,
|
||||||
|
secret_ref: str | None = None,
|
||||||
|
capabilities: list[str] | None = None,
|
||||||
|
policy: Mapping[str, Any] | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
clear_password: bool = False,
|
||||||
|
clear_token: bool = False,
|
||||||
|
) -> FileConnectorProfile:
|
||||||
|
if label is not None:
|
||||||
|
row.label = _normalize_label(label)
|
||||||
|
if provider is not None:
|
||||||
|
row.provider = _normalize_provider(provider)
|
||||||
|
if endpoint_url is not None:
|
||||||
|
row.endpoint_url = _clean(endpoint_url)
|
||||||
|
if base_path is not None:
|
||||||
|
row.base_path = _clean(base_path)
|
||||||
|
if enabled is not None:
|
||||||
|
row.enabled = bool(enabled)
|
||||||
|
if credential_profile_id is not None:
|
||||||
|
row.credential_profile_id = _clean(credential_profile_id)
|
||||||
|
if credential_mode is not None:
|
||||||
|
row.credential_mode = _normalize_credential_mode(credential_mode)
|
||||||
|
if username is not None:
|
||||||
|
row.username = _clean(username)
|
||||||
|
if password is not None:
|
||||||
|
row.password_encrypted = encrypt_secret(_clean(password))
|
||||||
|
elif clear_password:
|
||||||
|
row.password_encrypted = None
|
||||||
|
if token is not None:
|
||||||
|
row.token_encrypted = encrypt_secret(_clean(token))
|
||||||
|
elif clear_token:
|
||||||
|
row.token_encrypted = None
|
||||||
|
if password_env is not None:
|
||||||
|
row.password_env = _clean(password_env)
|
||||||
|
if token_env is not None:
|
||||||
|
row.token_env = _clean(token_env)
|
||||||
|
if secret_ref is not None:
|
||||||
|
row.secret_ref = _clean(secret_ref)
|
||||||
|
if capabilities is not None:
|
||||||
|
row.capabilities = _string_list(capabilities)
|
||||||
|
if policy is not None:
|
||||||
|
row.policy = dict(policy)
|
||||||
|
if metadata is not None:
|
||||||
|
row.metadata_ = dict(metadata)
|
||||||
|
row.updated_by_user_id = user_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate_connector_profile_row(session: Session, row: FileConnectorProfile, *, user_id: str | None) -> FileConnectorProfile:
|
||||||
|
row.enabled = False
|
||||||
|
row.updated_by_user_id = user_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_scope(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str, str | None, str | None]:
|
||||||
|
clean_scope_type = normalize_policy_scope_type(scope_type)
|
||||||
|
clean_scope_id = _clean(scope_id)
|
||||||
|
if clean_scope_type == "system":
|
||||||
|
return "system", None, None
|
||||||
|
if clean_scope_type == "tenant":
|
||||||
|
return "tenant", tenant_id, tenant_id
|
||||||
|
if clean_scope_type in {"user", "group", "campaign"}:
|
||||||
|
if not clean_scope_id:
|
||||||
|
raise FileStorageError(f"{clean_scope_type.capitalize()} connector profiles require scope_id")
|
||||||
|
return clean_scope_type, clean_scope_id, tenant_id
|
||||||
|
raise FileStorageError("Unsupported connector profile scope")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_profile_id(value: str) -> str:
|
||||||
|
clean = _clean(value)
|
||||||
|
if not clean:
|
||||||
|
raise FileStorageError("Connector profile id is required")
|
||||||
|
if len(clean) > 255:
|
||||||
|
raise FileStorageError("Connector profile id is too long")
|
||||||
|
if any(char.isspace() for char in clean):
|
||||||
|
raise FileStorageError("Connector profile id cannot contain whitespace")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_label(value: str) -> str:
|
||||||
|
clean = value.strip()
|
||||||
|
if not clean:
|
||||||
|
raise FileStorageError("Connector profile label is required")
|
||||||
|
if len(clean) > 255:
|
||||||
|
raise FileStorageError("Connector profile label is too long")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_provider(value: str) -> str:
|
||||||
|
clean = value.strip().casefold()
|
||||||
|
if clean not in supported_connector_providers():
|
||||||
|
raise FileStorageError(f"Unsupported connector provider: {value}")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_credential_mode(value: str) -> str:
|
||||||
|
clean = value.strip().casefold() or "none"
|
||||||
|
if clean not in {"none", "anonymous", "basic", "token", "secret_ref"}:
|
||||||
|
raise FileStorageError(f"Unsupported connector credential mode: {value}")
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
values = value.split(",")
|
||||||
|
elif isinstance(value, (list, tuple, set)):
|
||||||
|
values = value
|
||||||
|
else:
|
||||||
|
values = [value]
|
||||||
|
return [str(item).strip() for item in values if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
272
src/govoplan_files/backend/storage/connector_profiles.py
Normal file
272
src/govoplan_files/backend/storage/connector_profiles.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path
|
||||||
|
from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload
|
||||||
|
|
||||||
|
|
||||||
|
_ENV_JSON_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON", "FILES_CONNECTOR_PROFILES_JSON")
|
||||||
|
_ENV_FILE_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE", "FILES_CONNECTOR_PROFILES_FILE")
|
||||||
|
_SUPPORTED_PROVIDERS = {"seafile", "nextcloud", "webdav", "smb", "nfs", "dms", "generic"}
|
||||||
|
_INLINE_SECRET_FIELDS = ("password", "token", "api_key", "access_key", "secret_key")
|
||||||
|
|
||||||
|
|
||||||
|
def supported_connector_providers() -> set[str]:
|
||||||
|
return set(_SUPPORTED_PROVIDERS)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorProfile:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
provider: str
|
||||||
|
scope_type: str = "system"
|
||||||
|
scope_id: str | None = None
|
||||||
|
endpoint_url: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
credential_profile_id: str | None = None
|
||||||
|
credential_profile_label: str | None = None
|
||||||
|
credential_mode: str = "none"
|
||||||
|
username: str | None = None
|
||||||
|
password_env: str | None = None
|
||||||
|
token_env: str | None = None
|
||||||
|
secret_ref: str | None = None
|
||||||
|
password_value: str | None = field(default=None, repr=False)
|
||||||
|
token_value: str | None = field(default=None, repr=False)
|
||||||
|
has_inline_secret: bool = False
|
||||||
|
capabilities: tuple[str, ...] = ()
|
||||||
|
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
source_kind: str = "settings"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_path(self) -> str:
|
||||||
|
return policy_source_path(self.scope_type, self.scope_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credential_source(self) -> str | None:
|
||||||
|
if self.credential_profile_id:
|
||||||
|
return "credential_profile"
|
||||||
|
if self.secret_ref:
|
||||||
|
return "secret_ref"
|
||||||
|
if self.token_value or self.password_value:
|
||||||
|
return "stored"
|
||||||
|
if self.token_env:
|
||||||
|
return "token_env"
|
||||||
|
if self.password_env:
|
||||||
|
return "password_env"
|
||||||
|
if self.has_inline_secret:
|
||||||
|
return "inline"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credentials_configured(self) -> bool:
|
||||||
|
mode = self.credential_mode.casefold()
|
||||||
|
if mode in {"", "none", "anonymous"}:
|
||||||
|
return True
|
||||||
|
if self.secret_ref or self.has_inline_secret or self.password_value or self.token_value:
|
||||||
|
return True
|
||||||
|
if self.token_env:
|
||||||
|
return bool(os.environ.get(self.token_env))
|
||||||
|
if self.password_env:
|
||||||
|
return bool(os.environ.get(self.password_env))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def to_response(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"provider": self.provider,
|
||||||
|
"endpoint_url": self.endpoint_url,
|
||||||
|
"base_path": self.base_path,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"scope_type": self.scope_type,
|
||||||
|
"scope_id": self.scope_id,
|
||||||
|
"source_path": self.source_path,
|
||||||
|
"credential_profile_id": self.credential_profile_id,
|
||||||
|
"credential_profile_label": self.credential_profile_label,
|
||||||
|
"credential_mode": self.credential_mode,
|
||||||
|
"credential_source": self.credential_source,
|
||||||
|
"credentials_configured": self.credentials_configured,
|
||||||
|
"username": self.username,
|
||||||
|
"capabilities": list(self.capabilities),
|
||||||
|
"policy_sources": [_policy_source_response(source) for source in self.policy_sources],
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
"source_kind": self.source_kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def connector_profiles_from_settings(settings: object | None = None) -> list[ConnectorProfile]:
|
||||||
|
raw = _raw_profiles_payload(settings)
|
||||||
|
if raw in (None, ""):
|
||||||
|
return []
|
||||||
|
payload = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
return connector_profiles_from_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def connector_profiles_from_payload(value: object) -> list[ConnectorProfile]:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
raw_profiles = value.get("profiles", [])
|
||||||
|
elif isinstance(value, list):
|
||||||
|
raw_profiles = value
|
||||||
|
else:
|
||||||
|
raise ValueError("Connector profiles must be a JSON object with profiles or a list")
|
||||||
|
if not isinstance(raw_profiles, list):
|
||||||
|
raise ValueError("Connector profiles payload requires a profiles list")
|
||||||
|
return [_profile_from_mapping(item) for item in raw_profiles if isinstance(item, Mapping)]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_from_mapping(value: Mapping[str, Any]) -> ConnectorProfile:
|
||||||
|
profile_id = _clean(value.get("id") or value.get("connector_id") or value.get("name"))
|
||||||
|
if not profile_id:
|
||||||
|
raise ValueError("Connector profiles require id")
|
||||||
|
provider = (_clean(value.get("provider") or value.get("type")) or "generic").casefold()
|
||||||
|
if provider not in _SUPPORTED_PROVIDERS:
|
||||||
|
raise ValueError(f"Unsupported connector provider: {provider}")
|
||||||
|
scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system"))
|
||||||
|
scope_id = _clean(value.get("scope_id"))
|
||||||
|
if scope_type == "system":
|
||||||
|
scope_id = None
|
||||||
|
elif not scope_id:
|
||||||
|
raise ValueError(f"{scope_type} connector profiles require scope_id")
|
||||||
|
|
||||||
|
credentials = value.get("credentials")
|
||||||
|
credentials = credentials if isinstance(credentials, Mapping) else {}
|
||||||
|
mode = _clean(value.get("credential_mode") or value.get("auth_type") or credentials.get("mode") or credentials.get("type")) or "none"
|
||||||
|
profile = ConnectorProfile(
|
||||||
|
id=profile_id,
|
||||||
|
label=_clean(value.get("label") or value.get("name")) or profile_id,
|
||||||
|
provider=provider,
|
||||||
|
endpoint_url=_clean(value.get("endpoint_url") or value.get("base_url") or value.get("url")),
|
||||||
|
base_path=_clean(value.get("base_path") or value.get("path") or value.get("root")),
|
||||||
|
enabled=_bool(value.get("enabled"), default=True),
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
credential_profile_id=_clean(value.get("credential_profile_id") or value.get("credential_id")),
|
||||||
|
credential_profile_label=_clean(value.get("credential_profile_label") or value.get("credential_label")),
|
||||||
|
credential_mode=mode,
|
||||||
|
username=_clean(value.get("username") or credentials.get("username")),
|
||||||
|
password_env=_clean(value.get("password_env") or credentials.get("password_env")),
|
||||||
|
token_env=_clean(value.get("token_env") or credentials.get("token_env")),
|
||||||
|
secret_ref=_clean(value.get("secret_ref") or credentials.get("secret_ref")),
|
||||||
|
password_value=_clean(value.get("password") or credentials.get("password")),
|
||||||
|
token_value=_clean(value.get("token") or credentials.get("token")),
|
||||||
|
has_inline_secret=any(_clean(value.get(field) or credentials.get(field)) for field in _INLINE_SECRET_FIELDS),
|
||||||
|
capabilities=tuple(_string_list(value.get("capabilities"))),
|
||||||
|
metadata=_public_metadata(value.get("metadata")),
|
||||||
|
)
|
||||||
|
return ConnectorProfile(
|
||||||
|
id=profile.id,
|
||||||
|
label=profile.label,
|
||||||
|
provider=profile.provider,
|
||||||
|
scope_type=profile.scope_type,
|
||||||
|
scope_id=profile.scope_id,
|
||||||
|
endpoint_url=profile.endpoint_url,
|
||||||
|
base_path=profile.base_path,
|
||||||
|
enabled=profile.enabled,
|
||||||
|
credential_profile_id=profile.credential_profile_id,
|
||||||
|
credential_profile_label=profile.credential_profile_label,
|
||||||
|
credential_mode=profile.credential_mode,
|
||||||
|
username=profile.username,
|
||||||
|
password_env=profile.password_env,
|
||||||
|
token_env=profile.token_env,
|
||||||
|
secret_ref=profile.secret_ref,
|
||||||
|
password_value=profile.password_value,
|
||||||
|
token_value=profile.token_value,
|
||||||
|
has_inline_secret=profile.has_inline_secret,
|
||||||
|
capabilities=profile.capabilities,
|
||||||
|
policy_sources=tuple(_policy_sources_from_profile(value, profile)),
|
||||||
|
metadata=profile.metadata,
|
||||||
|
source_kind="settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_profiles_payload(settings: object | None) -> object:
|
||||||
|
for key in _ENV_JSON_KEYS:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
for key in _ENV_FILE_KEYS:
|
||||||
|
path = os.environ.get(key)
|
||||||
|
if path:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
if settings is not None:
|
||||||
|
value = getattr(settings, "files_connector_profiles_json", None)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
value = getattr(settings, "files_connector_profiles", None)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_sources_from_profile(value: Mapping[str, Any], profile: ConnectorProfile) -> list[ConnectorPolicySource]:
|
||||||
|
raw_sources: list[Mapping[str, Any]] = []
|
||||||
|
policy = value.get("policy")
|
||||||
|
if isinstance(policy, Mapping):
|
||||||
|
raw_sources.append({
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"scope_id": profile.scope_id,
|
||||||
|
"label": profile.label,
|
||||||
|
"policy": policy,
|
||||||
|
})
|
||||||
|
configured_sources = value.get("policy_sources") or value.get("connector_policy_sources")
|
||||||
|
if configured_sources is not None:
|
||||||
|
raw = connector_policy_sources_from_payload(configured_sources)
|
||||||
|
raw_sources.extend(_policy_source_response(source) for source in raw)
|
||||||
|
return connector_policy_sources_from_payload(raw_sources)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope_type": source.scope_type,
|
||||||
|
"scope_id": source.scope_id,
|
||||||
|
"label": source.label,
|
||||||
|
"policy": dict(source.policy or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_metadata(value: object) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(key): item
|
||||||
|
for key, item in value.items()
|
||||||
|
if str(key).strip().casefold() not in _INLINE_SECRET_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: object) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
values: Iterable[object] = value.split(",")
|
||||||
|
elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)):
|
||||||
|
values = value
|
||||||
|
else:
|
||||||
|
values = (value,)
|
||||||
|
return [str(item).strip() for item in values if str(item).strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _bool(value: object, *, default: bool) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().casefold() not in {"0", "false", "no", "off"}
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
145
src/govoplan_files/backend/storage/connector_providers.py
Normal file
145
src/govoplan_files/backend/storage/connector_providers.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib.util import find_spec
|
||||||
|
|
||||||
|
|
||||||
|
CONNECTOR_PROVIDER_CAPABILITY = "files.connector.providers"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ConnectorProviderDescriptor:
|
||||||
|
provider: str
|
||||||
|
label: str
|
||||||
|
protocol: str
|
||||||
|
implemented: bool
|
||||||
|
browse_supported: bool
|
||||||
|
import_supported: bool
|
||||||
|
optional_dependency: str | None
|
||||||
|
permission_model: str
|
||||||
|
sync_strategy: str
|
||||||
|
conflict_strategy: str
|
||||||
|
preview_strategy: str
|
||||||
|
audit_events: tuple[str, ...]
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
def to_response(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"provider": self.provider,
|
||||||
|
"label": self.label,
|
||||||
|
"protocol": self.protocol,
|
||||||
|
"implemented": self.implemented,
|
||||||
|
"installed": self.installed,
|
||||||
|
"browse_supported": self.browse_supported,
|
||||||
|
"import_supported": self.import_supported,
|
||||||
|
"optional_dependency": self.optional_dependency,
|
||||||
|
"permission_model": self.permission_model,
|
||||||
|
"sync_strategy": self.sync_strategy,
|
||||||
|
"conflict_strategy": self.conflict_strategy,
|
||||||
|
"preview_strategy": self.preview_strategy,
|
||||||
|
"audit_events": list(self.audit_events),
|
||||||
|
"notes": self.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def installed(self) -> bool:
|
||||||
|
if self.optional_dependency is None:
|
||||||
|
return self.implemented
|
||||||
|
return find_spec(self.optional_dependency) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def connector_provider_descriptors() -> tuple[ConnectorProviderDescriptor, ...]:
|
||||||
|
freeze_model = "Imported or synced connector files become managed GovOPlaN files with frozen blob/version provenance."
|
||||||
|
governed_permissions = "GovOPlaN profile scope and connector policy are enforced before browse/import/sync; remote ACLs are still evaluated by the upstream service."
|
||||||
|
return (
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="seafile",
|
||||||
|
label="Seafile",
|
||||||
|
protocol="seafile-api",
|
||||||
|
implemented=True,
|
||||||
|
browse_supported=True,
|
||||||
|
import_supported=True,
|
||||||
|
optional_dependency=None,
|
||||||
|
permission_model=governed_permissions,
|
||||||
|
sync_strategy="On-demand browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
|
||||||
|
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
|
||||||
|
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Native account-token API for libraries, directories, file detail, and download-link import; WebDAV opt-in remains available.",
|
||||||
|
),
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="nextcloud",
|
||||||
|
label="Nextcloud",
|
||||||
|
protocol="webdav",
|
||||||
|
implemented=True,
|
||||||
|
browse_supported=True,
|
||||||
|
import_supported=True,
|
||||||
|
optional_dependency=None,
|
||||||
|
permission_model=governed_permissions,
|
||||||
|
sync_strategy="On-demand WebDAV browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
|
||||||
|
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
|
||||||
|
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Uses the configured Nextcloud WebDAV endpoint, basic auth, or bearer token profile credentials.",
|
||||||
|
),
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="webdav",
|
||||||
|
label="WebDAV",
|
||||||
|
protocol="webdav",
|
||||||
|
implemented=True,
|
||||||
|
browse_supported=True,
|
||||||
|
import_supported=True,
|
||||||
|
optional_dependency=None,
|
||||||
|
permission_model=governed_permissions,
|
||||||
|
sync_strategy="On-demand WebDAV browse/import/sync; sync updates the managed file version when source content changes and never mutates the remote source.",
|
||||||
|
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling: reject, overwrite, or rename.",
|
||||||
|
preview_strategy="Previews are generated from the frozen managed file after import or sync.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Generic WebDAV provider for standards-compatible stores.",
|
||||||
|
),
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="smb",
|
||||||
|
label="SMB",
|
||||||
|
protocol="smb",
|
||||||
|
implemented=True,
|
||||||
|
browse_supported=True,
|
||||||
|
import_supported=True,
|
||||||
|
optional_dependency="smbprotocol",
|
||||||
|
permission_model=governed_permissions,
|
||||||
|
sync_strategy="On-demand browse/import/sync over administrator-declared shares; sync updates managed versions and never mutates the remote share.",
|
||||||
|
conflict_strategy="Managed import/sync uses existing files conflict_strategy handling after download.",
|
||||||
|
preview_strategy="Previews are generated from the frozen managed file after import or sync, not directly from the share.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Requires the optional smbprotocol dependency and an smb://server[:port]/share[/path] profile endpoint.",
|
||||||
|
),
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="nfs",
|
||||||
|
label="NFS",
|
||||||
|
protocol="nfs",
|
||||||
|
implemented=False,
|
||||||
|
browse_supported=False,
|
||||||
|
import_supported=False,
|
||||||
|
optional_dependency=None,
|
||||||
|
permission_model=governed_permissions,
|
||||||
|
sync_strategy="Planned optional provider over administrator-mounted paths or a sidecar service.",
|
||||||
|
conflict_strategy="Will use managed import conflict_strategy handling after download.",
|
||||||
|
preview_strategy="Will preview from frozen managed file after import, not directly from the mount.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Kept optional because NFS availability is deployment and host-kernel dependent.",
|
||||||
|
),
|
||||||
|
ConnectorProviderDescriptor(
|
||||||
|
provider="local",
|
||||||
|
label="Local filesystem",
|
||||||
|
protocol="file",
|
||||||
|
implemented=False,
|
||||||
|
browse_supported=False,
|
||||||
|
import_supported=False,
|
||||||
|
optional_dependency=None,
|
||||||
|
permission_model="Local connector access should be restricted to administrator-declared roots plus GovOPlaN profile and policy checks.",
|
||||||
|
sync_strategy="Planned optional provider; managed storage local backend already exists separately.",
|
||||||
|
conflict_strategy=freeze_model,
|
||||||
|
preview_strategy="Will preview from frozen managed file after import or sync.",
|
||||||
|
audit_events=("files.connector.imported", "files.connector.synced", "files.connector.accessed"),
|
||||||
|
notes="Separate from the existing managed-file local storage backend.",
|
||||||
|
),
|
||||||
|
)
|
||||||
248
src/govoplan_files/backend/storage/connector_spaces.py
Normal file
248
src/govoplan_files/backend/storage/connector_spaces.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import false, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorSpace
|
||||||
|
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError, utcnow
|
||||||
|
from govoplan_files.backend.storage.connector_browse import normalize_connector_browse_path
|
||||||
|
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile
|
||||||
|
|
||||||
|
|
||||||
|
SYNC_MODES = {"manual"}
|
||||||
|
|
||||||
|
|
||||||
|
def connector_space_owner_id(space: FileConnectorSpace) -> str:
|
||||||
|
return space.owner_user_id if space.owner_type == "user" else space.owner_group_id # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def create_connector_space(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
user_id: str,
|
||||||
|
label: str,
|
||||||
|
profile: ConnectorProfile,
|
||||||
|
library_id: str | None = None,
|
||||||
|
remote_path: str | None = None,
|
||||||
|
sync_mode: str = "manual",
|
||||||
|
read_only: bool = True,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> FileConnectorSpace:
|
||||||
|
owner_type = owner_type.lower().strip()
|
||||||
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
|
||||||
|
label = _normalize_label(label)
|
||||||
|
sync_mode = _normalize_sync_mode(sync_mode)
|
||||||
|
remote_path = normalize_connector_browse_path(remote_path)
|
||||||
|
library_id = _clean_optional(library_id)
|
||||||
|
|
||||||
|
existing = _owned_query(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id).filter(
|
||||||
|
FileConnectorSpace.label == label,
|
||||||
|
FileConnectorSpace.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
raise FileStorageError(f"Connector space already exists: {label}")
|
||||||
|
|
||||||
|
space = FileConnectorSpace(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_user_id=owner_id if owner_type == "user" else None,
|
||||||
|
owner_group_id=owner_id if owner_type == "group" else None,
|
||||||
|
label=label,
|
||||||
|
connector_profile_id=profile.id,
|
||||||
|
provider=profile.provider,
|
||||||
|
library_id=library_id,
|
||||||
|
remote_path=remote_path,
|
||||||
|
sync_mode=sync_mode,
|
||||||
|
read_only=read_only,
|
||||||
|
is_active=True,
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
metadata_=dict(metadata or {}),
|
||||||
|
)
|
||||||
|
session.add(space)
|
||||||
|
session.flush()
|
||||||
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
def list_connector_spaces_for_user(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> list[FileConnectorSpace]:
|
||||||
|
query = session.query(FileConnectorSpace).filter(
|
||||||
|
FileConnectorSpace.tenant_id == tenant_id,
|
||||||
|
FileConnectorSpace.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.filter(FileConnectorSpace.is_active.is_(True))
|
||||||
|
|
||||||
|
if owner_type:
|
||||||
|
if not owner_id:
|
||||||
|
raise FileStorageError("owner_id is required when owner_type is set")
|
||||||
|
owner_type = owner_type.lower().strip()
|
||||||
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
|
||||||
|
query = _owner_filter(query, owner_type, owner_id)
|
||||||
|
elif not is_admin:
|
||||||
|
group_ids = user_group_ids(session, tenant_id=tenant_id, user_id=user_id)
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
FileConnectorSpace.owner_user_id == user_id,
|
||||||
|
FileConnectorSpace.owner_group_id.in_(group_ids) if group_ids else false(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.order_by(FileConnectorSpace.owner_type.asc(), FileConnectorSpace.label.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_connector_space_for_user(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
space_id: str,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> FileConnectorSpace:
|
||||||
|
query = session.query(FileConnectorSpace).filter(
|
||||||
|
FileConnectorSpace.id == space_id,
|
||||||
|
FileConnectorSpace.tenant_id == tenant_id,
|
||||||
|
FileConnectorSpace.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.filter(FileConnectorSpace.is_active.is_(True))
|
||||||
|
space = query.one_or_none()
|
||||||
|
if space is None:
|
||||||
|
raise FileStorageError("Connector space not found")
|
||||||
|
ensure_owner_access(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=space.owner_type,
|
||||||
|
owner_id=connector_space_owner_id(space),
|
||||||
|
user_id=user_id,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
def update_connector_space(
|
||||||
|
session: Session,
|
||||||
|
space: FileConnectorSpace,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
label: str | None = None,
|
||||||
|
library_id: str | None = None,
|
||||||
|
remote_path: str | None = None,
|
||||||
|
sync_mode: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> FileConnectorSpace:
|
||||||
|
ensure_owner_access(
|
||||||
|
session,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
owner_type=space.owner_type,
|
||||||
|
owner_id=connector_space_owner_id(space),
|
||||||
|
user_id=user_id,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
if label is not None:
|
||||||
|
new_label = _normalize_label(label)
|
||||||
|
existing = _owned_query(
|
||||||
|
session,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
owner_type=space.owner_type,
|
||||||
|
owner_id=connector_space_owner_id(space),
|
||||||
|
).filter(
|
||||||
|
FileConnectorSpace.id != space.id,
|
||||||
|
FileConnectorSpace.label == new_label,
|
||||||
|
FileConnectorSpace.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
raise FileStorageError(f"Connector space already exists: {new_label}")
|
||||||
|
space.label = new_label
|
||||||
|
if library_id is not None:
|
||||||
|
space.library_id = _clean_optional(library_id)
|
||||||
|
if remote_path is not None:
|
||||||
|
space.remote_path = normalize_connector_browse_path(remote_path)
|
||||||
|
if sync_mode is not None:
|
||||||
|
space.sync_mode = _normalize_sync_mode(sync_mode)
|
||||||
|
if is_active is not None:
|
||||||
|
space.is_active = bool(is_active)
|
||||||
|
if metadata is not None:
|
||||||
|
space.metadata_ = dict(metadata)
|
||||||
|
session.add(space)
|
||||||
|
session.flush()
|
||||||
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
def soft_delete_connector_space(
|
||||||
|
session: Session,
|
||||||
|
space: FileConnectorSpace,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> FileConnectorSpace:
|
||||||
|
ensure_owner_access(
|
||||||
|
session,
|
||||||
|
tenant_id=space.tenant_id,
|
||||||
|
owner_type=space.owner_type,
|
||||||
|
owner_id=connector_space_owner_id(space),
|
||||||
|
user_id=user_id,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
space.deleted_at = utcnow()
|
||||||
|
space.is_active = False
|
||||||
|
session.add(space)
|
||||||
|
session.flush()
|
||||||
|
return space
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_query(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
|
||||||
|
query = session.query(FileConnectorSpace).filter(
|
||||||
|
FileConnectorSpace.tenant_id == tenant_id,
|
||||||
|
FileConnectorSpace.owner_type == owner_type,
|
||||||
|
)
|
||||||
|
return _owner_filter(query, owner_type, owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_filter(query, owner_type: str, owner_id: str):
|
||||||
|
if owner_type == "user":
|
||||||
|
return query.filter(FileConnectorSpace.owner_user_id == owner_id)
|
||||||
|
if owner_type == "group":
|
||||||
|
return query.filter(FileConnectorSpace.owner_group_id == owner_id)
|
||||||
|
raise FileStorageError("Files must be owned by a user or group")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_label(value: str) -> str:
|
||||||
|
label = value.strip()
|
||||||
|
if not label:
|
||||||
|
raise FileStorageError("Connector space label is required")
|
||||||
|
if len(label) > 255:
|
||||||
|
raise FileStorageError("Connector space label is too long")
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sync_mode(value: str) -> str:
|
||||||
|
mode = value.strip().casefold()
|
||||||
|
if mode not in SYNC_MODES:
|
||||||
|
raise FileStorageError(f"Unsupported connector space sync mode: {value}")
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_optional(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
@@ -6,26 +6,32 @@ from pathlib import PurePosixPath
|
|||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import and_, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.models import Group, Tenant, User
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
|
||||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
|
||||||
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
|
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
|
||||||
from govoplan_files.backend.runtime import settings
|
from govoplan_files.backend.runtime import get_registry, settings
|
||||||
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
|
from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
|
||||||
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
|
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
|
||||||
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
|
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
|
||||||
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
|
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
|
||||||
|
from govoplan_files.backend.storage.provenance import source_provenance_from_metadata
|
||||||
|
|
||||||
|
|
||||||
def _campaign_model():
|
def _campaign_access_provider() -> CampaignAccessProvider:
|
||||||
try:
|
registry = get_registry()
|
||||||
from govoplan_campaign.backend.db.models import Campaign
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
|
||||||
except ModuleNotFoundError as exc:
|
raise FileStorageError("Campaign module is not installed")
|
||||||
reraise_unless_missing_package(exc, "govoplan_campaign")
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
|
||||||
raise FileStorageError("Campaign module is not installed") from exc
|
if not isinstance(capability, CampaignAccessProvider):
|
||||||
return Campaign
|
raise FileStorageError("Campaign access capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_campaign_exists(session: Session, *, tenant_id: str, campaign_id: str) -> None:
|
||||||
|
if not _campaign_access_provider().campaign_exists(session, tenant_id=tenant_id, campaign_id=campaign_id):
|
||||||
|
raise FileStorageError("Campaign not found")
|
||||||
|
|
||||||
|
|
||||||
def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
|
def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
|
||||||
@@ -176,6 +182,136 @@ def create_file_asset(
|
|||||||
return UploadedStoredFile(asset=asset, version=version, blob=blob)
|
return UploadedStoredFile(asset=asset, version=version, blob=blob)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_file_asset_from_source(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
user_id: str,
|
||||||
|
filename: str,
|
||||||
|
data: bytes,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
folder: str | None = None,
|
||||||
|
display_path: str | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
conflict_strategy: str = "rename",
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> tuple[UploadedStoredFile, str, str | None]:
|
||||||
|
owner_type = owner_type.lower().strip()
|
||||||
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
|
||||||
|
provenance = source_provenance_from_metadata(metadata)
|
||||||
|
if not provenance:
|
||||||
|
raise FileStorageError("Connector sync requires source provenance")
|
||||||
|
existing = find_asset_by_source(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
source_provenance=provenance,
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
stored = create_file_asset(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
user_id=user_id,
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
folder=folder,
|
||||||
|
display_path=display_path,
|
||||||
|
content_type=content_type,
|
||||||
|
metadata=metadata,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
conflict_strategy=conflict_strategy,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
return stored, "created", None
|
||||||
|
|
||||||
|
previous_version_id = existing.current_version_id
|
||||||
|
stored, action = update_file_asset_content(
|
||||||
|
session,
|
||||||
|
existing,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
filename=filename,
|
||||||
|
data=data,
|
||||||
|
content_type=content_type,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
if campaign_id:
|
||||||
|
share_file(session, tenant_id=tenant_id, asset=existing, target_type="campaign", target_id=campaign_id, permission="read", user_id=user_id)
|
||||||
|
return stored, action, previous_version_id
|
||||||
|
|
||||||
|
|
||||||
|
def find_asset_by_source(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
source_provenance: dict[str, Any],
|
||||||
|
) -> FileAsset | None:
|
||||||
|
wanted = _source_identity(source_provenance)
|
||||||
|
if wanted is None:
|
||||||
|
return None
|
||||||
|
assets = (
|
||||||
|
_asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id)
|
||||||
|
.filter(FileAsset.deleted_at.is_(None))
|
||||||
|
.order_by(FileAsset.updated_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for asset in assets:
|
||||||
|
if _source_identity(source_provenance_from_metadata(asset.metadata_ or {})) == wanted:
|
||||||
|
return asset
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_file_asset_content(
|
||||||
|
session: Session,
|
||||||
|
asset: FileAsset,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
filename: str,
|
||||||
|
data: bytes,
|
||||||
|
content_type: str | None,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> tuple[UploadedStoredFile, str]:
|
||||||
|
if asset.tenant_id != tenant_id or asset.deleted_at is not None:
|
||||||
|
raise FileStorageError("File not found")
|
||||||
|
safe_filename = filename_from_path(normalize_logical_path(filename, fallback_filename="file"))
|
||||||
|
if not content_type:
|
||||||
|
content_type = mimetypes.guess_type(safe_filename)[0] or "application/octet-stream"
|
||||||
|
current_version, current_blob = current_version_and_blob(session, asset)
|
||||||
|
checksum = hashlib.sha256(data).hexdigest()
|
||||||
|
asset.metadata_ = metadata
|
||||||
|
session.add(asset)
|
||||||
|
if current_blob.checksum_sha256 == checksum and current_blob.size_bytes == len(data):
|
||||||
|
return UploadedStoredFile(asset=asset, version=current_version, blob=current_blob), "unchanged"
|
||||||
|
|
||||||
|
blob = _get_or_create_blob(session, tenant_id=tenant_id, data=data, filename=safe_filename, content_type=content_type)
|
||||||
|
version = FileVersion(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
file_asset_id=asset.id,
|
||||||
|
blob_id=blob.id,
|
||||||
|
version_number=_next_version_number(session, asset.id),
|
||||||
|
filename_at_upload=safe_filename,
|
||||||
|
display_path_at_upload=asset.display_path,
|
||||||
|
content_type=content_type,
|
||||||
|
size_bytes=blob.size_bytes,
|
||||||
|
checksum_sha256=blob.checksum_sha256,
|
||||||
|
created_by_user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.flush()
|
||||||
|
asset.current_version_id = version.id
|
||||||
|
session.add(asset)
|
||||||
|
return UploadedStoredFile(asset=asset, version=version, blob=blob), "updated"
|
||||||
|
|
||||||
|
|
||||||
def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset:
|
def get_asset_for_user(session: Session, *, tenant_id: str, user_id: str, asset_id: str, require_write: bool = False, is_admin: bool = False) -> FileAsset:
|
||||||
asset = session.get(FileAsset, asset_id)
|
asset = session.get(FileAsset, asset_id)
|
||||||
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
|
if not asset or asset.tenant_id != tenant_id or asset.deleted_at is not None:
|
||||||
@@ -219,6 +355,71 @@ def list_assets_for_user(
|
|||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
) -> list[FileAsset]:
|
) -> list[FileAsset]:
|
||||||
|
query = _asset_visibility_query_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_assets_for_user_window(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
path_prefix: str | None = None,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
page_size: int,
|
||||||
|
after_display_path: str | None = None,
|
||||||
|
after_updated_at=None,
|
||||||
|
after_id: str | None = None,
|
||||||
|
) -> tuple[list[FileAsset], bool]:
|
||||||
|
query = _asset_visibility_query_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
path_prefix=path_prefix,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
if after_display_path is not None and after_updated_at is not None and after_id:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
FileAsset.display_path > after_display_path,
|
||||||
|
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at < after_updated_at),
|
||||||
|
and_(FileAsset.display_path == after_display_path, FileAsset.updated_at == after_updated_at, FileAsset.id > after_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc(), FileAsset.id.asc()).limit(page_size + 1).all()
|
||||||
|
return rows[:page_size], len(rows) > page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_visibility_query_for_user(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
campaign_id: str | None = None,
|
||||||
|
path_prefix: str | None = None,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
):
|
||||||
query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id)
|
query = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id)
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
query = query.filter(FileAsset.deleted_at.is_(None))
|
query = query.filter(FileAsset.deleted_at.is_(None))
|
||||||
@@ -250,7 +451,7 @@ def list_assets_for_user(
|
|||||||
prefix = normalize_folder(path_prefix)
|
prefix = normalize_folder(path_prefix)
|
||||||
if prefix:
|
if prefix:
|
||||||
query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
|
query = query.filter(FileAsset.display_path.like(f"{prefix}/%"))
|
||||||
return query.order_by(FileAsset.display_path.asc(), FileAsset.updated_at.desc()).all()
|
return query
|
||||||
|
|
||||||
|
|
||||||
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
|
def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVersion, FileBlob]:
|
||||||
@@ -265,6 +466,32 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe
|
|||||||
return version, blob
|
return version, blob
|
||||||
|
|
||||||
|
|
||||||
|
def current_versions_and_blobs(session: Session, assets: Iterable[FileAsset]) -> dict[str, tuple[FileVersion, FileBlob]]:
|
||||||
|
asset_list = list(assets)
|
||||||
|
if not asset_list:
|
||||||
|
return {}
|
||||||
|
version_ids = [asset.current_version_id for asset in asset_list if asset.current_version_id]
|
||||||
|
if len(version_ids) != len(asset_list):
|
||||||
|
raise FileStorageError("File has no current version")
|
||||||
|
|
||||||
|
rows: list[tuple[FileVersion, FileBlob]] = []
|
||||||
|
for chunk in _chunks(version_ids):
|
||||||
|
rows.extend(
|
||||||
|
session.query(FileVersion, FileBlob)
|
||||||
|
.join(FileBlob, FileBlob.id == FileVersion.blob_id)
|
||||||
|
.filter(FileVersion.id.in_(chunk))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_version_id = {version.id: (version, blob) for version, blob in rows}
|
||||||
|
result: dict[str, tuple[FileVersion, FileBlob]] = {}
|
||||||
|
for asset in asset_list:
|
||||||
|
version_blob = by_version_id.get(asset.current_version_id or "")
|
||||||
|
if not version_blob:
|
||||||
|
raise FileStorageError("File version not found")
|
||||||
|
result[asset.id] = version_blob
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]:
|
def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]:
|
||||||
version, blob = current_version_and_blob(session, asset)
|
version, blob = current_version_and_blob(session, asset)
|
||||||
backend = get_storage_backend()
|
backend = get_storage_backend()
|
||||||
@@ -292,23 +519,10 @@ def share_file(
|
|||||||
raise FileStorageError("Unsupported share target")
|
raise FileStorageError("Unsupported share target")
|
||||||
if permission not in {"read", "write", "manage"}:
|
if permission not in {"read", "write", "manage"}:
|
||||||
raise FileStorageError("Unsupported file permission")
|
raise FileStorageError("Unsupported file permission")
|
||||||
if target_type == "user":
|
if target_type in {"user", "group", "tenant"}:
|
||||||
target_user = session.get(User, target_id)
|
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
|
||||||
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
|
|
||||||
raise FileStorageError("User not found")
|
|
||||||
if target_type == "group":
|
|
||||||
group = session.get(Group, target_id)
|
|
||||||
if not group or group.tenant_id != tenant_id or not group.is_active:
|
|
||||||
raise FileStorageError("Group not found")
|
|
||||||
if target_type == "tenant":
|
|
||||||
tenant = session.get(Tenant, target_id)
|
|
||||||
if target_id != tenant_id or not tenant or not tenant.is_active:
|
|
||||||
raise FileStorageError("Tenant not found")
|
|
||||||
if target_type == "campaign":
|
if target_type == "campaign":
|
||||||
Campaign = _campaign_model()
|
_ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
|
||||||
campaign = session.get(Campaign, target_id)
|
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
|
||||||
raise FileStorageError("Campaign not found")
|
|
||||||
existing = (
|
existing = (
|
||||||
session.query(FileShare)
|
session.query(FileShare)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -352,23 +566,10 @@ def share_files(
|
|||||||
raise FileStorageError("Unsupported share target")
|
raise FileStorageError("Unsupported share target")
|
||||||
if permission not in {"read", "write", "manage"}:
|
if permission not in {"read", "write", "manage"}:
|
||||||
raise FileStorageError("Unsupported file permission")
|
raise FileStorageError("Unsupported file permission")
|
||||||
if target_type == "user":
|
if target_type in {"user", "group", "tenant"}:
|
||||||
target_user = session.get(User, target_id)
|
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
|
||||||
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
|
|
||||||
raise FileStorageError("User not found")
|
|
||||||
if target_type == "group":
|
|
||||||
group = session.get(Group, target_id)
|
|
||||||
if not group or group.tenant_id != tenant_id or not group.is_active:
|
|
||||||
raise FileStorageError("Group not found")
|
|
||||||
if target_type == "tenant":
|
|
||||||
tenant = session.get(Tenant, target_id)
|
|
||||||
if target_id != tenant_id or not tenant or not tenant.is_active:
|
|
||||||
raise FileStorageError("Tenant not found")
|
|
||||||
if target_type == "campaign":
|
if target_type == "campaign":
|
||||||
Campaign = _campaign_model()
|
_ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
|
||||||
campaign = session.get(Campaign, target_id)
|
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
|
||||||
raise FileStorageError("Campaign not found")
|
|
||||||
|
|
||||||
asset_list = list(assets)
|
asset_list = list(assets)
|
||||||
if not asset_list:
|
if not asset_list:
|
||||||
@@ -437,6 +638,11 @@ def _asset_owner_id(asset: FileAsset) -> str:
|
|||||||
raise FileStorageError("File has no valid owner")
|
raise FileStorageError("File has no valid owner")
|
||||||
|
|
||||||
|
|
||||||
|
def _chunks(values: list[str], size: int = 900):
|
||||||
|
for index in range(0, len(values), size):
|
||||||
|
yield values[index:index + size]
|
||||||
|
|
||||||
|
|
||||||
def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool:
|
def _active_asset_exists(session: Session, *, tenant_id: str, owner_type: str, owner_id: str, path: str, exclude_asset_id: str | None = None) -> bool:
|
||||||
return _active_asset_at_path(
|
return _active_asset_at_path(
|
||||||
session,
|
session,
|
||||||
@@ -543,6 +749,39 @@ def _normalize_conflict_strategy(strategy: str | None) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _next_version_number(session: Session, asset_id: str) -> int:
|
||||||
|
row = (
|
||||||
|
session.query(FileVersion.version_number)
|
||||||
|
.filter(FileVersion.file_asset_id == asset_id)
|
||||||
|
.order_by(FileVersion.version_number.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return (int(row[0]) if row else 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _source_identity(provenance: dict[str, Any] | None) -> tuple[object, ...] | None:
|
||||||
|
if not provenance:
|
||||||
|
return None
|
||||||
|
connector_id = _clean_identity(provenance.get("connector_id"))
|
||||||
|
provider = _clean_identity(provenance.get("provider"))
|
||||||
|
external_id = _clean_identity(provenance.get("external_id"))
|
||||||
|
if connector_id and external_id:
|
||||||
|
return ("external_id", connector_id, provider, external_id)
|
||||||
|
external_path = _clean_identity(provenance.get("external_path"))
|
||||||
|
metadata = provenance.get("metadata") if isinstance(provenance.get("metadata"), dict) else {}
|
||||||
|
library_id = _clean_identity(metadata.get("library_id") or metadata.get("profile_id") or metadata.get("share"))
|
||||||
|
if connector_id and external_path:
|
||||||
|
return ("external_path", connector_id, provider, library_id, external_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_identity(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
def _copy_asset_to_path(
|
def _copy_asset_to_path(
|
||||||
session: Session,
|
session: Session,
|
||||||
asset: FileAsset,
|
asset: FileAsset,
|
||||||
|
|||||||
@@ -68,13 +68,63 @@ def list_folders_for_user(
|
|||||||
include_deleted: bool = False,
|
include_deleted: bool = False,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
) -> list[FileFolder]:
|
) -> list[FileFolder]:
|
||||||
|
query = _folder_visibility_query_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
return query.order_by(FileFolder.path.asc(), FileFolder.id.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def list_folders_for_user_window(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
page_size: int,
|
||||||
|
after_path: str | None = None,
|
||||||
|
after_id: str | None = None,
|
||||||
|
) -> tuple[list[FileFolder], bool]:
|
||||||
|
query = _folder_visibility_query_for_user(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
include_deleted=include_deleted,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
if after_path is not None and after_id:
|
||||||
|
query = query.filter(or_(FileFolder.path > after_path, (FileFolder.path == after_path) & (FileFolder.id > after_id)))
|
||||||
|
rows = query.order_by(FileFolder.path.asc(), FileFolder.id.asc()).limit(page_size + 1).all()
|
||||||
|
return rows[:page_size], len(rows) > page_size
|
||||||
|
|
||||||
|
|
||||||
|
def _folder_visibility_query_for_user(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
include_deleted: bool = False,
|
||||||
|
is_admin: bool = False,
|
||||||
|
):
|
||||||
owner_type = owner_type.lower().strip()
|
owner_type = owner_type.lower().strip()
|
||||||
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type, owner_id=owner_id, user_id=user_id, is_admin=is_admin)
|
||||||
query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type)
|
query = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_type == owner_type)
|
||||||
query = _owner_filter(query, owner_type, owner_id)
|
query = _owner_filter(query, owner_type, owner_id)
|
||||||
if not include_deleted:
|
if not include_deleted:
|
||||||
query = query.filter(FileFolder.deleted_at.is_(None))
|
query = query.filter(FileFolder.deleted_at.is_(None))
|
||||||
return query.order_by(FileFolder.path.asc()).all()
|
return query
|
||||||
|
|
||||||
|
|
||||||
def soft_delete_folder(
|
def soft_delete_folder(
|
||||||
|
|||||||
90
src/govoplan_files/backend/storage/provenance.py
Normal file
90
src/govoplan_files/backend/storage/provenance.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_PROVENANCE_METADATA_KEY = "source_provenance"
|
||||||
|
SOURCE_REVISION_METADATA_KEY = "source_revision"
|
||||||
|
|
||||||
|
_PROVENANCE_STRING_FIELDS = {
|
||||||
|
"source_type",
|
||||||
|
"connector_id",
|
||||||
|
"provider",
|
||||||
|
"external_id",
|
||||||
|
"external_path",
|
||||||
|
"external_url",
|
||||||
|
"revision",
|
||||||
|
"revision_label",
|
||||||
|
"observed_at",
|
||||||
|
"imported_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_source_revision(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_source_provenance(value: object, *, source_revision: object = None) -> dict[str, Any] | None:
|
||||||
|
if value is None:
|
||||||
|
revision = normalize_source_revision(source_revision)
|
||||||
|
return {"revision": revision} if revision else None
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise ValueError("source_provenance must be a JSON object")
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
for field in _PROVENANCE_STRING_FIELDS:
|
||||||
|
normalized = normalize_source_revision(value.get(field))
|
||||||
|
if normalized:
|
||||||
|
payload[field] = normalized
|
||||||
|
|
||||||
|
extra = value.get("metadata")
|
||||||
|
if isinstance(extra, Mapping) and extra:
|
||||||
|
payload["metadata"] = dict(extra)
|
||||||
|
elif extra is not None and extra != "":
|
||||||
|
raise ValueError("source_provenance.metadata must be a JSON object")
|
||||||
|
|
||||||
|
revision = normalize_source_revision(source_revision)
|
||||||
|
if revision:
|
||||||
|
payload["revision"] = revision
|
||||||
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
|
def source_metadata(
|
||||||
|
*,
|
||||||
|
existing: Mapping[str, Any] | None = None,
|
||||||
|
source_provenance: object = None,
|
||||||
|
source_revision: object = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
payload = dict(existing or {})
|
||||||
|
normalized_provenance = normalize_source_provenance(source_provenance, source_revision=source_revision)
|
||||||
|
normalized_revision = normalize_source_revision(source_revision)
|
||||||
|
if normalized_provenance:
|
||||||
|
payload[SOURCE_PROVENANCE_METADATA_KEY] = normalized_provenance
|
||||||
|
if normalized_revision:
|
||||||
|
payload[SOURCE_REVISION_METADATA_KEY] = normalized_revision
|
||||||
|
elif normalized_provenance and normalized_provenance.get("revision"):
|
||||||
|
payload[SOURCE_REVISION_METADATA_KEY] = str(normalized_provenance["revision"])
|
||||||
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
|
def source_provenance_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(metadata, Mapping):
|
||||||
|
return None
|
||||||
|
value = metadata.get(SOURCE_PROVENANCE_METADATA_KEY)
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return dict(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def source_revision_from_metadata(metadata: Mapping[str, Any] | None) -> str | None:
|
||||||
|
if not isinstance(metadata, Mapping):
|
||||||
|
return None
|
||||||
|
revision = normalize_source_revision(metadata.get(SOURCE_REVISION_METADATA_KEY))
|
||||||
|
if revision:
|
||||||
|
return revision
|
||||||
|
provenance = source_provenance_from_metadata(metadata)
|
||||||
|
return normalize_source_revision(provenance.get("revision") if provenance else None)
|
||||||
@@ -33,6 +33,7 @@ from govoplan_files.backend.storage.files import (
|
|||||||
asset_is_audit_relevant,
|
asset_is_audit_relevant,
|
||||||
create_file_asset,
|
create_file_asset,
|
||||||
current_version_and_blob,
|
current_version_and_blob,
|
||||||
|
current_versions_and_blobs,
|
||||||
get_asset_for_user,
|
get_asset_for_user,
|
||||||
list_assets_for_user,
|
list_assets_for_user,
|
||||||
read_asset_bytes,
|
read_asset_bytes,
|
||||||
@@ -50,7 +51,7 @@ from govoplan_files.backend.storage.folders import (
|
|||||||
soft_delete_folder,
|
soft_delete_folder,
|
||||||
)
|
)
|
||||||
from govoplan_files.backend.storage.search import match_assets, resolve_patterns
|
from govoplan_files.backend.storage.search import match_assets, resolve_patterns
|
||||||
from govoplan_files.backend.storage.transfers import build_rename_preview, rename_selection, transfer_selection
|
from govoplan_files.backend.storage.transfers import build_rename_preview, legacy_rename_selection_without_owner_context, rename_selection, transfer_selection
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FileConflictResolution",
|
"FileConflictResolution",
|
||||||
@@ -64,6 +65,7 @@ __all__ = [
|
|||||||
"create_folder",
|
"create_folder",
|
||||||
"create_zip_file",
|
"create_zip_file",
|
||||||
"current_version_and_blob",
|
"current_version_and_blob",
|
||||||
|
"current_versions_and_blobs",
|
||||||
"ensure_group_access",
|
"ensure_group_access",
|
||||||
"ensure_owner_access",
|
"ensure_owner_access",
|
||||||
"extract_zip_upload",
|
"extract_zip_upload",
|
||||||
@@ -74,6 +76,7 @@ __all__ = [
|
|||||||
"match_assets",
|
"match_assets",
|
||||||
"read_asset_bytes",
|
"read_asset_bytes",
|
||||||
"record_campaign_attachment_uses_for_job",
|
"record_campaign_attachment_uses_for_job",
|
||||||
|
"legacy_rename_selection_without_owner_context",
|
||||||
"rename_asset",
|
"rename_asset",
|
||||||
"rename_selection",
|
"rename_selection",
|
||||||
"resolve_patterns",
|
"resolve_patterns",
|
||||||
|
|||||||
@@ -69,17 +69,13 @@ def transfer_selection(
|
|||||||
if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"):
|
if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"):
|
||||||
raise FileStorageError("Cannot move a folder into itself or one of its child folders")
|
raise FileStorageError("Cannot move a folder into itself or one of its child folders")
|
||||||
|
|
||||||
assets_by_id: dict[str, FileAsset] = {}
|
assets_by_id = _selected_assets_for_owner(
|
||||||
for file_id in file_ids:
|
|
||||||
asset = get_asset_for_user(
|
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user_id,
|
owner_type=source_owner_type,
|
||||||
asset_id=file_id,
|
owner_id=source_owner_id,
|
||||||
require_write=operation == "move",
|
file_ids=file_ids,
|
||||||
is_admin=is_admin,
|
|
||||||
)
|
)
|
||||||
assets_by_id[asset.id] = asset
|
|
||||||
|
|
||||||
folder_asset_targets: dict[str, str] = {}
|
folder_asset_targets: dict[str, str] = {}
|
||||||
folder_target_paths: dict[str, str] = {}
|
folder_target_paths: dict[str, str] = {}
|
||||||
@@ -297,6 +293,33 @@ def _collapse_folder_roots(folder_paths: list[str]) -> list[str]:
|
|||||||
return collapsed
|
return collapsed
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_assets_for_owner(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
file_ids: list[str],
|
||||||
|
) -> dict[str, FileAsset]:
|
||||||
|
selected_file_ids = list(dict.fromkeys(file_ids))
|
||||||
|
if not selected_file_ids:
|
||||||
|
return {}
|
||||||
|
assets = {
|
||||||
|
asset.id: asset
|
||||||
|
for asset in _asset_query_for_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
.filter(FileAsset.id.in_(selected_file_ids), FileAsset.deleted_at.is_(None))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
if len(assets) != len(selected_file_ids):
|
||||||
|
raise FileStorageError("File not found")
|
||||||
|
return assets
|
||||||
|
|
||||||
|
|
||||||
def _path_under_root(path: str, root: str) -> bool:
|
def _path_under_root(path: str, root: str) -> bool:
|
||||||
normalized = normalize_logical_path(path)
|
normalized = normalize_logical_path(path)
|
||||||
return normalized == root or normalized.startswith(f"{root}/")
|
return normalized == root or normalized.startswith(f"{root}/")
|
||||||
@@ -318,7 +341,7 @@ def _file_new_path_for_root(path: str, root: str, new_root: str, *, recursive: b
|
|||||||
return normalize_logical_path(f"{new_root}/{relative}")
|
return normalize_logical_path(f"{new_root}/{relative}")
|
||||||
|
|
||||||
|
|
||||||
def rename_selection(
|
def _rename_selection(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -336,6 +359,7 @@ def rename_selection(
|
|||||||
recursive: bool = False,
|
recursive: bool = False,
|
||||||
dry_run: bool = True,
|
dry_run: bool = True,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
|
allow_legacy_without_owner_context: bool = False,
|
||||||
) -> list[RenamePlanItem]:
|
) -> list[RenamePlanItem]:
|
||||||
mode = mode.lower().strip()
|
mode = mode.lower().strip()
|
||||||
if mode not in {"direct", "prefix", "suffix", "replace"}:
|
if mode not in {"direct", "prefix", "suffix", "replace"}:
|
||||||
@@ -349,13 +373,28 @@ def rename_selection(
|
|||||||
if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
|
if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
|
||||||
raise FileStorageError("Direct rename requires exactly one selected item")
|
raise FileStorageError("Direct rename requires exactly one selected item")
|
||||||
|
|
||||||
|
owner_type_norm = owner_type.lower().strip() if owner_type else None
|
||||||
|
owner_id_norm = owner_id
|
||||||
assets: dict[str, FileAsset] = {}
|
assets: dict[str, FileAsset] = {}
|
||||||
|
if selected_file_ids:
|
||||||
|
if owner_type_norm and owner_id_norm:
|
||||||
|
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin)
|
||||||
|
assets.update(
|
||||||
|
_selected_assets_for_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_type=owner_type_norm,
|
||||||
|
owner_id=owner_id_norm,
|
||||||
|
file_ids=selected_file_ids,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif allow_legacy_without_owner_context:
|
||||||
for file_id in selected_file_ids:
|
for file_id in selected_file_ids:
|
||||||
asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin)
|
asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin)
|
||||||
assets[asset.id] = asset
|
assets[asset.id] = asset
|
||||||
|
else:
|
||||||
|
raise FileStorageError("Bulk rename requires an owner file space")
|
||||||
|
|
||||||
owner_type_norm = owner_type.lower().strip() if owner_type else None
|
|
||||||
owner_id_norm = owner_id
|
|
||||||
folder_rows_by_path: dict[str, FileFolder] = {}
|
folder_rows_by_path: dict[str, FileFolder] = {}
|
||||||
affected_folder_paths: set[str] = set()
|
affected_folder_paths: set[str] = set()
|
||||||
if selected_folder_roots and owner_type_norm and owner_id_norm:
|
if selected_folder_roots and owner_type_norm and owner_id_norm:
|
||||||
@@ -444,3 +483,80 @@ def rename_selection(
|
|||||||
rename_asset(assets[asset_id], new_path=new_path)
|
rename_asset(assets[asset_id], new_path=new_path)
|
||||||
session.add(assets[asset_id])
|
session.add(assets[asset_id])
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def rename_selection(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
file_ids: list[str],
|
||||||
|
folder_paths: list[str],
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
mode: str,
|
||||||
|
new_name: str | None = None,
|
||||||
|
find: str | None = None,
|
||||||
|
replacement: str = "",
|
||||||
|
prefix: str = "",
|
||||||
|
suffix: str = "",
|
||||||
|
recursive: bool = False,
|
||||||
|
dry_run: bool = True,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> list[RenamePlanItem]:
|
||||||
|
return _rename_selection(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
file_ids=file_ids,
|
||||||
|
folder_paths=folder_paths,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
mode=mode,
|
||||||
|
new_name=new_name,
|
||||||
|
find=find,
|
||||||
|
replacement=replacement,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
recursive=recursive,
|
||||||
|
dry_run=dry_run,
|
||||||
|
is_admin=is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_rename_selection_without_owner_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
file_ids: list[str],
|
||||||
|
mode: str,
|
||||||
|
new_name: str | None = None,
|
||||||
|
find: str | None = None,
|
||||||
|
replacement: str = "",
|
||||||
|
prefix: str = "",
|
||||||
|
suffix: str = "",
|
||||||
|
dry_run: bool = True,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> list[RenamePlanItem]:
|
||||||
|
"""Compatibility path for historical file-only callers that lack owner scope."""
|
||||||
|
|
||||||
|
return _rename_selection(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
file_ids=file_ids,
|
||||||
|
folder_paths=[],
|
||||||
|
owner_type=None,
|
||||||
|
owner_id=None,
|
||||||
|
mode=mode,
|
||||||
|
new_name=new_name,
|
||||||
|
find=find,
|
||||||
|
replacement=replacement,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
recursive=False,
|
||||||
|
dry_run=dry_run,
|
||||||
|
is_admin=is_admin,
|
||||||
|
allow_legacy_without_owner_context=True,
|
||||||
|
)
|
||||||
|
|||||||
101
tests/test_access_provider.py
Normal file
101
tests/test_access_provider.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_files.backend.capabilities import FilesAccessService, virtual_folder_resource_id
|
||||||
|
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ID = "tenant-1"
|
||||||
|
USER_ID = "user-1"
|
||||||
|
OTHER_USER_ID = "user-2"
|
||||||
|
GROUP_ID = "group-1"
|
||||||
|
|
||||||
|
|
||||||
|
class FilesAccessProviderTests(unittest.TestCase):
|
||||||
|
def test_file_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
owned = FileAsset(id="file-owned", tenant_id=TENANT_ID, owner_type="user", owner_user_id=USER_ID, display_path="owned.pdf", filename="owned.pdf")
|
||||||
|
shared = FileAsset(id="file-shared", tenant_id=TENANT_ID, owner_type="user", owner_user_id=OTHER_USER_ID, display_path="shared.pdf", filename="shared.pdf")
|
||||||
|
session.add_all([
|
||||||
|
owned,
|
||||||
|
shared,
|
||||||
|
FileShare(id="share-group", tenant_id=TENANT_ID, file_asset_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = FilesAccessService()
|
||||||
|
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id=owned.id, action="files:file:read")
|
||||||
|
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="file", resource_id=shared.id, action="files:file:read")
|
||||||
|
admin_items = service.explain_resource_provenance(session, _principal(scopes={"files:file:admin"}), resource_type="file", resource_id=shared.id, action="files:file:read")
|
||||||
|
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="file", resource_id="missing-file", action="files:file:read")
|
||||||
|
|
||||||
|
self.assertTrue(any(item.kind == "resource" and item.source == "files.file" and item.id == owned.id for item in owned_items))
|
||||||
|
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
|
||||||
|
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
|
||||||
|
self.assertTrue(any(item.kind == "policy" and item.id == "files:file:admin" for item in admin_items))
|
||||||
|
self.assertEqual("files.not_found", missing_items[0].source)
|
||||||
|
self.assertIs(missing_items[0].details["found"], False)
|
||||||
|
|
||||||
|
def test_file_access_provider_explains_persisted_and_virtual_folders(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
persisted = FileFolder(id="folder-persisted", tenant_id=TENANT_ID, owner_type="group", owner_group_id=GROUP_ID, path="records")
|
||||||
|
virtual_child = FileAsset(
|
||||||
|
id="file-in-virtual-folder",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_type="group",
|
||||||
|
owner_group_id=GROUP_ID,
|
||||||
|
display_path="inferred/sub/file.pdf",
|
||||||
|
filename="file.pdf",
|
||||||
|
)
|
||||||
|
session.add_all([persisted, virtual_child])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = FilesAccessService()
|
||||||
|
principal = _principal(group_ids={GROUP_ID})
|
||||||
|
persisted_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=persisted.id, action="files:file:read")
|
||||||
|
virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/sub")
|
||||||
|
virtual_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=virtual_id, action="files:file:read")
|
||||||
|
missing_virtual_id = virtual_folder_resource_id(tenant_id=TENANT_ID, owner_type="group", owner_id=GROUP_ID, path="inferred/missing")
|
||||||
|
missing_items = service.explain_resource_provenance(session, principal, resource_type="folder", resource_id=missing_virtual_id, action="files:file:read")
|
||||||
|
|
||||||
|
self.assertTrue(any(item.kind == "resource" and item.source == "files.folder" and item.id == persisted.id for item in persisted_items))
|
||||||
|
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in persisted_items))
|
||||||
|
self.assertTrue(any(item.kind == "resource" and item.source == "files.virtual_folder" and item.id == virtual_id for item in virtual_items))
|
||||||
|
self.assertTrue(any(item.kind == "owner" and item.id == GROUP_ID for item in virtual_items))
|
||||||
|
self.assertEqual("files.not_found", missing_items[0].source)
|
||||||
|
|
||||||
|
|
||||||
|
def _session():
|
||||||
|
engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, FileAsset.__table__, FileFolder.__table__, FileShare.__table__])
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_access_subjects(session) -> None:
|
||||||
|
session.add_all([
|
||||||
|
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
|
||||||
|
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
|
||||||
|
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
|
||||||
|
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
|
||||||
|
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
|
||||||
|
return PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id=USER_ID,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
scopes=frozenset(scopes or {"files:file:read"}),
|
||||||
|
group_ids=frozenset(group_ids or set()),
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.2",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"@govoplan/core-webui": "^0.1.2"
|
"@govoplan/core-webui": "^0.1.8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui";
|
import { ApiError, apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings, type DeltaDeletedItem, type FilesManagedFileLinkTarget } from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type FileSpace = {
|
export type FileSpace = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,6 +6,14 @@ export type FileSpace = {
|
|||||||
owner_type: "user" | "group";
|
owner_type: "user" | "group";
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
space_type?: "managed" | "connector";
|
||||||
|
connector_space_id?: string | null;
|
||||||
|
connector_profile_id?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
library_id?: string | null;
|
||||||
|
remote_path?: string | null;
|
||||||
|
sync_mode?: string | null;
|
||||||
|
read_only?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileShare = {
|
export type FileShare = {
|
||||||
@@ -17,6 +25,250 @@ export type FileShare = {
|
|||||||
revoked_at?: string | null;
|
revoked_at?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileSourceProvenance = {
|
||||||
|
source_type?: string | null;
|
||||||
|
connector_id?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
|
external_id?: string | null;
|
||||||
|
external_path?: string | null;
|
||||||
|
external_url?: string | null;
|
||||||
|
revision?: string | null;
|
||||||
|
revision_label?: string | null;
|
||||||
|
observed_at?: string | null;
|
||||||
|
imported_at?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorPolicySource = {
|
||||||
|
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
policy: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorPolicyDecision = {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string | null;
|
||||||
|
source_path: unknown[];
|
||||||
|
requirements: string[];
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorPolicyStep = {
|
||||||
|
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
applied_fields: string[];
|
||||||
|
policy: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorPolicyResponse = {
|
||||||
|
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
policy: Record<string, unknown>;
|
||||||
|
effective_policy?: Record<string, unknown> | null;
|
||||||
|
parent_policy?: Record<string, unknown> | null;
|
||||||
|
effective_policy_sources?: FileConnectorPolicyStep[];
|
||||||
|
parent_policy_sources?: FileConnectorPolicyStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorProfile = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: string;
|
||||||
|
endpoint_url?: string | null;
|
||||||
|
base_path?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
source_path: string;
|
||||||
|
credential_profile_id?: string | null;
|
||||||
|
credential_profile_label?: string | null;
|
||||||
|
credential_mode: string;
|
||||||
|
credential_source?: string | null;
|
||||||
|
credentials_configured: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
policy_sources: FileConnectorPolicySource[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
source_kind?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorCredential = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
scope_type: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
source_path: string;
|
||||||
|
credential_mode: string;
|
||||||
|
credential_secret_source?: string | null;
|
||||||
|
credentials_configured: boolean;
|
||||||
|
username?: string | null;
|
||||||
|
policy_sources: FileConnectorPolicySource[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
source_kind?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorCredentialsPayload = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
token?: string | null;
|
||||||
|
password_env?: string | null;
|
||||||
|
token_env?: string | null;
|
||||||
|
secret_ref?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorDiscoveryCandidate = {
|
||||||
|
endpoint_url: string;
|
||||||
|
status: "failed" | "usable" | "found" | "credentials_rejected";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorDiscoveryPayload = {
|
||||||
|
provider: FileConnectorProfilePayload["provider"];
|
||||||
|
endpoint_url: string;
|
||||||
|
base_path?: string | null;
|
||||||
|
credential_mode?: FileConnectorProfilePayload["credential_mode"];
|
||||||
|
credentials?: FileConnectorCredentialsPayload;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
require_valid_credentials?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorDiscoveryResponse = {
|
||||||
|
provider: string;
|
||||||
|
endpoint_url?: string | null;
|
||||||
|
base_path?: string | null;
|
||||||
|
status: "usable" | "found" | "credentials_rejected" | "not_found" | "unsupported";
|
||||||
|
message: string;
|
||||||
|
candidates: FileConnectorDiscoveryCandidate[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorProfilePayload = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: "seafile" | "nextcloud" | "webdav" | "smb" | "nfs" | "dms" | "generic";
|
||||||
|
endpoint_url?: string | null;
|
||||||
|
base_path?: string | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
credential_profile_id?: string | null;
|
||||||
|
credential_mode?: "none" | "anonymous" | "basic" | "token" | "secret_ref";
|
||||||
|
credentials?: FileConnectorCredentialsPayload;
|
||||||
|
capabilities?: string[];
|
||||||
|
policy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorProfileUpdatePayload = Partial<Omit<FileConnectorProfilePayload, "id" | "scope_type" | "scope_id">> & {
|
||||||
|
clear_password?: boolean;
|
||||||
|
clear_token?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorCredentialPayload = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider?: "seafile" | "nextcloud" | "webdav" | "smb" | "nfs" | "dms" | "generic" | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
credential_mode?: "none" | "anonymous" | "basic" | "token" | "secret_ref";
|
||||||
|
credentials?: FileConnectorCredentialsPayload;
|
||||||
|
policy?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorCredentialUpdatePayload = Partial<Omit<FileConnectorCredentialPayload, "id" | "scope_type" | "scope_id">> & {
|
||||||
|
clear_password?: boolean;
|
||||||
|
clear_token?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorBrowseItem = {
|
||||||
|
kind: "library" | "folder" | "file";
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
external_id?: string | null;
|
||||||
|
external_url?: string | null;
|
||||||
|
size_bytes?: number | null;
|
||||||
|
content_type?: string | null;
|
||||||
|
modified_at?: string | null;
|
||||||
|
etag?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorBrowseResponse = {
|
||||||
|
profile_id: string;
|
||||||
|
provider: string;
|
||||||
|
path: string;
|
||||||
|
library_id?: string | null;
|
||||||
|
read_only: boolean;
|
||||||
|
decision: FileConnectorPolicyDecision;
|
||||||
|
items: FileConnectorBrowseItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorProvider = {
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
protocol: string;
|
||||||
|
implemented: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
browse_supported: boolean;
|
||||||
|
import_supported: boolean;
|
||||||
|
optional_dependency?: string | null;
|
||||||
|
permission_model: string;
|
||||||
|
sync_strategy: string;
|
||||||
|
conflict_strategy: string;
|
||||||
|
preview_strategy: string;
|
||||||
|
audit_events: string[];
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorSpace = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
owner_type: "user" | "group";
|
||||||
|
owner_id: string;
|
||||||
|
label: string;
|
||||||
|
connector_profile_id: string;
|
||||||
|
provider: string;
|
||||||
|
library_id?: string | null;
|
||||||
|
remote_path: string;
|
||||||
|
sync_mode: string;
|
||||||
|
read_only: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorSettingsDeltaResponse = {
|
||||||
|
profiles: FileConnectorProfile[];
|
||||||
|
credentials: FileConnectorCredential[];
|
||||||
|
spaces: FileConnectorSpace[];
|
||||||
|
policy?: FileConnectorPolicyResponse | null;
|
||||||
|
changed_sections?: string[];
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileConnectorSpacePayload = {
|
||||||
|
owner_type?: "user" | "group";
|
||||||
|
owner_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
connector_profile_id: string;
|
||||||
|
library_id?: string | null;
|
||||||
|
remote_path?: string;
|
||||||
|
sync_mode?: "manual";
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ManagedFile = {
|
export type ManagedFile = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
@@ -34,13 +286,43 @@ export type ManagedFile = {
|
|||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
audit_relevant: boolean;
|
audit_relevant: boolean;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
|
source_provenance?: FileSourceProvenance | null;
|
||||||
|
source_revision?: string | null;
|
||||||
shares?: FileShare[];
|
shares?: FileShare[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileListResponse = { files: ManagedFile[] };
|
export type FileListResponse = {files: ManagedFile[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
|
||||||
export type FileSpacesResponse = { spaces: FileSpace[] };
|
export type FileDeltaDeletedItem = {id: string;resource_type?: string | null;revision?: string | null;deleted_at?: string | null;};
|
||||||
export type FileUploadResponse = { files: ManagedFile[] };
|
export type FileDeltaResponse = {
|
||||||
export type FileUploadProgress = { loaded: number; total?: number; percentage: number | null };
|
files: ManagedFile[];
|
||||||
|
folders: FileFolder[];
|
||||||
|
deleted: FileDeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
export type ManagedFileSnapshotResponse = {files: ManagedFile[];folders: FileFolder[];watermark?: string | null;};
|
||||||
|
export type FileSpacesResponse = {spaces: FileSpace[];};
|
||||||
|
export type FileUploadResponse = {files: ManagedFile[];};
|
||||||
|
export type FileUploadProgress = {loaded: number;total?: number;percentage: number | null;};
|
||||||
|
export type FileConnectorFilePayload = {
|
||||||
|
library_id: string;
|
||||||
|
path: string;
|
||||||
|
owner_type: "user" | "group";
|
||||||
|
owner_id?: string | null;
|
||||||
|
target_folder?: string | null;
|
||||||
|
target_path?: string | null;
|
||||||
|
campaign_id?: string | null;
|
||||||
|
conflict_strategy?: ConflictStrategy;
|
||||||
|
source_revision?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type FileConnectorSyncResponse = {
|
||||||
|
file: ManagedFile;
|
||||||
|
action: "created" | "updated" | "unchanged";
|
||||||
|
previous_version_id?: string | null;
|
||||||
|
current_version_id: string;
|
||||||
|
};
|
||||||
export type FileFolder = {
|
export type FileFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
@@ -51,16 +333,18 @@ export type FileFolder = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
};
|
};
|
||||||
export type FileFoldersResponse = { folders: FileFolder[] };
|
export type FileFoldersResponse = {folders: FileFolder[];cursor?: string | null;next_cursor?: string | null;watermark?: string | null;};
|
||||||
export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number };
|
|
||||||
export type BulkDeleteResponse = { deleted_count: number };
|
const DEFAULT_MANAGED_FILE_WINDOW_SIZE = 500;
|
||||||
export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] };
|
export type FolderDeleteResponse = {deleted_folders: number;deleted_files: number;};
|
||||||
export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number };
|
export type BulkDeleteResponse = {deleted_count: number;};
|
||||||
|
export type RenameResponse = {dry_run: boolean;items: {kind: "file" | "folder";id: string;file_id?: string | null;folder_path?: string | null;old_path: string;new_path: string;}[];};
|
||||||
|
export type TransferResponse = {operation: "move" | "copy";files: number;folders: number;};
|
||||||
export type ConflictAction = "overwrite" | "rename" | "skip";
|
export type ConflictAction = "overwrite" | "rename" | "skip";
|
||||||
export type ConflictStrategy = "reject" | "overwrite" | "rename";
|
export type ConflictStrategy = "reject" | "overwrite" | "rename";
|
||||||
export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string };
|
export type ConflictResolution = {target_path: string;action: ConflictAction;new_path?: string;};
|
||||||
export type PatternResolveResponse = {
|
export type PatternResolveResponse = {
|
||||||
patterns: { pattern: string; matches: ManagedFile[] }[];
|
patterns: {pattern: string;matches: ManagedFile[];}[];
|
||||||
unmatched: ManagedFile[];
|
unmatched: ManagedFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,36 +354,124 @@ export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesRespons
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
|
export function listFolders(settings: ApiSettings, params: {owner_type: "user" | "group";owner_id: string;page_size?: number;cursor?: string | null;}): Promise<FileFoldersResponse> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
search.set("owner_type", params.owner_type);
|
search.set("owner_type", params.owner_type);
|
||||||
search.set("owner_id", params.owner_id);
|
search.set("owner_id", params.owner_id);
|
||||||
|
if (params.page_size) search.set("page_size", String(params.page_size));
|
||||||
|
if (params.cursor) search.set("cursor", params.cursor);
|
||||||
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
|
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFolder(
|
export function createFolder(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: { owner_type: "user" | "group"; owner_id: string; path: string }
|
payload: {owner_type: "user" | "group";owner_id: string;path: string;})
|
||||||
): Promise<FileFolder> {
|
: Promise<FileFolder> {
|
||||||
return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteFolder(
|
export function deleteFolder(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean }
|
payload: {owner_type: "user" | "group";owner_id: string;path: string;recursive?: boolean;})
|
||||||
): Promise<FolderDeleteResponse> {
|
: Promise<FolderDeleteResponse> {
|
||||||
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
|
export function listFiles(settings: ApiSettings, params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;page_size?: number;cursor?: string | null;} = {}): Promise<FileListResponse> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
if (value) search.set(key, value);
|
if (value) search.set(key, String(value));
|
||||||
}
|
}
|
||||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listFilesDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {owner_type?: string;owner_id?: string;campaign_id?: string;path_prefix?: string;since?: string;limit?: number;} = {})
|
||||||
|
: Promise<FileDeltaResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileDeltaResponse>(settings, `/api/v1/files/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDeltaToSnapshot(
|
||||||
|
files: ManagedFile[],
|
||||||
|
folders: FileFolder[],
|
||||||
|
response: FileDeltaResponse)
|
||||||
|
: {files: ManagedFile[];folders: FileFolder[];} {
|
||||||
|
if (response.full) return { files: response.files, folders: response.folders };
|
||||||
|
const deletedFileIds = new Set(response.deleted.filter((item) => !item.resource_type || item.resource_type === "file").map((item) => item.id));
|
||||||
|
const deletedFolderIds = new Set(response.deleted.filter((item) => item.resource_type === "folder").map((item) => item.id));
|
||||||
|
const filesById = new Map(files.map((file) => [file.id, file]));
|
||||||
|
const foldersById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||||
|
deletedFileIds.forEach((id) => filesById.delete(id));
|
||||||
|
deletedFolderIds.forEach((id) => foldersById.delete(id));
|
||||||
|
response.files.forEach((file) => filesById.set(file.id, file));
|
||||||
|
response.folders.forEach((folder) => foldersById.set(folder.id, folder));
|
||||||
|
return { files: Array.from(filesById.values()), folders: Array.from(foldersById.values()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listManagedFileSnapshot(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {owner_type: "user" | "group";owner_id: string;path_prefix?: string;page_size?: number;})
|
||||||
|
: Promise<ManagedFileSnapshotResponse> {
|
||||||
|
const pageSize = params.page_size ?? DEFAULT_MANAGED_FILE_WINDOW_SIZE;
|
||||||
|
let watermark: string | null | undefined = null;
|
||||||
|
let folderCursor: string | null | undefined = null;
|
||||||
|
let folders: FileFolder[] = [];
|
||||||
|
do {
|
||||||
|
const response = await listFolders(settings, {
|
||||||
|
owner_type: params.owner_type,
|
||||||
|
owner_id: params.owner_id,
|
||||||
|
page_size: pageSize,
|
||||||
|
cursor: folderCursor
|
||||||
|
});
|
||||||
|
watermark = watermark || response.watermark;
|
||||||
|
folders = folders.concat(response.folders);
|
||||||
|
folderCursor = response.next_cursor;
|
||||||
|
} while (folderCursor);
|
||||||
|
|
||||||
|
let fileCursor: string | null | undefined = null;
|
||||||
|
let files: ManagedFile[] = [];
|
||||||
|
do {
|
||||||
|
const response = await listFiles(settings, {
|
||||||
|
owner_type: params.owner_type,
|
||||||
|
owner_id: params.owner_id,
|
||||||
|
path_prefix: params.path_prefix,
|
||||||
|
page_size: pageSize,
|
||||||
|
cursor: fileCursor
|
||||||
|
});
|
||||||
|
watermark = watermark || response.watermark;
|
||||||
|
files = files.concat(response.files);
|
||||||
|
fileCursor = response.next_cursor;
|
||||||
|
} while (fileCursor);
|
||||||
|
|
||||||
|
if (watermark) {
|
||||||
|
let since = watermark;
|
||||||
|
let response: FileDeltaResponse | null = null;
|
||||||
|
do {
|
||||||
|
response = await listFilesDelta(settings, {
|
||||||
|
owner_type: params.owner_type,
|
||||||
|
owner_id: params.owner_id,
|
||||||
|
path_prefix: params.path_prefix,
|
||||||
|
since,
|
||||||
|
limit: pageSize
|
||||||
|
});
|
||||||
|
const snapshot = applyDeltaToSnapshot(files, folders, response);
|
||||||
|
files = snapshot.files;
|
||||||
|
folders = snapshot.folders;
|
||||||
|
since = response.watermark || "";
|
||||||
|
} while (response.has_more && response.watermark);
|
||||||
|
watermark = since || watermark;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, folders, watermark };
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadFiles(
|
export async function uploadFiles(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
files: File[],
|
files: File[],
|
||||||
@@ -111,9 +483,12 @@ export async function uploadFiles(
|
|||||||
unpack_zip?: boolean;
|
unpack_zip?: boolean;
|
||||||
conflict_strategy?: ConflictStrategy;
|
conflict_strategy?: ConflictStrategy;
|
||||||
conflict_resolutions?: ConflictResolution[];
|
conflict_resolutions?: ConflictResolution[];
|
||||||
|
source_provenance?: FileSourceProvenance;
|
||||||
|
source_revision?: string;
|
||||||
|
connector_policy_sources?: FileConnectorPolicySource[];
|
||||||
onProgress?: (progress: FileUploadProgress) => void;
|
onProgress?: (progress: FileUploadProgress) => void;
|
||||||
}
|
})
|
||||||
): Promise<FileUploadResponse> {
|
: Promise<FileUploadResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
files.forEach((file) => form.append("files", file));
|
files.forEach((file) => form.append("files", file));
|
||||||
form.append("owner_type", options.owner_type);
|
form.append("owner_type", options.owner_type);
|
||||||
@@ -123,6 +498,9 @@ export async function uploadFiles(
|
|||||||
if (options.unpack_zip) form.append("unpack_zip", "true");
|
if (options.unpack_zip) form.append("unpack_zip", "true");
|
||||||
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
|
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
|
||||||
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
|
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
|
||||||
|
if (options.source_provenance) form.append("source_provenance_json", JSON.stringify(options.source_provenance));
|
||||||
|
if (options.source_revision) form.append("source_revision", options.source_revision);
|
||||||
|
if (options.connector_policy_sources?.length) form.append("connector_policy_json", JSON.stringify({ sources: options.connector_policy_sources }));
|
||||||
if (options.onProgress) return uploadFilesWithProgress(settings, form, options.onProgress);
|
if (options.onProgress) return uploadFilesWithProgress(settings, form, options.onProgress);
|
||||||
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
|
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
|
||||||
}
|
}
|
||||||
@@ -130,8 +508,8 @@ export async function uploadFiles(
|
|||||||
function uploadFilesWithProgress(
|
function uploadFilesWithProgress(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
form: FormData,
|
form: FormData,
|
||||||
onProgress: (progress: FileUploadProgress) => void
|
onProgress: (progress: FileUploadProgress) => void)
|
||||||
): Promise<FileUploadResponse> {
|
: Promise<FileUploadResponse> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
|
xhr.open("POST", apiUrl(settings, "/api/v1/files/upload"));
|
||||||
@@ -145,12 +523,12 @@ function uploadFilesWithProgress(
|
|||||||
onProgress({
|
onProgress({
|
||||||
loaded: event.loaded,
|
loaded: event.loaded,
|
||||||
total,
|
total,
|
||||||
percentage: total && total > 0 ? Math.round((event.loaded / total) * 100) : null
|
percentage: total && total > 0 ? Math.round(event.loaded / total * 100) : null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => reject(new Error("Upload failed because the network request could not be completed."));
|
xhr.onerror = () => reject(new Error("i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3"));
|
||||||
xhr.onabort = () => reject(new Error("Upload was cancelled."));
|
xhr.onabort = () => reject(new Error("i18n:govoplan-files.upload_was_cancelled.5bab0f9b"));
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
const responseText = xhr.responseText || "";
|
const responseText = xhr.responseText || "";
|
||||||
if (xhr.status < 200 || xhr.status >= 300) {
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
@@ -179,26 +557,304 @@ export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promi
|
|||||||
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
|
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
|
export type FileBulkShareResponse = {shares: FileShare[];shared_count: number;};
|
||||||
|
|
||||||
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
|
export type AccessExplanationUser = {
|
||||||
|
id: string;
|
||||||
|
account_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessDecisionProvenanceItem = {
|
||||||
|
kind: string;
|
||||||
|
id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
tenant_id?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResourceAccessExplanationResponse = {
|
||||||
|
user: AccessExplanationUser;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
action: string;
|
||||||
|
provenance: AccessDecisionProvenanceItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchResourceAccessExplanation(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {userId: string;resourceType: string;resourceId: string;action: string;tenantId?: string | null;})
|
||||||
|
: Promise<ResourceAccessExplanationResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
user_id: options.userId,
|
||||||
|
resource_type: options.resourceType,
|
||||||
|
resource_id: options.resourceId,
|
||||||
|
action: options.action
|
||||||
|
});
|
||||||
|
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||||
|
return apiFetch<ResourceAccessExplanationResponse>(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function virtualFolderResourceId(options: {tenantId: string;ownerType: "user" | "group";ownerId: string;path: string;}): string {
|
||||||
|
return `virtual-folder:v1:${options.tenantId}:${options.ownerType}:${options.ownerId}:${base64Url(options.path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Url(value: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(value);
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shareFilesWithTarget(settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget): Promise<FileBulkShareResponse> {
|
||||||
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
|
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" })
|
body: JSON.stringify({ file_ids: fileIds, target_type: target.type, target_id: target.id, permission: target.permission ?? "read" })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function evaluateFileConnectorPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {source_provenance: FileSourceProvenance;operation?: string;policy_sources?: FileConnectorPolicySource[];})
|
||||||
|
: Promise<{decision: FileConnectorPolicyDecision;}> {
|
||||||
|
return apiFetch<{decision: FileConnectorPolicyDecision;}>(settings, "/api/v1/files/connector-policy/evaluate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ operation: "access", policy_sources: [], ...payload })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileConnectorPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scopeType: "system" | "tenant" | "user" | "group" | "campaign",
|
||||||
|
scopeId?: string | null)
|
||||||
|
: Promise<FileConnectorPolicyResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (scopeId) search.set("scope_id", scopeId);
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileConnectorPolicyResponse>(settings, `/api/v1/files/connectors/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchFileConnectorSettingsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {
|
||||||
|
scope_type?: "system" | "tenant" | "user" | "group" | "campaign";
|
||||||
|
scope_id?: string | null;
|
||||||
|
provider?: string;
|
||||||
|
campaign_id?: string | null;
|
||||||
|
include_disabled?: boolean;
|
||||||
|
include_inactive?: boolean;
|
||||||
|
owner_type?: "user" | "group";
|
||||||
|
owner_id?: string;
|
||||||
|
since?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
} = {})
|
||||||
|
: Promise<FileConnectorSettingsDeltaResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.scope_type) search.set("scope_type", params.scope_type);
|
||||||
|
if (params.scope_id) search.set("scope_id", params.scope_id);
|
||||||
|
if (params.provider) search.set("provider", params.provider);
|
||||||
|
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
|
||||||
|
if (params.include_disabled) search.set("include_disabled", "true");
|
||||||
|
if (params.include_inactive) search.set("include_inactive", "true");
|
||||||
|
if (params.owner_type) search.set("owner_type", params.owner_type);
|
||||||
|
if (params.owner_id) search.set("owner_id", params.owner_id);
|
||||||
|
if (params.since) search.set("since", params.since);
|
||||||
|
if (params.limit) search.set("limit", String(params.limit));
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileConnectorSettingsDeltaResponse>(settings, `/api/v1/files/connectors/settings/delta${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFileConnectorPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
scopeType: "system" | "tenant" | "user" | "group" | "campaign",
|
||||||
|
policy: Record<string, unknown>,
|
||||||
|
scopeId?: string | null)
|
||||||
|
: Promise<FileConnectorPolicyResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (scopeId) search.set("scope_id", scopeId);
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileConnectorPolicyResponse>(settings, `/api/v1/files/connectors/policies/${encodeURIComponent(scopeType)}${suffix}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ policy })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFileConnectorProfiles(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {provider?: string;campaign_id?: string;include_disabled?: boolean;} = {})
|
||||||
|
: Promise<{profiles: FileConnectorProfile[];}> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.provider) search.set("provider", params.provider);
|
||||||
|
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
|
||||||
|
if (params.include_disabled) search.set("include_disabled", "true");
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<{profiles: FileConnectorProfile[];}>(settings, `/api/v1/files/connectors/profiles${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFileConnectorProviders(settings: ApiSettings): Promise<{providers: FileConnectorProvider[];}> {
|
||||||
|
return apiFetch<{providers: FileConnectorProvider[];}>(settings, "/api/v1/files/connectors/providers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverFileConnectorEndpoint(settings: ApiSettings, payload: FileConnectorDiscoveryPayload): Promise<FileConnectorDiscoveryResponse> {
|
||||||
|
return apiFetch<FileConnectorDiscoveryResponse>(settings, "/api/v1/files/connectors/discover", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFileConnectorCredentials(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {provider?: string;include_disabled?: boolean;} = {})
|
||||||
|
: Promise<{credentials: FileConnectorCredential[];}> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.provider) search.set("provider", params.provider);
|
||||||
|
if (params.include_disabled) search.set("include_disabled", "true");
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<{credentials: FileConnectorCredential[];}>(settings, `/api/v1/files/connectors/credentials${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileConnectorCredential(settings: ApiSettings, payload: FileConnectorCredentialPayload): Promise<FileConnectorCredential> {
|
||||||
|
return apiFetch<FileConnectorCredential>(settings, "/api/v1/files/connectors/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFileConnectorCredential(
|
||||||
|
settings: ApiSettings,
|
||||||
|
credentialId: string,
|
||||||
|
payload: FileConnectorCredentialUpdatePayload)
|
||||||
|
: Promise<FileConnectorCredential> {
|
||||||
|
return apiFetch<FileConnectorCredential>(settings, `/api/v1/files/connectors/credentials/${encodeURIComponent(credentialId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateFileConnectorCredential(settings: ApiSettings, credentialId: string): Promise<FileConnectorCredential> {
|
||||||
|
return apiFetch<FileConnectorCredential>(settings, `/api/v1/files/connectors/credentials/${encodeURIComponent(credentialId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFileConnectorSpaces(
|
||||||
|
settings: ApiSettings,
|
||||||
|
params: {owner_type?: "user" | "group";owner_id?: string;include_inactive?: boolean;} = {})
|
||||||
|
: Promise<{spaces: FileConnectorSpace[];}> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.owner_type) search.set("owner_type", params.owner_type);
|
||||||
|
if (params.owner_id) search.set("owner_id", params.owner_id);
|
||||||
|
if (params.include_inactive) search.set("include_inactive", "true");
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<{spaces: FileConnectorSpace[];}>(settings, `/api/v1/files/connector-spaces${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileConnectorSpace(settings: ApiSettings, payload: FileConnectorSpacePayload): Promise<FileConnectorSpace> {
|
||||||
|
return apiFetch<FileConnectorSpace>(settings, "/api/v1/files/connector-spaces", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ owner_type: "user", remote_path: "", sync_mode: "manual", metadata: {}, ...payload })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFileConnectorSpace(
|
||||||
|
settings: ApiSettings,
|
||||||
|
spaceId: string,
|
||||||
|
payload: Partial<Omit<FileConnectorSpacePayload, "connector_profile_id" | "owner_type" | "owner_id">> & {is_active?: boolean;})
|
||||||
|
: Promise<FileConnectorSpace> {
|
||||||
|
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFileConnectorSpace(settings: ApiSettings, spaceId: string): Promise<FileConnectorSpace> {
|
||||||
|
return apiFetch<FileConnectorSpace>(settings, `/api/v1/files/connector-spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileConnectorProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
params: {campaign_id?: string;include_disabled?: boolean;} = {})
|
||||||
|
: Promise<FileConnectorProfile> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
|
||||||
|
if (params.include_disabled) search.set("include_disabled", "true");
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileConnectorProfile(settings: ApiSettings, payload: FileConnectorProfilePayload): Promise<FileConnectorProfile> {
|
||||||
|
return apiFetch<FileConnectorProfile>(settings, "/api/v1/files/connectors/profiles", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFileConnectorProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
payload: FileConnectorProfileUpdatePayload)
|
||||||
|
: Promise<FileConnectorProfile> {
|
||||||
|
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivateFileConnectorProfile(settings: ApiSettings, profileId: string): Promise<FileConnectorProfile> {
|
||||||
|
return apiFetch<FileConnectorProfile>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browseFileConnectorProfile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
params: {path?: string;library_id?: string;campaign_id?: string;} = {})
|
||||||
|
: Promise<FileConnectorBrowseResponse> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.path) search.set("path", params.path);
|
||||||
|
if (params.library_id) search.set("library_id", params.library_id);
|
||||||
|
if (params.campaign_id) search.set("campaign_id", params.campaign_id);
|
||||||
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
|
return apiFetch<FileConnectorBrowseResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/browse${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFileConnectorFile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
payload: FileConnectorFilePayload)
|
||||||
|
: Promise<FileUploadResponse> {
|
||||||
|
return apiFetch<FileUploadResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ conflict_strategy: "reject", metadata: {}, ...payload })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncFileConnectorFile(
|
||||||
|
settings: ApiSettings,
|
||||||
|
profileId: string,
|
||||||
|
payload: FileConnectorFilePayload)
|
||||||
|
: Promise<FileConnectorSyncResponse> {
|
||||||
|
return apiFetch<FileConnectorSyncResponse>(settings, `/api/v1/files/connectors/profiles/${encodeURIComponent(profileId)}/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ conflict_strategy: "rename", metadata: {}, ...payload })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
|
||||||
|
return shareFilesWithTarget(settings, fileIds, { type: "campaign", id: campaignId, label: "campaign" });
|
||||||
|
}
|
||||||
|
|
||||||
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
|
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
|
||||||
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
|
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
|
||||||
const share = response.shares[0];
|
const share = response.shares[0];
|
||||||
if (!share) throw new Error("File share was not created.");
|
if (!share) throw new Error("i18n:govoplan-files.file_share_was_not_created.033ac476");
|
||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bulkRenameFiles(
|
export function bulkRenameFiles(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean }
|
payload: {file_ids: string[];folder_paths?: string[];owner_type: "user" | "group";owner_id: string;mode: "direct" | "prefix" | "suffix" | "replace";new_name?: string;find?: string;replacement?: string;prefix?: string;suffix?: string;recursive?: boolean;dry_run?: boolean;})
|
||||||
): Promise<RenameResponse> {
|
: Promise<RenameResponse> {
|
||||||
return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", {
|
return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload })
|
body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload })
|
||||||
@@ -207,8 +863,8 @@ export function bulkRenameFiles(
|
|||||||
|
|
||||||
export function resolveFilePatterns(
|
export function resolveFilePatterns(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
|
payload: {patterns: string[];owner_type?: "user" | "group";owner_id?: string;campaign_id?: string;path_prefix?: string;include_unmatched?: boolean;case_sensitive?: boolean;})
|
||||||
): Promise<PatternResolveResponse> {
|
: Promise<PatternResolveResponse> {
|
||||||
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,8 +881,8 @@ export function transferFiles(
|
|||||||
target_folder: string;
|
target_folder: string;
|
||||||
conflict_strategy?: ConflictStrategy;
|
conflict_strategy?: ConflictStrategy;
|
||||||
conflict_resolutions?: ConflictResolution[];
|
conflict_resolutions?: ConflictResolution[];
|
||||||
}
|
})
|
||||||
): Promise<TransferResponse> {
|
: Promise<TransferResponse> {
|
||||||
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1717
webui/src/features/files/FileConnectorSettingsPanel.tsx
Normal file
1717
webui/src/features/files/FileConnectorSettingsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||||
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
|
import { Copy, Download, FolderOpen, Home, KeyRound, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
|
||||||
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
|
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } from "@govoplan/core-webui";
|
||||||
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
|
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
|
||||||
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
|
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
|
||||||
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||||
@@ -11,28 +11,28 @@ export function TransferFolderSelector({
|
|||||||
selectedFolder,
|
selectedFolder,
|
||||||
onSelect,
|
onSelect,
|
||||||
disabled
|
disabled
|
||||||
}: {
|
|
||||||
space: FileSpace | null;
|
|
||||||
nodes: FolderNode[];
|
|
||||||
selectedFolder: string;
|
|
||||||
onSelect: (folderPath: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
}: {space: FileSpace | null;nodes: FolderNode[];selectedFolder: string;onSelect: (folderPath: string) => void;disabled?: boolean;}) {
|
||||||
return (
|
return (
|
||||||
<div className="file-folder-selector" role="tree" aria-label="Destination folder">
|
<div className="file-folder-selector" role="tree" aria-label="i18n:govoplan-files.destination_folder.f8ccb63b">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
|
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
|
||||||
onClick={() => onSelect("")}
|
onClick={() => onSelect("")}
|
||||||
disabled={disabled || !space}
|
disabled={disabled || !space}>
|
||||||
>
|
|
||||||
<Home size={15} aria-hidden="true" />
|
<Home size={15} aria-hidden="true" />
|
||||||
<span>{space?.label || "Root"}</span>
|
<span>{space?.label || "i18n:govoplan-files.root.e96857c5"}</span>
|
||||||
</button>
|
</button>
|
||||||
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>}
|
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d</span>}
|
||||||
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
|
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransferFolderSelectorNodes({
|
export function TransferFolderSelectorNodes({
|
||||||
@@ -41,33 +41,33 @@ export function TransferFolderSelectorNodes({
|
|||||||
onSelect,
|
onSelect,
|
||||||
disabled,
|
disabled,
|
||||||
depth = 1
|
depth = 1
|
||||||
}: {
|
|
||||||
nodes: FolderNode[];
|
|
||||||
selectedFolder: string;
|
|
||||||
onSelect: (folderPath: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
depth?: number;
|
|
||||||
}) {
|
}: {nodes: FolderNode[];selectedFolder: string;onSelect: (folderPath: string) => void;disabled?: boolean;depth?: number;}) {
|
||||||
if (nodes.length === 0) return null;
|
if (nodes.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="file-folder-selector-children">
|
<div className="file-folder-selector-children">
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) =>
|
||||||
<div key={node.path}>
|
<div key={node.path}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
|
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
|
||||||
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
|
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
|
||||||
onClick={() => onSelect(node.path)}
|
onClick={() => onSelect(node.path)}
|
||||||
disabled={disabled}
|
disabled={disabled}>
|
||||||
>
|
|
||||||
<FolderOpen size={15} aria-hidden="true" />
|
<FolderOpen size={15} aria-hidden="true" />
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
|
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FolderTree({
|
export function FolderTree({
|
||||||
@@ -90,27 +90,27 @@ export function FolderTree({
|
|||||||
contextMenuEnabled = true,
|
contextMenuEnabled = true,
|
||||||
disabled,
|
disabled,
|
||||||
depth = 1
|
depth = 1
|
||||||
}: {
|
|
||||||
nodes: FolderNode[];
|
|
||||||
activeSpaceId: string;
|
|
||||||
spaceId: string;
|
|
||||||
currentFolder: string;
|
|
||||||
dropTargetKey?: string;
|
|
||||||
expandedKeys: Set<string>;
|
|
||||||
onOpen: (spaceId: string, path: string) => void;
|
|
||||||
onToggle: (spaceId: string, path: string) => void;
|
|
||||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
|
||||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
|
||||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
|
||||||
onClearDropState?: () => void;
|
|
||||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
|
||||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
|
||||||
onDragEndFolder?: () => void;
|
|
||||||
dragDropEnabled?: boolean;
|
|
||||||
contextMenuEnabled?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
depth?: number;
|
|
||||||
}) {
|
}: {nodes: FolderNode[];activeSpaceId: string;spaceId: string;currentFolder: string;dropTargetKey?: string;expandedKeys: Set<string>;onOpen: (spaceId: string, path: string) => void;onToggle: (spaceId: string, path: string) => void;onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;onClearDropState?: () => void;onRequestDragExpand?: (spaceId: string, path: string) => void;onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;onDragEndFolder?: () => void;dragDropEnabled?: boolean;contextMenuEnabled?: boolean;disabled?: boolean;depth?: number;}) {
|
||||||
return (
|
return (
|
||||||
<ExplorerTree
|
<ExplorerTree
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
@@ -138,9 +138,9 @@ export function FolderTree({
|
|||||||
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
|
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
|
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
|
||||||
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
|
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined} />);
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RenamePreviewList({
|
export function RenamePreviewList({
|
||||||
@@ -151,23 +151,23 @@ export function RenamePreviewList({
|
|||||||
visibleCount,
|
visibleCount,
|
||||||
onShowMore,
|
onShowMore,
|
||||||
onShowFewer
|
onShowFewer
|
||||||
}: {
|
|
||||||
response: RenameResponse;
|
|
||||||
selectedFileIds: Set<string>;
|
|
||||||
selectedFolderPaths: Set<string>;
|
|
||||||
recursive: boolean;
|
|
||||||
visibleCount: number;
|
|
||||||
onShowMore: () => void;
|
|
||||||
onShowFewer: () => void;
|
|
||||||
}) {
|
}: {response: RenameResponse;selectedFileIds: Set<string>;selectedFolderPaths: Set<string>;recursive: boolean;visibleCount: number;onShowMore: () => void;onShowFewer: () => void;}) {
|
||||||
const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
|
const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
|
||||||
const hiddenNonRecursiveItems = !recursive
|
const hiddenNonRecursiveItems = !recursive ?
|
||||||
? response.items.filter((item) => {
|
response.items.filter((item) => {
|
||||||
if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
|
if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
|
||||||
if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
|
if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
|
||||||
return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
|
return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
|
||||||
}).length
|
}).length :
|
||||||
: 0;
|
0;
|
||||||
const visibleItems = response.items.filter((item) => {
|
const visibleItems = response.items.filter((item) => {
|
||||||
if (recursive) return true;
|
if (recursive) return true;
|
||||||
if (item.kind === "file") {
|
if (item.kind === "file") {
|
||||||
@@ -182,20 +182,20 @@ export function RenamePreviewList({
|
|||||||
return (
|
return (
|
||||||
<div className="rename-preview-panel">
|
<div className="rename-preview-panel">
|
||||||
<div className="placeholder-stack rename-preview-list">
|
<div className="placeholder-stack rename-preview-list">
|
||||||
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.</span>}
|
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1</span>}
|
||||||
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> → <code>{item.new_path}</code></span>)}
|
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> → <code>{item.new_path}</code></span>)}
|
||||||
{remaining > 0 && (
|
{remaining > 0 &&
|
||||||
<button type="button" className="rename-preview-more" onClick={onShowMore}>… and {remaining} more — show next {Math.min(20, remaining)}</button>
|
<button type="button" className="rename-preview-more" onClick={onShowMore}>i18n:govoplan-files.and.a0d93385 {remaining} i18n:govoplan-files.more_show_next.f1912318 {Math.min(20, remaining)}</button>
|
||||||
)}
|
}
|
||||||
{shownItems.length > 20 && (
|
{shownItems.length > 20 &&
|
||||||
<button type="button" className="rename-preview-more" onClick={onShowFewer}>Show fewer</button>
|
<button type="button" className="rename-preview-more" onClick={onShowFewer}>i18n:govoplan-files.show_fewer.d94dfbe5</button>
|
||||||
)}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
export function FileDialog({ title, onClose, children }: {title: string;onClose: () => void;children: ReactNode;}) {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@@ -205,11 +205,11 @@ export function FileDialog({ title, onClose, children }: { title: string; onClos
|
|||||||
className="file-dialog"
|
className="file-dialog"
|
||||||
headerClassName="file-dialog-header"
|
headerClassName="file-dialog-header"
|
||||||
titleClassName="file-dialog-title"
|
titleClassName="file-dialog-title"
|
||||||
bodyClassName="file-dialog-body"
|
bodyClassName="file-dialog-body">
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Dialog>
|
</Dialog>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileContextMenu({
|
export function FileContextMenu({
|
||||||
@@ -220,29 +220,31 @@ export function FileContextMenu({
|
|||||||
canDownload,
|
canDownload,
|
||||||
canOrganize,
|
canOrganize,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
canExplainAccess,
|
||||||
downloadLabel,
|
downloadLabel,
|
||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
onUpload,
|
onUpload,
|
||||||
onDownload,
|
onDownload,
|
||||||
onMove,
|
onMove,
|
||||||
onCopy,
|
onCopy,
|
||||||
|
onExplainAccess,
|
||||||
onDelete
|
onDelete
|
||||||
}: {
|
|
||||||
menu: ContextMenuState;
|
|
||||||
hasSelection: boolean;
|
|
||||||
canCreateFolder: boolean;
|
|
||||||
canUpload: boolean;
|
|
||||||
canDownload: boolean;
|
|
||||||
canOrganize: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
downloadLabel: string;
|
|
||||||
onCreateFolder: () => void;
|
|
||||||
onUpload: () => void;
|
|
||||||
onDownload: () => void;
|
|
||||||
onMove: () => void;
|
|
||||||
onCopy: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
}: {menu: ContextMenuState;hasSelection: boolean;canCreateFolder: boolean;canUpload: boolean;canDownload: boolean;canOrganize: boolean;canDelete: boolean;canExplainAccess: boolean;downloadLabel: string;onCreateFolder: () => void;onUpload: () => void;onDownload: () => void;onMove: () => void;onCopy: () => void;onExplainAccess: () => void;onDelete: () => void;}) {
|
||||||
const showNewFolder = true;
|
const showNewFolder = true;
|
||||||
const showDelete = menu.target !== "empty";
|
const showDelete = menu.target !== "empty";
|
||||||
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
|
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
|
||||||
@@ -251,19 +253,20 @@ export function FileContextMenu({
|
|||||||
const estimatedHeight = 260;
|
const estimatedHeight = 260;
|
||||||
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
|
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
|
||||||
const openUp = menu.y + estimatedHeight > viewportHeight;
|
const openUp = menu.y + estimatedHeight > viewportHeight;
|
||||||
const style: CSSProperties = openUp
|
const style: CSSProperties = openUp ?
|
||||||
? { left, bottom: Math.max(8, viewportHeight - menu.y) }
|
{ left, bottom: Math.max(8, viewportHeight - menu.y) } :
|
||||||
: { left, top: Math.max(8, menu.y) };
|
{ left, top: Math.max(8, menu.y) };
|
||||||
return (
|
return (
|
||||||
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
|
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
|
||||||
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> New folder</button>}
|
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> i18n:govoplan-files.new_folder.a711999b</button>}
|
||||||
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> Upload</button>
|
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</button>
|
||||||
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
|
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
|
||||||
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> Move…</button>
|
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
|
||||||
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> Copy…</button>
|
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
|
||||||
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> Delete</button>}
|
<button type="button" role="menuitem" onClick={onExplainAccess} disabled={!canExplainAccess}><KeyRound size={15} aria-hidden="true" /> i18n:govoplan-files.explain_access.4d5fac37</button>
|
||||||
</div>
|
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
|
||||||
);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileConflictDialog({
|
export function FileConflictDialog({
|
||||||
@@ -276,60 +279,60 @@ export function FileConflictDialog({
|
|||||||
onReview,
|
onReview,
|
||||||
onApplyReview,
|
onApplyReview,
|
||||||
onUpdateItem
|
onUpdateItem
|
||||||
}: {
|
|
||||||
state: ConflictDialogState;
|
|
||||||
busy: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onOverwrite: () => void;
|
|
||||||
onRename: () => void;
|
|
||||||
onReview: () => void;
|
|
||||||
onApplyReview: () => void;
|
|
||||||
onUpdateItem: (id: string, patch: Partial<FileConflictItem>) => void;
|
|
||||||
}) {
|
}: {state: ConflictDialogState;busy: boolean;onClose: () => void;onCancel: () => void;onOverwrite: () => void;onRename: () => void;onReview: () => void;onApplyReview: () => void;onUpdateItem: (id: string, patch: Partial<FileConflictItem>) => void;}) {
|
||||||
return (
|
return (
|
||||||
<FileDialog title={state.title} onClose={onClose}>
|
<FileDialog title={state.title} onClose={onClose}>
|
||||||
<p className="muted">{state.message}</p>
|
<p className="muted">{state.message}</p>
|
||||||
<div className="file-conflict-summary">
|
<div className="file-conflict-summary">
|
||||||
<strong>{state.items.length} conflict(s)</strong>
|
<strong>{state.items.length} i18n:govoplan-files.conflict_s.60cea6d5</strong>
|
||||||
<span>{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}</span>
|
<span>{state.items.length > 1 ? "i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f" : "i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6"}</span>
|
||||||
</div>
|
</div>
|
||||||
{state.review && (
|
{state.review &&
|
||||||
<div className="file-conflict-list">
|
<div className="file-conflict-list">
|
||||||
{state.items.map((item) => (
|
{state.items.map((item) =>
|
||||||
<div className="file-conflict-row" key={item.id}>
|
<div className="file-conflict-row" key={item.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{item.label}</strong>
|
<strong>{item.label}</strong>
|
||||||
<small>{item.kind === "folder" ? "Folder" : "File"} conflict at <code>{item.targetPath}</code></small>
|
<small>{item.kind === "folder" ? "i18n:govoplan-files.folder.30baa249" : "i18n:govoplan-files.file.2c3cafa4"} i18n:govoplan-files.conflict_at.0a91052f <code>{item.targetPath}</code></small>
|
||||||
</div>
|
</div>
|
||||||
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
|
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
|
||||||
<option value="overwrite">Overwrite</option>
|
<option value="overwrite">i18n:govoplan-files.overwrite.0625c54e</option>
|
||||||
<option value="rename">Rename</option>
|
<option value="rename">i18n:govoplan-files.rename.d3f4cb89</option>
|
||||||
<option value="skip">Skip</option>
|
<option value="skip">i18n:govoplan-files.skip.3da47453</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
value={item.newPath}
|
value={item.newPath}
|
||||||
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
|
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
|
||||||
disabled={busy || item.action !== "rename"}
|
disabled={busy || item.action !== "rename"}
|
||||||
aria-label={`New path for ${item.label}`}
|
aria-label={i18nMessage("i18n:govoplan-files.new_path_for_value.a9a30c1c", { value0: item.label })} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!state.review && (
|
</div>
|
||||||
|
}
|
||||||
|
{!state.review &&
|
||||||
<div className="placeholder-stack">
|
<div className="placeholder-stack">
|
||||||
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
|
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
|
||||||
{state.items.length > 8 && <span>… and {state.items.length - 8} more</span>}
|
{state.items.length > 8 && <span>i18n:govoplan-files.and.a0d93385 {state.items.length - 8} more</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
<div className="button-row compact-actions align-end">
|
<div className="button-row compact-actions align-end">
|
||||||
<Button onClick={onCancel} disabled={busy}>Cancel</Button>
|
<Button onClick={onCancel} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
|
||||||
<Button onClick={onOverwrite} disabled={busy}>Overwrite all</Button>
|
<Button onClick={onOverwrite} disabled={busy}>i18n:govoplan-files.overwrite_all.a76f2d04</Button>
|
||||||
<Button onClick={onRename} disabled={busy}>Rename all</Button>
|
<Button onClick={onRename} disabled={busy}>i18n:govoplan-files.rename_all.1d6b24dc</Button>
|
||||||
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>Review individually</Button>}
|
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>i18n:govoplan-files.review_individually.19abbe58</Button>}
|
||||||
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>Apply reviewed choices</Button>}
|
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>i18n:govoplan-files.apply_reviewed_choices.bb55f7b3</Button>}
|
||||||
</div>
|
</div>
|
||||||
</FileDialog>
|
</FileDialog>);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
1051
webui/src/features/files/components/ManagedFileChooser.tsx
Normal file
1051
webui/src/features/files/components/ManagedFileChooser.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ export type RenameMode = "prefix" | "suffix" | "replace";
|
|||||||
export type SortColumn = "name" | "size" | "modified";
|
export type SortColumn = "name" | "size" | "modified";
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
export type TransferMode = "move" | "copy";
|
export type TransferMode = "move" | "copy";
|
||||||
export type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
|
export type DialogKind = "upload" | "connector-sync" | "connector-space" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
|
||||||
export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
|
export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
|
||||||
export type ContextMenuState = {
|
export type ContextMenuState = {
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
|
import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
|
||||||
import type { FileFolder, ManagedFile } from "../../../api/files";
|
import type { FileFolder, ManagedFile } from "../../../api/files";
|
||||||
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
|
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
|
||||||
|
|
||||||
|
type FolderStats = {
|
||||||
|
directFiles: number;
|
||||||
|
childFolders: Set<string>;
|
||||||
|
totalSize: number;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
|
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
|
||||||
const byPath = new Map<string, FolderNode>();
|
const byPath = new Map<string, FolderNode>();
|
||||||
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
|
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
|
||||||
@@ -48,7 +55,7 @@ export function treeNodeKey(spaceId: string, folderPath: string): string {
|
|||||||
return `${spaceId}:${normalizeFolder(folderPath)}`;
|
return `${spaceId}:${normalizeFolder(folderPath)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
|
export function folderAncestorPaths(folderPath: string, options: {includeSelf?: boolean;} = {}): string[] {
|
||||||
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
|
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
|
||||||
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
|
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
@@ -71,21 +78,23 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
|
|||||||
const prefix = folder ? `${folder}/` : "";
|
const prefix = folder ? `${folder}/` : "";
|
||||||
const folderMap = new Map<string, FolderEntry>();
|
const folderMap = new Map<string, FolderEntry>();
|
||||||
const directFiles: FileEntry[] = [];
|
const directFiles: FileEntry[] = [];
|
||||||
|
const folderStats = buildFolderStats(files, folders);
|
||||||
|
|
||||||
for (const persisted of folders) {
|
for (const persisted of folders) {
|
||||||
const path = normalizeFolder(persisted.path);
|
const path = normalizeFolder(persisted.path);
|
||||||
if (!path.startsWith(prefix) || path === folder) continue;
|
if (!path.startsWith(prefix) || path === folder) continue;
|
||||||
const relative = prefix ? path.slice(prefix.length) : path;
|
const relative = prefix ? path.slice(prefix.length) : path;
|
||||||
if (!relative || relative.includes("/")) continue;
|
if (!relative || relative.includes("/")) continue;
|
||||||
|
const stats = folderStats.get(path);
|
||||||
folderMap.set(path, {
|
folderMap.set(path, {
|
||||||
kind: "folder",
|
kind: "folder",
|
||||||
id: `folder:${path}`,
|
id: `folder:${path}`,
|
||||||
name: relative,
|
name: relative,
|
||||||
path,
|
path,
|
||||||
fileCount: 0,
|
fileCount: stats?.directFiles ?? 0,
|
||||||
folderCount: 0,
|
folderCount: stats?.childFolders.size ?? 0,
|
||||||
totalSize: 0,
|
totalSize: stats?.totalSize ?? 0,
|
||||||
updatedAt: persisted.updated_at,
|
updatedAt: latestTimestamp(persisted.updated_at, stats?.updatedAt),
|
||||||
persisted: true
|
persisted: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102,31 +111,22 @@ export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[]
|
|||||||
}
|
}
|
||||||
const folderName = relativePath.slice(0, slashIndex);
|
const folderName = relativePath.slice(0, slashIndex);
|
||||||
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
|
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
|
||||||
const existing = folderMap.get(folderPath);
|
if (!folderMap.has(folderPath)) {
|
||||||
if (existing) {
|
const stats = folderStats.get(folderPath);
|
||||||
existing.totalSize += file.size_bytes;
|
|
||||||
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
|
|
||||||
} else {
|
|
||||||
folderMap.set(folderPath, {
|
folderMap.set(folderPath, {
|
||||||
kind: "folder",
|
kind: "folder",
|
||||||
id: `folder:${folderPath}`,
|
id: `folder:${folderPath}`,
|
||||||
name: folderName,
|
name: folderName,
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
fileCount: 0,
|
fileCount: stats?.directFiles ?? 0,
|
||||||
folderCount: 0,
|
folderCount: stats?.childFolders.size ?? 0,
|
||||||
totalSize: file.size_bytes,
|
totalSize: stats?.totalSize ?? file.size_bytes,
|
||||||
updatedAt: file.updated_at,
|
updatedAt: stats?.updatedAt || file.updated_at,
|
||||||
persisted: false
|
persisted: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of folderMap.values()) {
|
|
||||||
const counts = directFolderCounts(files, folders, entry.path);
|
|
||||||
entry.fileCount = counts.files;
|
|
||||||
entry.folderCount = counts.folders;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...Array.from(folderMap.values()), ...directFiles];
|
return [...Array.from(folderMap.values()), ...directFiles];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn
|
|||||||
const factor = direction === "asc" ? 1 : -1;
|
const factor = direction === "asc" ? 1 : -1;
|
||||||
return [...entries].sort((a, b) => {
|
return [...entries].sort((a, b) => {
|
||||||
if (column === "name") {
|
if (column === "name") {
|
||||||
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
|
if (a.kind !== b.kind) return a.kind === "folder" ? direction === "asc" ? -1 : 1 : direction === "asc" ? 1 : -1;
|
||||||
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||||
}
|
}
|
||||||
if (column === "size") {
|
if (column === "size") {
|
||||||
@@ -163,26 +163,56 @@ export function entryModifiedTime(entry: ExplorerEntry): number {
|
|||||||
return Number.isNaN(parsed) ? 0 : parsed;
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
|
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): {files: number;folders: number;} {
|
||||||
const normalized = normalizeFolder(folderPath);
|
const stats = buildFolderStats(files, folders).get(normalizeFolder(folderPath));
|
||||||
const prefix = normalized ? `${normalized}/` : "";
|
return { files: stats?.directFiles ?? 0, folders: stats?.childFolders.size ?? 0 };
|
||||||
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
|
}
|
||||||
const childFolders = new Set<string>();
|
|
||||||
|
function buildFolderStats(files: ManagedFile[], folders: FileFolder[]): Map<string, FolderStats> {
|
||||||
|
const statsByPath = new Map<string, FolderStats>();
|
||||||
|
const ensureStats = (path: string): FolderStats => {
|
||||||
|
const normalized = normalizeFolder(path);
|
||||||
|
const existing = statsByPath.get(normalized);
|
||||||
|
if (existing) return existing;
|
||||||
|
const stats: FolderStats = { directFiles: 0, childFolders: new Set(), totalSize: 0, updatedAt: "" };
|
||||||
|
statsByPath.set(normalized, stats);
|
||||||
|
return stats;
|
||||||
|
};
|
||||||
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const path = normalizeFolder(folder.path);
|
const path = normalizeFolder(folder.path);
|
||||||
if (!path.startsWith(prefix) || path === normalized) continue;
|
if (!path) continue;
|
||||||
const relative = prefix ? path.slice(prefix.length) : path;
|
const parts = path.split("/");
|
||||||
if (!relative) continue;
|
const parentPath = normalizeFolder(parts.slice(0, -1).join("/"));
|
||||||
childFolders.add(relative.split("/")[0]);
|
const name = parts[parts.length - 1];
|
||||||
|
if (name) ensureStats(parentPath).childFolders.add(name);
|
||||||
|
ensureStats(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const path = normalizeFilePath(file.display_path);
|
const parts = normalizeFilePath(file.display_path || file.filename).split("/").filter(Boolean);
|
||||||
if (!path.startsWith(prefix)) continue;
|
if (parts.length === 0) continue;
|
||||||
const relative = prefix ? path.slice(prefix.length) : path;
|
const directParentPath = normalizeFolder(parts.slice(0, -1).join("/"));
|
||||||
if (!relative || !relative.includes("/")) continue;
|
ensureStats(directParentPath).directFiles += 1;
|
||||||
childFolders.add(relative.split("/")[0]);
|
|
||||||
|
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||||
|
const folderPath = parts.slice(0, index + 1).join("/");
|
||||||
|
const stats = ensureStats(folderPath);
|
||||||
|
stats.totalSize += file.size_bytes;
|
||||||
|
stats.updatedAt = latestTimestamp(stats.updatedAt, file.updated_at);
|
||||||
|
|
||||||
|
const parentPath = normalizeFolder(parts.slice(0, index).join("/"));
|
||||||
|
ensureStats(parentPath).childFolders.add(parts[index]);
|
||||||
}
|
}
|
||||||
return { files: directFiles, folders: childFolders.size };
|
}
|
||||||
|
|
||||||
|
return statsByPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTimestamp(current: string, candidate?: string): string {
|
||||||
|
if (!candidate) return current;
|
||||||
|
if (!current) return candidate;
|
||||||
|
return candidate > current ? candidate : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderContentLabel(entry: FolderEntry): string {
|
export function folderContentLabel(entry: FolderEntry): string {
|
||||||
@@ -209,13 +239,14 @@ export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileF
|
|||||||
const sortedDates = childFiles.map((file) => file.updated_at).sort();
|
const sortedDates = childFiles.map((file) => file.updated_at).sort();
|
||||||
const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined;
|
const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined;
|
||||||
const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString();
|
const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString();
|
||||||
|
const counts = directFolderCounts(files, folders, normalizedPath);
|
||||||
return {
|
return {
|
||||||
kind: "folder",
|
kind: "folder",
|
||||||
id: `folder:${normalizedPath}`,
|
id: `folder:${normalizedPath}`,
|
||||||
name: lastPathSegment(normalizedPath) || "Root",
|
name: lastPathSegment(normalizedPath) || "i18n:govoplan-files.root.e96857c5",
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
fileCount: directFolderCounts(files, folders, normalizedPath).files,
|
fileCount: counts.files,
|
||||||
folderCount: directFolderCounts(files, folders, normalizedPath).folders,
|
folderCount: counts.folders,
|
||||||
totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0),
|
totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
persisted: Boolean(persistedFolder)
|
persisted: Boolean(persistedFolder)
|
||||||
@@ -240,7 +271,7 @@ export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set<s
|
|||||||
return Array.from(ids);
|
return Array.from(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderBreadcrumbs(folder: string): { name: string; path: string }[] {
|
export function folderBreadcrumbs(folder: string): {name: string;path: string;}[] {
|
||||||
const parts = normalizeFolder(folder).split("/").filter(Boolean);
|
const parts = normalizeFolder(folder).split("/").filter(Boolean);
|
||||||
return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") }));
|
return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") }));
|
||||||
}
|
}
|
||||||
@@ -287,14 +318,19 @@ export function parseDragState(value: string): DragSelectionState | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(value: number): string {
|
export function formatBytes(value: number): string {
|
||||||
if (value < 1024) return `${value} B`;
|
if (value < 1024) return i18nMessage("i18n:govoplan-files.bytes_b", { value0: value });
|
||||||
const units = ["KB", "MB", "GB", "TB"];
|
const units = [
|
||||||
|
"i18n:govoplan-files.bytes_kb",
|
||||||
|
"i18n:govoplan-files.bytes_mb",
|
||||||
|
"i18n:govoplan-files.bytes_gb",
|
||||||
|
"i18n:govoplan-files.bytes_tb"
|
||||||
|
];
|
||||||
let size = value / 1024;
|
let size = value / 1024;
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
|
if (size < 1024) return i18nMessage(unit, { value0: size.toFixed(size >= 10 ? 0 : 1) });
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
}
|
}
|
||||||
return `${size.toFixed(1)} PB`;
|
return i18nMessage("i18n:govoplan-files.bytes_pb", { value0: size.toFixed(1) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(value: string): string {
|
export function formatDate(value: string): string {
|
||||||
|
|||||||
718
webui/src/i18n/generatedTranslations.ts
Normal file
718
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
"en": {
|
||||||
|
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
|
||||||
|
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
|
||||||
|
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",
|
||||||
|
"i18n:govoplan-files.add_space.e4d674d4": "Add Space",
|
||||||
|
"i18n:govoplan-files.add_suffix_before_extension.784381fe": "Add suffix before extension",
|
||||||
|
"i18n:govoplan-files.added_connector_space_value.a24725b8": "Added connector space {value0}.",
|
||||||
|
"i18n:govoplan-files.allowed_connection_ids.0df854c8": "Allowed connection IDs",
|
||||||
|
"i18n:govoplan-files.allowed_credential_ids.efcaba2d": "Allowed credential IDs",
|
||||||
|
"i18n:govoplan-files.allowed_endpoint_urls.34cfa8e9": "Allowed endpoint URLs",
|
||||||
|
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
|
||||||
|
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
|
||||||
|
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
|
||||||
|
"i18n:govoplan-files.access_explanation.75ee7f62": "Access explanation",
|
||||||
|
"i18n:govoplan-files.action.97c89a4d": "Action",
|
||||||
|
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
|
||||||
|
"i18n:govoplan-files.and.a0d93385": "… and",
|
||||||
|
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
|
||||||
|
"i18n:govoplan-files.any_provider.b3fc542f": "Any provider",
|
||||||
|
"i18n:govoplan-files.apply_preview.8692d5eb": "Apply preview",
|
||||||
|
"i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984": "Apply recursively to folder contents",
|
||||||
|
"i18n:govoplan-files.apply_reviewed_choices.bb55f7b3": "Apply reviewed choices",
|
||||||
|
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
|
||||||
|
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
|
||||||
|
"i18n:govoplan-files.available.974948f2": " · Available",
|
||||||
|
"i18n:govoplan-files.close.bbfa773e": "Close",
|
||||||
|
"i18n:govoplan-files.evidence.8487d192": "Evidence",
|
||||||
|
"i18n:govoplan-files.explain_access.4d5fac37": "Explain access",
|
||||||
|
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Loading access explanation...",
|
||||||
|
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "No access evidence was returned.",
|
||||||
|
"i18n:govoplan-files.no_source.6dcf9723": "No source",
|
||||||
|
"i18n:govoplan-files.owner.89ff3122": "Owner",
|
||||||
|
"i18n:govoplan-files.permission.2f81a22d": "Permission",
|
||||||
|
"i18n:govoplan-files.policy.0b779a05": "Policy",
|
||||||
|
"i18n:govoplan-files.resource.d1c626a9": "Resource",
|
||||||
|
"i18n:govoplan-files.role.b5b4a5a2": "Role",
|
||||||
|
"i18n:govoplan-files.share.09ca55ca": "Share",
|
||||||
|
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
|
||||||
|
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
|
||||||
|
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
|
||||||
|
"i18n:govoplan-files.blocked.99613c74": "Blocked",
|
||||||
|
"i18n:govoplan-files.bootstrap_settings.05e3df38": " · Bootstrap settings",
|
||||||
|
"i18n:govoplan-files.browse_ok_value_item_value.921906fd": "Browse OK ({value0} item{value1}).",
|
||||||
|
"i18n:govoplan-files.browse_protocol.d1f27c90": "Browse protocol",
|
||||||
|
"i18n:govoplan-files.browse.2f3b5c55": "Browse",
|
||||||
|
"i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b": "Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.",
|
||||||
|
"i18n:govoplan-files.bulk_rename_selected_items.5e79d694": "Bulk rename selected items",
|
||||||
|
"i18n:govoplan-files.bulk_rename.7dcaa624": "Bulk rename",
|
||||||
|
"i18n:govoplan-files.bytes_b": "{value0} B",
|
||||||
|
"i18n:govoplan-files.bytes_gb": "{value0} GB",
|
||||||
|
"i18n:govoplan-files.bytes_kb": "{value0} KB",
|
||||||
|
"i18n:govoplan-files.bytes_mb": "{value0} MB",
|
||||||
|
"i18n:govoplan-files.bytes_pb": "{value0} PB",
|
||||||
|
"i18n:govoplan-files.bytes_tb": "{value0} TB",
|
||||||
|
"i18n:govoplan-files.can_use_this_connection.5091a74c": "Can use this connection",
|
||||||
|
"i18n:govoplan-files.can_use_this_credential.f4a41971": "Can use this credential",
|
||||||
|
"i18n:govoplan-files.cancel.77dfd213": "Cancel",
|
||||||
|
"i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea": "Cannot move or copy a folder into itself or one of its child folders.",
|
||||||
|
"i18n:govoplan-files.case_sensitive.c73af7bd": "Case sensitive",
|
||||||
|
"i18n:govoplan-files.checking.494d0f68": "Checking...",
|
||||||
|
"i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb": "Choose a connector file and destination folder.",
|
||||||
|
"i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00": "Choose a connector profile and owner space.",
|
||||||
|
"i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643": "Choose a destination space and folder. Folders are transferred recursively.",
|
||||||
|
"i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f": "Choose how the selected names should be changed.",
|
||||||
|
"i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6": "Choose how to resolve this conflict.",
|
||||||
|
"i18n:govoplan-files.choose_managed_attachment_source.b3709493": "Choose managed attachment source",
|
||||||
|
"i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183": "Choose managed file or pattern",
|
||||||
|
"i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75": "Choose the destination folder before selecting or dropping files.",
|
||||||
|
"i18n:govoplan-files.campaigns": "Campaigns",
|
||||||
|
"i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7": "Choose the folder that should act as this campaign attachment source.",
|
||||||
|
"i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580": "Choose the parent folder for the new subfolder.",
|
||||||
|
"i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f": "Choose the same action for all conflicts or review the target names individually.",
|
||||||
|
"i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e": "Choose the target folder. Folders are transferred recursively.",
|
||||||
|
"i18n:govoplan-files.clear_saved_password.7442260d": "Clear saved password",
|
||||||
|
"i18n:govoplan-files.clear_saved_token.a9c670aa": "Clear saved token",
|
||||||
|
"i18n:govoplan-files.clear.719ea396": "Clear",
|
||||||
|
"i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24": "Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.",
|
||||||
|
"i18n:govoplan-files.collapse.9cf188d3": "Collapse",
|
||||||
|
"i18n:govoplan-files.conflict_at.0a91052f": "conflict at",
|
||||||
|
"i18n:govoplan-files.conflict_s.60cea6d5": "conflict(s)",
|
||||||
|
"i18n:govoplan-files.connection_credential.178babe0": "Connection / credential",
|
||||||
|
"i18n:govoplan-files.connector_files.8f351f5d": "Connector files",
|
||||||
|
"i18n:govoplan-files.connector_folders.960cbb03": "Connector folders",
|
||||||
|
"i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5": "Connector profile is not configured for this space.",
|
||||||
|
"i18n:govoplan-files.connector_profile.9e9cd317": "Connector profile",
|
||||||
|
"i18n:govoplan-files.connector_root.9027235c": "Connector root",
|
||||||
|
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1": "Connector source already matched",
|
||||||
|
"i18n:govoplan-files.connector_space_files.dbb0ab24": "Connector space files",
|
||||||
|
"i18n:govoplan-files.connector.ba358306": "Connector",
|
||||||
|
"i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1": "contained path(s) will move with the selected folder(s), but their own names will stay unchanged.",
|
||||||
|
"i18n:govoplan-files.copied.8e3df45a": "Copied",
|
||||||
|
"i18n:govoplan-files.copy.92556c6d": "Copy…",
|
||||||
|
"i18n:govoplan-files.copy.a69c71d0": " copy",
|
||||||
|
"i18n:govoplan-files.copy.af74f7c5": "Copy",
|
||||||
|
"i18n:govoplan-files.copying.43b7de98": "Copying",
|
||||||
|
"i18n:govoplan-files.copymove.d0fa5904": "copyMove",
|
||||||
|
"i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76": "Create a folder below the selected destination.",
|
||||||
|
"i18n:govoplan-files.create_folder.97bafaba": "Create Folder",
|
||||||
|
"i18n:govoplan-files.create_folder.e59f63fa": "Create folder",
|
||||||
|
"i18n:govoplan-files.create_space.b50606b4": "Create Space",
|
||||||
|
"i18n:govoplan-files.created_folder_value.6c3002f9": "Created folder {value0}.",
|
||||||
|
"i18n:govoplan-files.created_inside.3426a815": "Created inside",
|
||||||
|
"i18n:govoplan-files.credential_id_and_label_are_required.4cfd1ffd": "Credential id and label are required.",
|
||||||
|
"i18n:govoplan-files.credential_id.9432a6e1": "Credential id",
|
||||||
|
"i18n:govoplan-files.credential_mode.23fdd899": "Credential mode",
|
||||||
|
"i18n:govoplan-files.credential.8bede3ea": "Credential",
|
||||||
|
"i18n:govoplan-files.current_file.1fbfcf3d": "current file",
|
||||||
|
"i18n:govoplan-files.current_folder_contents.f9a24fa8": "Current folder contents",
|
||||||
|
"i18n:govoplan-files.current_folder.5aeab2f0": "Current folder",
|
||||||
|
"i18n:govoplan-files.delete_selected_item_s.4b6a0023": "Delete selected item(s)?",
|
||||||
|
"i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f": "Delete {value0}? Audit-relevant blobs remain retained.",
|
||||||
|
"i18n:govoplan-files.delete.f6fdbe48": "Delete",
|
||||||
|
"i18n:govoplan-files.denied_connection_ids.a95218b4": "Denied connection IDs",
|
||||||
|
"i18n:govoplan-files.denied_credential_ids.b6838bc2": "Denied credential IDs",
|
||||||
|
"i18n:govoplan-files.denied_endpoint_urls.1d8d4369": "Denied endpoint URLs",
|
||||||
|
"i18n:govoplan-files.denied_paths.d25e4315": "Denied paths",
|
||||||
|
"i18n:govoplan-files.denied_providers.149b29f4": "Denied providers",
|
||||||
|
"i18n:govoplan-files.deny_listed_paths.fcde62cc": "Deny-listed paths",
|
||||||
|
"i18n:govoplan-files.destination_folder.f8ccb63b": "Destination folder",
|
||||||
|
"i18n:govoplan-files.destination_space.92b63970": "Destination space",
|
||||||
|
"i18n:govoplan-files.disable_file_connection.3fd940ae": "Disable file connection",
|
||||||
|
"i18n:govoplan-files.disable_file_credential.49bff614": "Disable file credential",
|
||||||
|
"i18n:govoplan-files.disable_value_connections_using_it_will_stop_aut.fc3a1708": "Disable {value0}? Connections using it will stop authenticating until another credential is selected.",
|
||||||
|
"i18n:govoplan-files.disable_value_existing_linked_spaces_will_stop_b.8c1b0ac6": "Disable {value0}? Existing linked spaces will stop browsing until the connection is enabled again.",
|
||||||
|
"i18n:govoplan-files.disable_value.09485f7f": "Disable {value0}",
|
||||||
|
"i18n:govoplan-files.disable.9a7d4e06": "Disable",
|
||||||
|
"i18n:govoplan-files.disabled.f4f4473d": "Disabled",
|
||||||
|
"i18n:govoplan-files.dms.477e5652": "DMS",
|
||||||
|
"i18n:govoplan-files.download_zip.7bd0e4b0": "Download ZIP",
|
||||||
|
"i18n:govoplan-files.download.a479c9c3": "Download",
|
||||||
|
"i18n:govoplan-files.e_g_invoices.77a73bd1": "e.g. invoices",
|
||||||
|
"i18n:govoplan-files.edit_file_connection.78698154": "Edit file connection",
|
||||||
|
"i18n:govoplan-files.edit_file_credential.bc49d44f": "Edit file credential",
|
||||||
|
"i18n:govoplan-files.edit_value.fad75899": "Edit {value0}",
|
||||||
|
"i18n:govoplan-files.effective_sources_none.9a7c407f": "Effective sources: none",
|
||||||
|
"i18n:govoplan-files.effective_sources.9a576802": "Effective sources:",
|
||||||
|
"i18n:govoplan-files.enabled.df174a3f": "Enabled",
|
||||||
|
"i18n:govoplan-files.endpoint_url.65aaaa45": "Endpoint URL",
|
||||||
|
"i18n:govoplan-files.endpoint.92ec6350": "Endpoint",
|
||||||
|
"i18n:govoplan-files.expand.9869e506": "Expand",
|
||||||
|
"i18n:govoplan-files.extracting_files_on_the_server.845a3c1a": "Extracting files on the server…",
|
||||||
|
"i18n:govoplan-files.file_actions.9e1b94c5": "File actions",
|
||||||
|
"i18n:govoplan-files.file_connection_created.bdf6e1f6": "File connection created.",
|
||||||
|
"i18n:govoplan-files.file_connection_disabled_value.059a7de9": "File connection disabled: {value0}.",
|
||||||
|
"i18n:govoplan-files.file_connection_saved.68c16ee9": "File connection saved.",
|
||||||
|
"i18n:govoplan-files.file_connections.1e362326": "File connections",
|
||||||
|
"i18n:govoplan-files.file_credential_created.87c31882": "File credential created.",
|
||||||
|
"i18n:govoplan-files.file_credential_disabled_value.947538fd": "File credential disabled: {value0}.",
|
||||||
|
"i18n:govoplan-files.file_credential_saved.d6a1eef8": "File credential saved.",
|
||||||
|
"i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831": "File or pattern relative to",
|
||||||
|
"i18n:govoplan-files.file_s.ca61e97f": "file(s),",
|
||||||
|
"i18n:govoplan-files.file_share_was_not_created.033ac476": "File share was not created.",
|
||||||
|
"i18n:govoplan-files.file_spaces_and_folders.443d2056": "File spaces and folders",
|
||||||
|
"i18n:govoplan-files.file.2c3cafa4": "File",
|
||||||
|
"i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0": "Files with the same visible name already exist in this folder.",
|
||||||
|
"i18n:govoplan-files.files.6ce6c512": "Files",
|
||||||
|
"i18n:govoplan-files.finalizing_upload.bcce936d": "Finalizing upload…",
|
||||||
|
"i18n:govoplan-files.find_and_replace.2c6b404b": "Find and replace",
|
||||||
|
"i18n:govoplan-files.find.df251b06": "Find",
|
||||||
|
"i18n:govoplan-files.first.49ec0838": "first.",
|
||||||
|
"i18n:govoplan-files.folder_name.b2ce023b": "Folder name",
|
||||||
|
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
|
||||||
|
"i18n:govoplan-files.folder.30baa249": "Folder",
|
||||||
|
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
|
||||||
|
"i18n:govoplan-files.folders.19adc47b": "Folders",
|
||||||
|
"i18n:govoplan-files.gb.505a44fa": "GB",
|
||||||
|
"i18n:govoplan-files.generic.ff7613e5": "Generic",
|
||||||
|
"i18n:govoplan-files.govoplan_files.b20752ed": "GovOPlaN files",
|
||||||
|
"i18n:govoplan-files.govoplan_webdav_credentials.bc696d69": "GovOPlaN WebDAV credentials",
|
||||||
|
"i18n:govoplan-files.group.171a0606": "Group",
|
||||||
|
"i18n:govoplan-files.groups": "Groups",
|
||||||
|
"i18n:govoplan-files.import.d6fbc9d2": "Import",
|
||||||
|
"i18n:govoplan-files.inherited.58823af0": "Inherited",
|
||||||
|
"i18n:govoplan-files.kb.315b39a0": "KB",
|
||||||
|
"i18n:govoplan-files.kerberos.53c35fb7": "Kerberos",
|
||||||
|
"i18n:govoplan-files.label.74341e3c": "Label",
|
||||||
|
"i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e": "Leave empty to keep saved password",
|
||||||
|
"i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b": "Leave empty to keep saved token",
|
||||||
|
"i18n:govoplan-files.library.b8100f5b": "Library",
|
||||||
|
"i18n:govoplan-files.linked_file_space.1a614c7d": "Linked file space",
|
||||||
|
"i18n:govoplan-files.linked_to_1_campaign.5349694f": "Linked to 1 campaign",
|
||||||
|
"i18n:govoplan-files.linked_to_value_campaigns.1ad7be94": "Linked to {value0} campaigns",
|
||||||
|
"i18n:govoplan-files.linked_to.ca188768": "linked to",
|
||||||
|
"i18n:govoplan-files.linked_to.e7ca9e02": "Linked to",
|
||||||
|
"i18n:govoplan-files.linking.6f640897": "Linking…",
|
||||||
|
"i18n:govoplan-files.loading_connector_files.02c876e0": "Loading connector files…",
|
||||||
|
"i18n:govoplan-files.loading_connector_files.e5b0871d": "Loading connector files",
|
||||||
|
"i18n:govoplan-files.loading_connector_folders.426de3b6": "Loading connector folders",
|
||||||
|
"i18n:govoplan-files.loading_connector_folders.dde26f3e": "Loading connector folders...",
|
||||||
|
"i18n:govoplan-files.loading_connector_policy.2b984eec": "Loading connector policy...",
|
||||||
|
"i18n:govoplan-files.loading_connector_policy.f12ab285": "Loading connector policy",
|
||||||
|
"i18n:govoplan-files.loading_connector_space.356d83c6": "Loading connector space…",
|
||||||
|
"i18n:govoplan-files.loading_connector_space.c176e6b8": "Loading connector space",
|
||||||
|
"i18n:govoplan-files.loading_file_connections.bd68f224": "Loading file connections",
|
||||||
|
"i18n:govoplan-files.loading_file_connections.ef22dc81": "Loading file connections...",
|
||||||
|
"i18n:govoplan-files.loading_files.3b142382": "Loading files",
|
||||||
|
"i18n:govoplan-files.loading_files.bfd27a7e": "Loading files…",
|
||||||
|
"i18n:govoplan-files.loading.b04ba49f": "Loading...",
|
||||||
|
"i18n:govoplan-files.lower_connection_limits.4f9838bc": "Lower connection limits",
|
||||||
|
"i18n:govoplan-files.lower_credential_limits.ec2b72bc": "Lower credential limits",
|
||||||
|
"i18n:govoplan-files.lower_endpoint_limits.3fd781d5": "Lower endpoint limits",
|
||||||
|
"i18n:govoplan-files.lower_path_limits.1abcccb1": "Lower path limits",
|
||||||
|
"i18n:govoplan-files.lower_provider_limits.5816270a": "Lower provider limits",
|
||||||
|
"i18n:govoplan-files.managed_files.4dd6ad11": "Managed files",
|
||||||
|
"i18n:govoplan-files.match.c79af5c2": "match.",
|
||||||
|
"i18n:govoplan-files.mb.6e979f42": "MB",
|
||||||
|
"i18n:govoplan-files.metadata_json_must_be_an_object.48629f13": "Metadata JSON must be an object.",
|
||||||
|
"i18n:govoplan-files.metadata_json.b0e4c283": "Metadata JSON",
|
||||||
|
"i18n:govoplan-files.mode.a7b93d21": "Mode",
|
||||||
|
"i18n:govoplan-files.modified.19a532c8": "Modified",
|
||||||
|
"i18n:govoplan-files.more_show_next.f1912318": "more — show next",
|
||||||
|
"i18n:govoplan-files.move.76cdb950": "Move",
|
||||||
|
"i18n:govoplan-files.move.8a74a26e": "Move…",
|
||||||
|
"i18n:govoplan-files.moved.0fc28db0": "Moved",
|
||||||
|
"i18n:govoplan-files.moving.65cd4dd8": "Moving",
|
||||||
|
"i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4": "Must stay in allowed paths",
|
||||||
|
"i18n:govoplan-files.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-files.native_default.ba32f7b6": "Native/default",
|
||||||
|
"i18n:govoplan-files.new_connection.ac979fe4": "New connection",
|
||||||
|
"i18n:govoplan-files.new_credential.65b2a9b1": "New credential",
|
||||||
|
"i18n:govoplan-files.new_file_connection.2ec6eb56": "New file connection",
|
||||||
|
"i18n:govoplan-files.new_file_credential.4c061082": "New file credential",
|
||||||
|
"i18n:govoplan-files.new_folder.a711999b": "New folder",
|
||||||
|
"i18n:govoplan-files.new_name.9e627c7b": "New name",
|
||||||
|
"i18n:govoplan-files.new_path_for_value.a9a30c1c": "New path for {value0}",
|
||||||
|
"i18n:govoplan-files.nextcloud.aab73a3d": "Nextcloud",
|
||||||
|
"i18n:govoplan-files.nfs.db121865": "NFS",
|
||||||
|
"i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003": "No accessible file spaces were found.",
|
||||||
|
"i18n:govoplan-files.no_campaign_links.4203c2be": "No campaign links",
|
||||||
|
"i18n:govoplan-files.no_connector_items.d634e023": "No connector items.",
|
||||||
|
"i18n:govoplan-files.no_connector_profiles_are_available.9be3be28": "No connector profiles are available.",
|
||||||
|
"i18n:govoplan-files.no_connector_profiles.03c730ee": "No connector profiles",
|
||||||
|
"i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836": "No current files match this pattern.",
|
||||||
|
"i18n:govoplan-files.no_endpoint.d0ea68c4": "No endpoint",
|
||||||
|
"i18n:govoplan-files.no_file_connections_configured.3ef02049": "No file connections configured.",
|
||||||
|
"i18n:govoplan-files.no_file_selected.f76f1c1c": "No file selected",
|
||||||
|
"i18n:govoplan-files.no_file_spaces_available.a81fa3d0": "No file spaces available.",
|
||||||
|
"i18n:govoplan-files.no_value_available": "No {value0} available.",
|
||||||
|
"i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5": "No files uploaded; all conflicts were skipped.",
|
||||||
|
"i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d": "No folders yet. Choose the root folder.",
|
||||||
|
"i18n:govoplan-files.no_saved_credential.30a5a951": "No saved credential",
|
||||||
|
"i18n:govoplan-files.no_secret.33c1a339": "No secret",
|
||||||
|
"i18n:govoplan-files.none.6eef6648": "None",
|
||||||
|
"i18n:govoplan-files.not_sorted.0abc03c5": "not sorted",
|
||||||
|
"i18n:govoplan-files.ntlm.026eac85": "NTLM",
|
||||||
|
"i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71": "Only the visible name changes. Immutable blobs stay stable for audit.",
|
||||||
|
"i18n:govoplan-files.open_parent_folder_value.8030a7c7": "Open parent folder {value0}",
|
||||||
|
"i18n:govoplan-files.overwrite_all.a76f2d04": "Overwrite all",
|
||||||
|
"i18n:govoplan-files.overwrite.0625c54e": "Overwrite",
|
||||||
|
"i18n:govoplan-files.owner_space.364c4b62": "Owner space",
|
||||||
|
"i18n:govoplan-files.parent_folder.d8ed4c2a": "Parent folder",
|
||||||
|
"i18n:govoplan-files.password_environment_variable.0e77673f": "Password environment variable",
|
||||||
|
"i18n:govoplan-files.password.8be3c943": "Password",
|
||||||
|
"i18n:govoplan-files.path_limited.d32cbef0": "Path-limited",
|
||||||
|
"i18n:govoplan-files.pattern_preview_results.56cea56c": "Pattern preview results",
|
||||||
|
"i18n:govoplan-files.policy.bb9cf141": "Policy",
|
||||||
|
"i18n:govoplan-files.prefix.90eceb01": "Prefix",
|
||||||
|
"i18n:govoplan-files.preview_rename.bb890b24": "Preview rename",
|
||||||
|
"i18n:govoplan-files.preview_resolves_to.a8d99432": "Preview resolves to",
|
||||||
|
"i18n:govoplan-files.preview.f1fbb2b4": "Preview",
|
||||||
|
"i18n:govoplan-files.profile_id_and_label_are_required.6ad85df1": "Profile id and label are required.",
|
||||||
|
"i18n:govoplan-files.profile_id.25961d68": "Profile id",
|
||||||
|
"i18n:govoplan-files.provider.7ceee3f3": "Provider",
|
||||||
|
"i18n:govoplan-files.read_only.9b19a5a2": "Read-only",
|
||||||
|
"i18n:govoplan-files.refresh.56e3badc": "Refresh",
|
||||||
|
"i18n:govoplan-files.reload.cce71553": "Reload",
|
||||||
|
"i18n:govoplan-files.remote_connector_space.d8956863": "Remote connector space",
|
||||||
|
"i18n:govoplan-files.rename_all.1d6b24dc": "Rename all",
|
||||||
|
"i18n:govoplan-files.rename_item.3d21d6ca": "Rename item",
|
||||||
|
"i18n:govoplan-files.rename.d3f4cb89": "Rename",
|
||||||
|
"i18n:govoplan-files.renamed_value_item_s.3c4a40fe": "Renamed {value0} item(s).",
|
||||||
|
"i18n:govoplan-files.replace_pattern.d98e4c92": "Replace pattern",
|
||||||
|
"i18n:govoplan-files.replace_the_current_pattern.96f80707": "Replace the current pattern?",
|
||||||
|
"i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f": "Replace \"{value0}\" with the exact file \"{value1}\"?",
|
||||||
|
"i18n:govoplan-files.replacement.c385e347": "Replacement",
|
||||||
|
"i18n:govoplan-files.require_smb_signing.5168a83d": "Require SMB signing",
|
||||||
|
"i18n:govoplan-files.review_individually.19abbe58": "Review individually",
|
||||||
|
"i18n:govoplan-files.root.e96857c5": "Root",
|
||||||
|
"i18n:govoplan-files.save_connection.07796d38": "Save connection",
|
||||||
|
"i18n:govoplan-files.save_credential.2c02a6d2": "Save credential",
|
||||||
|
"i18n:govoplan-files.save_policy.77d67ce3": "Save policy",
|
||||||
|
"i18n:govoplan-files.saving.56a2285c": "Saving…",
|
||||||
|
"i18n:govoplan-files.saving.ae7e8875": "Saving...",
|
||||||
|
"i18n:govoplan-files.seafile.600192cc": "Seafile",
|
||||||
|
"i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a": "Search this folder: *.pdf, **/*.pdf or 2026-??-*.pdf",
|
||||||
|
"i18n:govoplan-files.search.bce06414": "Search",
|
||||||
|
"i18n:govoplan-files.secret_configured.4dc0f095": "Secret configured",
|
||||||
|
"i18n:govoplan-files.secret_reference.04ed2221": "Secret reference",
|
||||||
|
"i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47": "Select a remote file to sync into the destination folder.",
|
||||||
|
"i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae": "Select a remote file to sync it into this space owner.",
|
||||||
|
"i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f": "Select the space that will receive the selected file(s) or folder(s).",
|
||||||
|
"i18n:govoplan-files.selected.840fac38": " · Selected",
|
||||||
|
"i18n:govoplan-files.show_fewer.d94dfbe5": "Show fewer",
|
||||||
|
"i18n:govoplan-files.size.b7152342": "Size",
|
||||||
|
"i18n:govoplan-files.skip.3da47453": "Skip",
|
||||||
|
"i18n:govoplan-files.smb_authentication.96f7cb83": "SMB authentication",
|
||||||
|
"i18n:govoplan-files.smb.515d2f88": "SMB",
|
||||||
|
"i18n:govoplan-files.space_label.ec892726": "Space label",
|
||||||
|
"i18n:govoplan-files.spaces.9b584b52": "Spaces",
|
||||||
|
"i18n:govoplan-files.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-files.suffix.885db975": "Suffix",
|
||||||
|
"i18n:govoplan-files.sync_to_value_value.29c362ea": "Sync to {value0} / {value1}",
|
||||||
|
"i18n:govoplan-files.sync.905f6309": "Sync",
|
||||||
|
"i18n:govoplan-files.synced_new_file.d5e8243d": "Synced new file",
|
||||||
|
"i18n:govoplan-files.synced_new_version.83784264": "Synced new version",
|
||||||
|
"i18n:govoplan-files.system.bc0792d8": "System",
|
||||||
|
"i18n:govoplan-files.target.61ad50a9": "Target",
|
||||||
|
"i18n:govoplan-files.tb.f8a902b0": "TB",
|
||||||
|
"i18n:govoplan-files.tenant.3ca93c78": "Tenant",
|
||||||
|
"i18n:govoplan-files.test_value.6a5d10a5": "Test {value0}",
|
||||||
|
"i18n:govoplan-files.testing.15ccc832": "Testing...",
|
||||||
|
"i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729": "The configured managed file space is no longer available to this user.",
|
||||||
|
"i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f": "This attachment base path is not connected to a managed file space. Choose its folder under",
|
||||||
|
"i18n:govoplan-files.this_file_connection.edcde997": "this file connection",
|
||||||
|
"i18n:govoplan-files.this_file_credential.695efb90": "this file credential",
|
||||||
|
"i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a": "This folder is empty. Upload files in the top-level Files module.",
|
||||||
|
"i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784": "This folder is empty. Use Upload or Create Folder to add content.",
|
||||||
|
"i18n:govoplan-files.token_environment_variable.5af4a79e": "Token environment variable",
|
||||||
|
"i18n:govoplan-files.token.a1141eb9": "Token",
|
||||||
|
"i18n:govoplan-files.unpack_zip_uploads.256fead4": "Unpack ZIP uploads",
|
||||||
|
"i18n:govoplan-files.unpacking_zip_archive.698095f4": "Unpacking ZIP archive",
|
||||||
|
"i18n:govoplan-files.unpacking_zip_upload.35019691": "Unpacking ZIP upload…",
|
||||||
|
"i18n:govoplan-files.up.2038bdec": "Up",
|
||||||
|
"i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3": "Upload failed because the network request could not be completed.",
|
||||||
|
"i18n:govoplan-files.upload_to_value_value.b83a34b4": "Upload to {value0} / {value1}",
|
||||||
|
"i18n:govoplan-files.upload_was_cancelled.5bab0f9b": "Upload was cancelled.",
|
||||||
|
"i18n:govoplan-files.upload.8bdf057f": "Upload",
|
||||||
|
"i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b": "Uploaded {value0} file(s) into {value1}.",
|
||||||
|
"i18n:govoplan-files.uploading_files.6536791d": "Uploading files",
|
||||||
|
"i18n:govoplan-files.uploading_value_file_s.715ba963": "Uploading {value0} file(s)…",
|
||||||
|
"i18n:govoplan-files.use_pattern.ce54ba3e": "Use pattern",
|
||||||
|
"i18n:govoplan-files.use_this_folder.0e77d6d1": "Use this folder",
|
||||||
|
"i18n:govoplan-files.user.9f8a2389": "User",
|
||||||
|
"i18n:govoplan-files.users": "Users",
|
||||||
|
"i18n:govoplan-files.username_password.e8ba8896": "Username/password",
|
||||||
|
"i18n:govoplan-files.username.84c29015": "Username",
|
||||||
|
"i18n:govoplan-files.value_conflict_s.b3872a48": "{value0} conflict(s)",
|
||||||
|
"i18n:govoplan-files.value_connector_policy_saved.430d4eca": "{value0} connector policy saved.",
|
||||||
|
"i18n:govoplan-files.value_connector_policy.0bf7f53b": "{value0} connector policy",
|
||||||
|
"i18n:govoplan-files.value_file_shown_for_context.3d6e0acb": "{value0}, file shown for context",
|
||||||
|
"i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c": "{value0} folder(s) · {value1} file(s)",
|
||||||
|
"i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4": "{value0} this selection would create duplicate visible names in the destination.",
|
||||||
|
"i18n:govoplan-files.value_upload_conflict_s.3a04f117": "{value0} upload conflict(s)",
|
||||||
|
"i18n:govoplan-files.value_value_activate_to_sort.3953ba71": "{value0}: {value1}. Activate to sort.",
|
||||||
|
"i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725": "{value0} {value1} file(s) and {value2} folder(s).",
|
||||||
|
"i18n:govoplan-files.value_value.36bc3a40": "{value0}: {value1}.",
|
||||||
|
"i18n:govoplan-files.value_value.598aab59": "{value0} -> {value1}",
|
||||||
|
"i18n:govoplan-files.value_value.c189e8bc": "{value0} ({value1})",
|
||||||
|
"i18n:govoplan-files.value_value.dca59cc0": "{value0} {value1}",
|
||||||
|
"i18n:govoplan-files.value.48afe802": "· {value0}",
|
||||||
|
"i18n:govoplan-files.value_file_connections": "{value0} file connections",
|
||||||
|
"i18n:govoplan-files.value_scope": "{value0} scope",
|
||||||
|
"i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90": "visible file(s) were not matched.",
|
||||||
|
"i18n:govoplan-files.webdav.521b4c8b": "WebDAV",
|
||||||
|
"i18n:govoplan-files.working.13b7bfca": "Working…",
|
||||||
|
"i18n:govoplan-files.writable.dd35487a": "Writable"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"i18n:govoplan-files.add_connector_space.aa6bdbd6": "Add connector space",
|
||||||
|
"i18n:govoplan-files.add_credential_for_value.0fa9c1fe": "Add credential for {value0}",
|
||||||
|
"i18n:govoplan-files.add_prefix.672452bc": "Add prefix",
|
||||||
|
"i18n:govoplan-files.add_space.e4d674d4": "Add Space",
|
||||||
|
"i18n:govoplan-files.add_suffix_before_extension.784381fe": "Add suffix before extension",
|
||||||
|
"i18n:govoplan-files.added_connector_space_value.a24725b8": "Added connector space {value0}.",
|
||||||
|
"i18n:govoplan-files.allowed_connection_ids.0df854c8": "Allowed connection IDs",
|
||||||
|
"i18n:govoplan-files.allowed_credential_ids.efcaba2d": "Allowed credential IDs",
|
||||||
|
"i18n:govoplan-files.allowed_endpoint_urls.34cfa8e9": "Allowed endpoint URLs",
|
||||||
|
"i18n:govoplan-files.allowed_paths.c953b4be": "Allowed paths",
|
||||||
|
"i18n:govoplan-files.allowed_providers.82a9c23e": "Allowed providers",
|
||||||
|
"i18n:govoplan-files.allowed.77c7b490": "Allowed",
|
||||||
|
"i18n:govoplan-files.access_explanation.75ee7f62": "Zugriffserklärung",
|
||||||
|
"i18n:govoplan-files.action.97c89a4d": "Aktion",
|
||||||
|
"i18n:govoplan-files.already_at_the_root_folder.1238f110": "Already at the root folder",
|
||||||
|
"i18n:govoplan-files.and.a0d93385": "… and",
|
||||||
|
"i18n:govoplan-files.anonymous.9bed5104": "Anonymous",
|
||||||
|
"i18n:govoplan-files.any_provider.b3fc542f": "Any provider",
|
||||||
|
"i18n:govoplan-files.apply_preview.8692d5eb": "Apply preview",
|
||||||
|
"i18n:govoplan-files.apply_recursively_to_folder_contents.c7076984": "Apply recursively to folder contents",
|
||||||
|
"i18n:govoplan-files.apply_reviewed_choices.bb55f7b3": "Apply reviewed choices",
|
||||||
|
"i18n:govoplan-files.attachments_attachment_sources.87d92d5b": "Attachments → Attachment sources",
|
||||||
|
"i18n:govoplan-files.audit_relevant_files_stay_retained_when_deleted_.2b7b4543": "Audit-relevant files stay retained when deleted from active browsing.",
|
||||||
|
"i18n:govoplan-files.available.974948f2": " · Available",
|
||||||
|
"i18n:govoplan-files.close.bbfa773e": "Schließen",
|
||||||
|
"i18n:govoplan-files.evidence.8487d192": "Nachweise",
|
||||||
|
"i18n:govoplan-files.explain_access.4d5fac37": "Zugriff erklären",
|
||||||
|
"i18n:govoplan-files.loading_access_explanation.04a7c934": "Zugriffserklärung wird geladen...",
|
||||||
|
"i18n:govoplan-files.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
|
||||||
|
"i18n:govoplan-files.no_source.6dcf9723": "Keine Quelle",
|
||||||
|
"i18n:govoplan-files.owner.89ff3122": "Eigentümer",
|
||||||
|
"i18n:govoplan-files.permission.2f81a22d": "Berechtigung",
|
||||||
|
"i18n:govoplan-files.policy.0b779a05": "Richtlinie",
|
||||||
|
"i18n:govoplan-files.resource.d1c626a9": "Ressource",
|
||||||
|
"i18n:govoplan-files.role.b5b4a5a2": "Rolle",
|
||||||
|
"i18n:govoplan-files.share.09ca55ca": "Freigabe",
|
||||||
|
"i18n:govoplan-files.back_to_folder.34ba1ed1": "Back to folder",
|
||||||
|
"i18n:govoplan-files.base_path.6a4867ca": "Base path",
|
||||||
|
"i18n:govoplan-files.blocked_by_policy.8d971f86": "Blocked by policy",
|
||||||
|
"i18n:govoplan-files.blocked.99613c74": "Blocked",
|
||||||
|
"i18n:govoplan-files.bootstrap_settings.05e3df38": " · Bootstrap settings",
|
||||||
|
"i18n:govoplan-files.browse_ok_value_item_value.921906fd": "Browse OK ({value0} item{value1}).",
|
||||||
|
"i18n:govoplan-files.browse_protocol.d1f27c90": "Browse protocol",
|
||||||
|
"i18n:govoplan-files.browse.2f3b5c55": "Durchsuchen",
|
||||||
|
"i18n:govoplan-files.bulk_rename_changes_managed_display_paths_only_i.8cf34a6b": "Bulk rename changes managed display paths only. Immutable blobs stay stable for audit.",
|
||||||
|
"i18n:govoplan-files.bulk_rename_selected_items.5e79d694": "Bulk rename selected items",
|
||||||
|
"i18n:govoplan-files.bulk_rename.7dcaa624": "Bulk rename",
|
||||||
|
"i18n:govoplan-files.bytes_b": "{value0} B",
|
||||||
|
"i18n:govoplan-files.bytes_gb": "{value0} GB",
|
||||||
|
"i18n:govoplan-files.bytes_kb": "{value0} KB",
|
||||||
|
"i18n:govoplan-files.bytes_mb": "{value0} MB",
|
||||||
|
"i18n:govoplan-files.bytes_pb": "{value0} PB",
|
||||||
|
"i18n:govoplan-files.bytes_tb": "{value0} TB",
|
||||||
|
"i18n:govoplan-files.can_use_this_connection.5091a74c": "Can use this connection",
|
||||||
|
"i18n:govoplan-files.can_use_this_credential.f4a41971": "Can use this credential",
|
||||||
|
"i18n:govoplan-files.cancel.77dfd213": "Abbrechen",
|
||||||
|
"i18n:govoplan-files.cannot_move_or_copy_a_folder_into_itself_or_one_.4adbd5ea": "Cannot move or copy a folder into itself or one of its child folders.",
|
||||||
|
"i18n:govoplan-files.case_sensitive.c73af7bd": "Case sensitive",
|
||||||
|
"i18n:govoplan-files.checking.494d0f68": "Checking...",
|
||||||
|
"i18n:govoplan-files.choose_a_connector_file_and_destination_folder.3c6f3ffb": "Choose a connector file and destination folder.",
|
||||||
|
"i18n:govoplan-files.choose_a_connector_profile_and_owner_space.4c386b00": "Choose a connector profile and owner space.",
|
||||||
|
"i18n:govoplan-files.choose_a_destination_space_and_folder_folders_ar.45158643": "Choose a destination space and folder. Folders are transferred recursively.",
|
||||||
|
"i18n:govoplan-files.choose_how_the_selected_names_should_be_changed.0231854f": "Choose how the selected names should be changed.",
|
||||||
|
"i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6": "Choose how to resolve this conflict.",
|
||||||
|
"i18n:govoplan-files.choose_managed_attachment_source.b3709493": "Choose managed attachment source",
|
||||||
|
"i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183": "Choose managed file or pattern",
|
||||||
|
"i18n:govoplan-files.choose_the_destination_folder_before_selecting_o.a4922d75": "Choose the destination folder before selecting or dropping files.",
|
||||||
|
"i18n:govoplan-files.campaigns": "Kampagnen",
|
||||||
|
"i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7": "Choose the folder that should act as this campaign attachment source.",
|
||||||
|
"i18n:govoplan-files.choose_the_parent_folder_for_the_new_subfolder.2fcb6580": "Choose the parent folder for the new subfolder.",
|
||||||
|
"i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f": "Choose the same action for all conflicts or review the target names individually.",
|
||||||
|
"i18n:govoplan-files.choose_the_target_folder_folders_are_transferred.f7ab8e9e": "Choose the target folder. Folders are transferred recursively.",
|
||||||
|
"i18n:govoplan-files.clear_saved_password.7442260d": "Clear saved password",
|
||||||
|
"i18n:govoplan-files.clear_saved_token.a9c670aa": "Clear saved token",
|
||||||
|
"i18n:govoplan-files.clear.719ea396": "Leeren",
|
||||||
|
"i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24": "Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.",
|
||||||
|
"i18n:govoplan-files.collapse.9cf188d3": "Collapse",
|
||||||
|
"i18n:govoplan-files.conflict_at.0a91052f": "conflict at",
|
||||||
|
"i18n:govoplan-files.conflict_s.60cea6d5": "conflict(s)",
|
||||||
|
"i18n:govoplan-files.connection_credential.178babe0": "Connection / credential",
|
||||||
|
"i18n:govoplan-files.connector_files.8f351f5d": "Connector files",
|
||||||
|
"i18n:govoplan-files.connector_folders.960cbb03": "Connector folders",
|
||||||
|
"i18n:govoplan-files.connector_profile_is_not_configured_for_this_spa.669f57b5": "Connector profile is not configured for this space.",
|
||||||
|
"i18n:govoplan-files.connector_profile.9e9cd317": "Connector profile",
|
||||||
|
"i18n:govoplan-files.connector_root.9027235c": "Connector root",
|
||||||
|
"i18n:govoplan-files.connector_source_already_matched.7eaf5be1": "Connector source already matched",
|
||||||
|
"i18n:govoplan-files.connector_space_files.dbb0ab24": "Connector space files",
|
||||||
|
"i18n:govoplan-files.connector.ba358306": "Verbindung",
|
||||||
|
"i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1": "contained path(s) will move with the selected folder(s), but their own names will stay unchanged.",
|
||||||
|
"i18n:govoplan-files.copied.8e3df45a": "Copied",
|
||||||
|
"i18n:govoplan-files.copy.92556c6d": "Copy…",
|
||||||
|
"i18n:govoplan-files.copy.a69c71d0": " copy",
|
||||||
|
"i18n:govoplan-files.copy.af74f7c5": "Copy",
|
||||||
|
"i18n:govoplan-files.copying.43b7de98": "Copying",
|
||||||
|
"i18n:govoplan-files.copymove.d0fa5904": "copyMove",
|
||||||
|
"i18n:govoplan-files.create_a_folder_below_the_selected_destination.9041ee76": "Create a folder below the selected destination.",
|
||||||
|
"i18n:govoplan-files.create_folder.97bafaba": "Create Folder",
|
||||||
|
"i18n:govoplan-files.create_folder.e59f63fa": "Ordner erstellen",
|
||||||
|
"i18n:govoplan-files.create_space.b50606b4": "Create Space",
|
||||||
|
"i18n:govoplan-files.created_folder_value.6c3002f9": "Created folder {value0}.",
|
||||||
|
"i18n:govoplan-files.created_inside.3426a815": "Created inside",
|
||||||
|
"i18n:govoplan-files.credential_id_and_label_are_required.4cfd1ffd": "Credential id and label are required.",
|
||||||
|
"i18n:govoplan-files.credential_id.9432a6e1": "Credential id",
|
||||||
|
"i18n:govoplan-files.credential_mode.23fdd899": "Credential mode",
|
||||||
|
"i18n:govoplan-files.credential.8bede3ea": "Credential",
|
||||||
|
"i18n:govoplan-files.current_file.1fbfcf3d": "current file",
|
||||||
|
"i18n:govoplan-files.current_folder_contents.f9a24fa8": "Current folder contents",
|
||||||
|
"i18n:govoplan-files.current_folder.5aeab2f0": "Current folder",
|
||||||
|
"i18n:govoplan-files.delete_selected_item_s.4b6a0023": "Delete selected item(s)?",
|
||||||
|
"i18n:govoplan-files.delete_value_audit_relevant_blobs_remain_retaine.290af88f": "Delete {value0}? Audit-relevant blobs remain retained.",
|
||||||
|
"i18n:govoplan-files.delete.f6fdbe48": "Löschen",
|
||||||
|
"i18n:govoplan-files.denied_connection_ids.a95218b4": "Denied connection IDs",
|
||||||
|
"i18n:govoplan-files.denied_credential_ids.b6838bc2": "Denied credential IDs",
|
||||||
|
"i18n:govoplan-files.denied_endpoint_urls.1d8d4369": "Denied endpoint URLs",
|
||||||
|
"i18n:govoplan-files.denied_paths.d25e4315": "Denied paths",
|
||||||
|
"i18n:govoplan-files.denied_providers.149b29f4": "Denied providers",
|
||||||
|
"i18n:govoplan-files.deny_listed_paths.fcde62cc": "Deny-listed paths",
|
||||||
|
"i18n:govoplan-files.destination_folder.f8ccb63b": "Destination folder",
|
||||||
|
"i18n:govoplan-files.destination_space.92b63970": "Destination space",
|
||||||
|
"i18n:govoplan-files.disable_file_connection.3fd940ae": "Disable file connection",
|
||||||
|
"i18n:govoplan-files.disable_file_credential.49bff614": "Disable file credential",
|
||||||
|
"i18n:govoplan-files.disable_value_connections_using_it_will_stop_aut.fc3a1708": "Disable {value0}? Connections using it will stop authenticating until another credential is selected.",
|
||||||
|
"i18n:govoplan-files.disable_value_existing_linked_spaces_will_stop_b.8c1b0ac6": "Disable {value0}? Existing linked spaces will stop browsing until the connection is enabled again.",
|
||||||
|
"i18n:govoplan-files.disable_value.09485f7f": "Disable {value0}",
|
||||||
|
"i18n:govoplan-files.disable.9a7d4e06": "Disable",
|
||||||
|
"i18n:govoplan-files.disabled.f4f4473d": "Disabled",
|
||||||
|
"i18n:govoplan-files.dms.477e5652": "DMS",
|
||||||
|
"i18n:govoplan-files.download_zip.7bd0e4b0": "Download ZIP",
|
||||||
|
"i18n:govoplan-files.download.a479c9c3": "Download",
|
||||||
|
"i18n:govoplan-files.e_g_invoices.77a73bd1": "e.g. invoices",
|
||||||
|
"i18n:govoplan-files.edit_file_connection.78698154": "Edit file connection",
|
||||||
|
"i18n:govoplan-files.edit_file_credential.bc49d44f": "Edit file credential",
|
||||||
|
"i18n:govoplan-files.edit_value.fad75899": "Edit {value0}",
|
||||||
|
"i18n:govoplan-files.effective_sources_none.9a7c407f": "Effective sources: none",
|
||||||
|
"i18n:govoplan-files.effective_sources.9a576802": "Effective sources:",
|
||||||
|
"i18n:govoplan-files.enabled.df174a3f": "Aktiviert",
|
||||||
|
"i18n:govoplan-files.endpoint_url.65aaaa45": "Endpoint URL",
|
||||||
|
"i18n:govoplan-files.endpoint.92ec6350": "Endpoint",
|
||||||
|
"i18n:govoplan-files.expand.9869e506": "Expand",
|
||||||
|
"i18n:govoplan-files.extracting_files_on_the_server.845a3c1a": "Extracting files on the server…",
|
||||||
|
"i18n:govoplan-files.file_actions.9e1b94c5": "File actions",
|
||||||
|
"i18n:govoplan-files.file_connection_created.bdf6e1f6": "File connection created.",
|
||||||
|
"i18n:govoplan-files.file_connection_disabled_value.059a7de9": "File connection disabled: {value0}.",
|
||||||
|
"i18n:govoplan-files.file_connection_saved.68c16ee9": "File connection saved.",
|
||||||
|
"i18n:govoplan-files.file_connections.1e362326": "Dateiverbindungen",
|
||||||
|
"i18n:govoplan-files.file_credential_created.87c31882": "File credential created.",
|
||||||
|
"i18n:govoplan-files.file_credential_disabled_value.947538fd": "File credential disabled: {value0}.",
|
||||||
|
"i18n:govoplan-files.file_credential_saved.d6a1eef8": "File credential saved.",
|
||||||
|
"i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831": "File or pattern relative to",
|
||||||
|
"i18n:govoplan-files.file_s.ca61e97f": "file(s),",
|
||||||
|
"i18n:govoplan-files.file_share_was_not_created.033ac476": "File share was not created.",
|
||||||
|
"i18n:govoplan-files.file_spaces_and_folders.443d2056": "File spaces and folders",
|
||||||
|
"i18n:govoplan-files.file.2c3cafa4": "Datei",
|
||||||
|
"i18n:govoplan-files.files_with_the_same_visible_name_already_exist_i.1bb487b0": "Files with the same visible name already exist in this folder.",
|
||||||
|
"i18n:govoplan-files.files.6ce6c512": "Dateien",
|
||||||
|
"i18n:govoplan-files.finalizing_upload.bcce936d": "Finalizing upload…",
|
||||||
|
"i18n:govoplan-files.find_and_replace.2c6b404b": "Find and replace",
|
||||||
|
"i18n:govoplan-files.find.df251b06": "Find",
|
||||||
|
"i18n:govoplan-files.first.49ec0838": "first.",
|
||||||
|
"i18n:govoplan-files.folder_name.b2ce023b": "Ordnername",
|
||||||
|
"i18n:govoplan-files.folder_s.10e54730": "folder(s)",
|
||||||
|
"i18n:govoplan-files.folder.30baa249": "Ordner",
|
||||||
|
"i18n:govoplan-files.folders_and_files.d107ac99": "Folders and files",
|
||||||
|
"i18n:govoplan-files.folders.19adc47b": "Ordner",
|
||||||
|
"i18n:govoplan-files.gb.505a44fa": "GB",
|
||||||
|
"i18n:govoplan-files.generic.ff7613e5": "Generic",
|
||||||
|
"i18n:govoplan-files.govoplan_files.b20752ed": "GovOPlaN files",
|
||||||
|
"i18n:govoplan-files.govoplan_webdav_credentials.bc696d69": "GovOPlaN WebDAV credentials",
|
||||||
|
"i18n:govoplan-files.group.171a0606": "Group",
|
||||||
|
"i18n:govoplan-files.groups": "Gruppen",
|
||||||
|
"i18n:govoplan-files.import.d6fbc9d2": "Import",
|
||||||
|
"i18n:govoplan-files.inherited.58823af0": "Inherited",
|
||||||
|
"i18n:govoplan-files.kb.315b39a0": "KB",
|
||||||
|
"i18n:govoplan-files.kerberos.53c35fb7": "Kerberos",
|
||||||
|
"i18n:govoplan-files.label.74341e3c": "Label",
|
||||||
|
"i18n:govoplan-files.leave_empty_to_keep_saved_password.6ec39f5e": "Leave empty to keep saved password",
|
||||||
|
"i18n:govoplan-files.leave_empty_to_keep_saved_token.e725857b": "Leave empty to keep saved token",
|
||||||
|
"i18n:govoplan-files.library.b8100f5b": "Library",
|
||||||
|
"i18n:govoplan-files.linked_file_space.1a614c7d": "Linked file space",
|
||||||
|
"i18n:govoplan-files.linked_to_1_campaign.5349694f": "Linked to 1 campaign",
|
||||||
|
"i18n:govoplan-files.linked_to_value_campaigns.1ad7be94": "Linked to {value0} campaigns",
|
||||||
|
"i18n:govoplan-files.linked_to.ca188768": "linked to",
|
||||||
|
"i18n:govoplan-files.linked_to.e7ca9e02": "Linked to",
|
||||||
|
"i18n:govoplan-files.linking.6f640897": "Linking…",
|
||||||
|
"i18n:govoplan-files.loading_connector_files.02c876e0": "Loading connector files…",
|
||||||
|
"i18n:govoplan-files.loading_connector_files.e5b0871d": "Loading connector files",
|
||||||
|
"i18n:govoplan-files.loading_connector_folders.426de3b6": "Loading connector folders",
|
||||||
|
"i18n:govoplan-files.loading_connector_folders.dde26f3e": "Loading connector folders...",
|
||||||
|
"i18n:govoplan-files.loading_connector_policy.2b984eec": "Loading connector policy...",
|
||||||
|
"i18n:govoplan-files.loading_connector_policy.f12ab285": "Loading connector policy",
|
||||||
|
"i18n:govoplan-files.loading_connector_space.356d83c6": "Loading connector space…",
|
||||||
|
"i18n:govoplan-files.loading_connector_space.c176e6b8": "Loading connector space",
|
||||||
|
"i18n:govoplan-files.loading_file_connections.bd68f224": "Loading file connections",
|
||||||
|
"i18n:govoplan-files.loading_file_connections.ef22dc81": "Loading file connections...",
|
||||||
|
"i18n:govoplan-files.loading_files.3b142382": "Loading files",
|
||||||
|
"i18n:govoplan-files.loading_files.bfd27a7e": "Loading files…",
|
||||||
|
"i18n:govoplan-files.loading.b04ba49f": "Loading...",
|
||||||
|
"i18n:govoplan-files.lower_connection_limits.4f9838bc": "Lower connection limits",
|
||||||
|
"i18n:govoplan-files.lower_credential_limits.ec2b72bc": "Lower credential limits",
|
||||||
|
"i18n:govoplan-files.lower_endpoint_limits.3fd781d5": "Lower endpoint limits",
|
||||||
|
"i18n:govoplan-files.lower_path_limits.1abcccb1": "Lower path limits",
|
||||||
|
"i18n:govoplan-files.lower_provider_limits.5816270a": "Lower provider limits",
|
||||||
|
"i18n:govoplan-files.managed_files.4dd6ad11": "Verwaltete Dateien",
|
||||||
|
"i18n:govoplan-files.match.c79af5c2": "match.",
|
||||||
|
"i18n:govoplan-files.mb.6e979f42": "MB",
|
||||||
|
"i18n:govoplan-files.metadata_json_must_be_an_object.48629f13": "Metadata JSON must be an object.",
|
||||||
|
"i18n:govoplan-files.metadata_json.b0e4c283": "Metadata JSON",
|
||||||
|
"i18n:govoplan-files.mode.a7b93d21": "Mode",
|
||||||
|
"i18n:govoplan-files.modified.19a532c8": "Modified",
|
||||||
|
"i18n:govoplan-files.more_show_next.f1912318": "more — show next",
|
||||||
|
"i18n:govoplan-files.move.76cdb950": "Verschieben",
|
||||||
|
"i18n:govoplan-files.move.8a74a26e": "Move…",
|
||||||
|
"i18n:govoplan-files.moved.0fc28db0": "Moved",
|
||||||
|
"i18n:govoplan-files.moving.65cd4dd8": "Moving",
|
||||||
|
"i18n:govoplan-files.must_stay_in_allowed_paths.dc5b4eb4": "Must stay in allowed paths",
|
||||||
|
"i18n:govoplan-files.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-files.native_default.ba32f7b6": "Native/default",
|
||||||
|
"i18n:govoplan-files.new_connection.ac979fe4": "New connection",
|
||||||
|
"i18n:govoplan-files.new_credential.65b2a9b1": "New credential",
|
||||||
|
"i18n:govoplan-files.new_file_connection.2ec6eb56": "New file connection",
|
||||||
|
"i18n:govoplan-files.new_file_credential.4c061082": "New file credential",
|
||||||
|
"i18n:govoplan-files.new_folder.a711999b": "Neuer Ordner",
|
||||||
|
"i18n:govoplan-files.new_name.9e627c7b": "New name",
|
||||||
|
"i18n:govoplan-files.new_path_for_value.a9a30c1c": "New path for {value0}",
|
||||||
|
"i18n:govoplan-files.nextcloud.aab73a3d": "Nextcloud",
|
||||||
|
"i18n:govoplan-files.nfs.db121865": "NFS",
|
||||||
|
"i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003": "No accessible file spaces were found.",
|
||||||
|
"i18n:govoplan-files.no_campaign_links.4203c2be": "No campaign links",
|
||||||
|
"i18n:govoplan-files.no_connector_items.d634e023": "No connector items.",
|
||||||
|
"i18n:govoplan-files.no_connector_profiles_are_available.9be3be28": "No connector profiles are available.",
|
||||||
|
"i18n:govoplan-files.no_connector_profiles.03c730ee": "No connector profiles",
|
||||||
|
"i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836": "No current files match this pattern.",
|
||||||
|
"i18n:govoplan-files.no_endpoint.d0ea68c4": "No endpoint",
|
||||||
|
"i18n:govoplan-files.no_file_connections_configured.3ef02049": "No file connections configured.",
|
||||||
|
"i18n:govoplan-files.no_file_selected.f76f1c1c": "No file selected",
|
||||||
|
"i18n:govoplan-files.no_file_spaces_available.a81fa3d0": "No file spaces available.",
|
||||||
|
"i18n:govoplan-files.no_value_available": "Keine {value0} verfügbar.",
|
||||||
|
"i18n:govoplan-files.no_files_uploaded_all_conflicts_were_skipped.1166f3b5": "No files uploaded; all conflicts were skipped.",
|
||||||
|
"i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d": "No folders yet. Choose the root folder.",
|
||||||
|
"i18n:govoplan-files.no_saved_credential.30a5a951": "No saved credential",
|
||||||
|
"i18n:govoplan-files.no_secret.33c1a339": "No secret",
|
||||||
|
"i18n:govoplan-files.none.6eef6648": "Keine",
|
||||||
|
"i18n:govoplan-files.not_sorted.0abc03c5": "not sorted",
|
||||||
|
"i18n:govoplan-files.ntlm.026eac85": "NTLM",
|
||||||
|
"i18n:govoplan-files.only_the_visible_name_changes_immutable_blobs_st.01557e71": "Only the visible name changes. Immutable blobs stay stable for audit.",
|
||||||
|
"i18n:govoplan-files.open_parent_folder_value.8030a7c7": "Open parent folder {value0}",
|
||||||
|
"i18n:govoplan-files.overwrite_all.a76f2d04": "Overwrite all",
|
||||||
|
"i18n:govoplan-files.overwrite.0625c54e": "Overwrite",
|
||||||
|
"i18n:govoplan-files.owner_space.364c4b62": "Owner space",
|
||||||
|
"i18n:govoplan-files.parent_folder.d8ed4c2a": "Parent folder",
|
||||||
|
"i18n:govoplan-files.password_environment_variable.0e77673f": "Password environment variable",
|
||||||
|
"i18n:govoplan-files.password.8be3c943": "Passwort",
|
||||||
|
"i18n:govoplan-files.path_limited.d32cbef0": "Path-limited",
|
||||||
|
"i18n:govoplan-files.pattern_preview_results.56cea56c": "Pattern preview results",
|
||||||
|
"i18n:govoplan-files.policy.bb9cf141": "Richtlinie",
|
||||||
|
"i18n:govoplan-files.prefix.90eceb01": "Prefix",
|
||||||
|
"i18n:govoplan-files.preview_rename.bb890b24": "Preview rename",
|
||||||
|
"i18n:govoplan-files.preview_resolves_to.a8d99432": "Preview resolves to",
|
||||||
|
"i18n:govoplan-files.preview.f1fbb2b4": "Vorschau",
|
||||||
|
"i18n:govoplan-files.profile_id_and_label_are_required.6ad85df1": "Profile id and label are required.",
|
||||||
|
"i18n:govoplan-files.profile_id.25961d68": "Profile id",
|
||||||
|
"i18n:govoplan-files.provider.7ceee3f3": "Provider",
|
||||||
|
"i18n:govoplan-files.read_only.9b19a5a2": "Read-only",
|
||||||
|
"i18n:govoplan-files.refresh.56e3badc": "Refresh",
|
||||||
|
"i18n:govoplan-files.reload.cce71553": "Neu laden",
|
||||||
|
"i18n:govoplan-files.remote_connector_space.d8956863": "Remote connector space",
|
||||||
|
"i18n:govoplan-files.rename_all.1d6b24dc": "Rename all",
|
||||||
|
"i18n:govoplan-files.rename_item.3d21d6ca": "Rename item",
|
||||||
|
"i18n:govoplan-files.rename.d3f4cb89": "Umbenennen",
|
||||||
|
"i18n:govoplan-files.renamed_value_item_s.3c4a40fe": "Renamed {value0} item(s).",
|
||||||
|
"i18n:govoplan-files.replace_pattern.d98e4c92": "Replace pattern",
|
||||||
|
"i18n:govoplan-files.replace_the_current_pattern.96f80707": "Replace the current pattern?",
|
||||||
|
"i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f": "Replace \"{value0}\" with the exact file \"{value1}\"?",
|
||||||
|
"i18n:govoplan-files.replacement.c385e347": "Replacement",
|
||||||
|
"i18n:govoplan-files.require_smb_signing.5168a83d": "Require SMB signing",
|
||||||
|
"i18n:govoplan-files.review_individually.19abbe58": "Review individually",
|
||||||
|
"i18n:govoplan-files.root.e96857c5": "Wurzel",
|
||||||
|
"i18n:govoplan-files.save_connection.07796d38": "Save connection",
|
||||||
|
"i18n:govoplan-files.save_credential.2c02a6d2": "Save credential",
|
||||||
|
"i18n:govoplan-files.save_policy.77d67ce3": "Save policy",
|
||||||
|
"i18n:govoplan-files.saving.56a2285c": "Saving…",
|
||||||
|
"i18n:govoplan-files.saving.ae7e8875": "Saving...",
|
||||||
|
"i18n:govoplan-files.seafile.600192cc": "Seafile",
|
||||||
|
"i18n:govoplan-files.search_this_folder_pdf_pdf_or_2026_pdf.74dec36a": "Search this folder: *.pdf, **/*.pdf or 2026-??-*.pdf",
|
||||||
|
"i18n:govoplan-files.search.bce06414": "Suchen",
|
||||||
|
"i18n:govoplan-files.secret_configured.4dc0f095": "Secret configured",
|
||||||
|
"i18n:govoplan-files.secret_reference.04ed2221": "Secret reference",
|
||||||
|
"i18n:govoplan-files.select_a_remote_file_to_sync_into_the_destinatio.80477a47": "Select a remote file to sync into the destination folder.",
|
||||||
|
"i18n:govoplan-files.select_a_remote_file_to_sync_it_into_this_space_.8e58a5ae": "Select a remote file to sync it into this space owner.",
|
||||||
|
"i18n:govoplan-files.select_the_space_that_will_receive_the_selected_.0a8bea8f": "Select the space that will receive the selected file(s) or folder(s).",
|
||||||
|
"i18n:govoplan-files.selected.840fac38": " · Selected",
|
||||||
|
"i18n:govoplan-files.show_fewer.d94dfbe5": "Show fewer",
|
||||||
|
"i18n:govoplan-files.size.b7152342": "Größe",
|
||||||
|
"i18n:govoplan-files.skip.3da47453": "Skip",
|
||||||
|
"i18n:govoplan-files.smb_authentication.96f7cb83": "SMB authentication",
|
||||||
|
"i18n:govoplan-files.smb.515d2f88": "SMB",
|
||||||
|
"i18n:govoplan-files.space_label.ec892726": "Space label",
|
||||||
|
"i18n:govoplan-files.spaces.9b584b52": "Spaces",
|
||||||
|
"i18n:govoplan-files.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-files.suffix.885db975": "Suffix",
|
||||||
|
"i18n:govoplan-files.sync_to_value_value.29c362ea": "Sync to {value0} / {value1}",
|
||||||
|
"i18n:govoplan-files.sync.905f6309": "Sync",
|
||||||
|
"i18n:govoplan-files.synced_new_file.d5e8243d": "Synced new file",
|
||||||
|
"i18n:govoplan-files.synced_new_version.83784264": "Synced new version",
|
||||||
|
"i18n:govoplan-files.system.bc0792d8": "System",
|
||||||
|
"i18n:govoplan-files.target.61ad50a9": "Target",
|
||||||
|
"i18n:govoplan-files.tb.f8a902b0": "TB",
|
||||||
|
"i18n:govoplan-files.tenant.3ca93c78": "Tenant",
|
||||||
|
"i18n:govoplan-files.test_value.6a5d10a5": "Test {value0}",
|
||||||
|
"i18n:govoplan-files.testing.15ccc832": "Testing...",
|
||||||
|
"i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729": "The configured managed file space is no longer available to this user.",
|
||||||
|
"i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f": "This attachment base path is not connected to a managed file space. Choose its folder under",
|
||||||
|
"i18n:govoplan-files.this_file_connection.edcde997": "this file connection",
|
||||||
|
"i18n:govoplan-files.this_file_credential.695efb90": "this file credential",
|
||||||
|
"i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a": "This folder is empty. Upload files in the top-level Files module.",
|
||||||
|
"i18n:govoplan-files.this_folder_is_empty_use_upload_or_create_folder.5977d784": "This folder is empty. Use Upload or Create Folder to add content.",
|
||||||
|
"i18n:govoplan-files.token_environment_variable.5af4a79e": "Token environment variable",
|
||||||
|
"i18n:govoplan-files.token.a1141eb9": "Token",
|
||||||
|
"i18n:govoplan-files.unpack_zip_uploads.256fead4": "Unpack ZIP uploads",
|
||||||
|
"i18n:govoplan-files.unpacking_zip_archive.698095f4": "Unpacking ZIP archive",
|
||||||
|
"i18n:govoplan-files.unpacking_zip_upload.35019691": "Unpacking ZIP upload…",
|
||||||
|
"i18n:govoplan-files.up.2038bdec": "Up",
|
||||||
|
"i18n:govoplan-files.upload_failed_because_the_network_request_could_.360a5ab3": "Upload failed because the network request could not be completed.",
|
||||||
|
"i18n:govoplan-files.upload_to_value_value.b83a34b4": "Upload to {value0} / {value1}",
|
||||||
|
"i18n:govoplan-files.upload_was_cancelled.5bab0f9b": "Upload was cancelled.",
|
||||||
|
"i18n:govoplan-files.upload.8bdf057f": "Hochladen",
|
||||||
|
"i18n:govoplan-files.uploaded_value_file_s_into_value.d0fa052b": "Uploaded {value0} file(s) into {value1}.",
|
||||||
|
"i18n:govoplan-files.uploading_files.6536791d": "Uploading files",
|
||||||
|
"i18n:govoplan-files.uploading_value_file_s.715ba963": "Uploading {value0} file(s)…",
|
||||||
|
"i18n:govoplan-files.use_pattern.ce54ba3e": "Use pattern",
|
||||||
|
"i18n:govoplan-files.use_this_folder.0e77d6d1": "Use this folder",
|
||||||
|
"i18n:govoplan-files.user.9f8a2389": "User",
|
||||||
|
"i18n:govoplan-files.users": "Benutzer",
|
||||||
|
"i18n:govoplan-files.username_password.e8ba8896": "Username/password",
|
||||||
|
"i18n:govoplan-files.username.84c29015": "Benutzername",
|
||||||
|
"i18n:govoplan-files.value_conflict_s.b3872a48": "{value0} conflict(s)",
|
||||||
|
"i18n:govoplan-files.value_connector_policy_saved.430d4eca": "{value0} connector policy saved.",
|
||||||
|
"i18n:govoplan-files.value_connector_policy.0bf7f53b": "{value0} connector policy",
|
||||||
|
"i18n:govoplan-files.value_file_shown_for_context.3d6e0acb": "{value0}, file shown for context",
|
||||||
|
"i18n:govoplan-files.value_folder_s_value_file_s.76b92c8c": "{value0} folder(s) · {value1} file(s)",
|
||||||
|
"i18n:govoplan-files.value_this_selection_would_create_duplicate_visi.26ae34d4": "{value0} this selection would create duplicate visible names in the destination.",
|
||||||
|
"i18n:govoplan-files.value_upload_conflict_s.3a04f117": "{value0} upload conflict(s)",
|
||||||
|
"i18n:govoplan-files.value_value_activate_to_sort.3953ba71": "{value0}: {value1}. Activate to sort.",
|
||||||
|
"i18n:govoplan-files.value_value_file_s_and_value_folder_s.c160e725": "{value0} {value1} file(s) and {value2} folder(s).",
|
||||||
|
"i18n:govoplan-files.value_value.36bc3a40": "{value0}: {value1}.",
|
||||||
|
"i18n:govoplan-files.value_value.598aab59": "{value0} -> {value1}",
|
||||||
|
"i18n:govoplan-files.value_value.c189e8bc": "{value0} ({value1})",
|
||||||
|
"i18n:govoplan-files.value_value.dca59cc0": "{value0} {value1}",
|
||||||
|
"i18n:govoplan-files.value.48afe802": "· {value0}",
|
||||||
|
"i18n:govoplan-files.value_file_connections": "{value0}-Dateiverbindungen",
|
||||||
|
"i18n:govoplan-files.value_scope": "Geltungsbereich: {value0}",
|
||||||
|
"i18n:govoplan-files.visible_file_s_were_not_matched.dc497e90": "visible file(s) were not matched.",
|
||||||
|
"i18n:govoplan-files.webdav.521b4c8b": "WebDAV",
|
||||||
|
"i18n:govoplan-files.working.13b7bfca": "Wird ausgeführt...",
|
||||||
|
"i18n:govoplan-files.writable.dd35487a": "Writable"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import "./styles/file-manager.css";
|
|
||||||
export { default } from "./module";
|
export { default } from "./module";
|
||||||
export * from "./module";
|
export * from "./module";
|
||||||
export { default as FilesPage } from "./features/files/FilesPage";
|
export { default as FilesPage } from "./features/files/FilesPage";
|
||||||
export * from "./api/files";
|
export * from "./api/files";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
export { FolderTree } from "./features/files/components/FileManagerComponents";
|
export { FolderTree } from "./features/files/components/FileManagerComponents";
|
||||||
|
export { default as ManagedFileChooser } from "./features/files/components/ManagedFileChooser";
|
||||||
|
export type { ManagedAttachmentSelection, ManagedFolderSelection } from "./features/files/components/ManagedFileChooser";
|
||||||
export { useFileTreeState } from "./features/files/hooks/useFileTreeState";
|
export { useFileTreeState } from "./features/files/hooks/useFileTreeState";
|
||||||
export type { FolderNode, SortColumn, SortDirection } from "./features/files/types";
|
export type { FolderNode, SortColumn, SortDirection } from "./features/files/types";
|
||||||
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils";
|
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils";
|
||||||
|
|||||||
@@ -1,22 +1,73 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { FilesFileExplorerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import { hasScope, type AdminSectionsUiCapability, type FilesConnectorsUiCapability, type FilesFileExplorerUiCapability, type PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import { FolderTree } from "./features/files/components/FileManagerComponents";
|
import { FolderTree } from "./features/files/components/FileManagerComponents";
|
||||||
|
import FileConnectorSettingsPanel from "./features/files/FileConnectorSettingsPanel";
|
||||||
|
import ManagedFileChooser from "./features/files/components/ManagedFileChooser";
|
||||||
|
import { listFileSpaces, listFiles, listFilesDelta, listFolders, resolveFilePatterns, shareFilesWithTarget } from "./api/files";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import "./styles/file-manager.css";
|
||||||
|
|
||||||
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
const fileRead = ["files:file:read"];
|
const fileRead = ["files:file:read"];
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "system-file-connectors",
|
||||||
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 75,
|
||||||
|
anyOf: ["system:settings:read", "files:file:admin"],
|
||||||
|
render: ({ settings, auth }) => createElement(FileConnectorSettingsPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "system",
|
||||||
|
canWrite: hasScope(auth, "system:settings:write") || hasScope(auth, "files:file:admin")
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-file-connectors",
|
||||||
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 65,
|
||||||
|
anyOf: ["admin:settings:read", "files:file:admin"],
|
||||||
|
render: ({ settings, auth }) => createElement(FileConnectorSettingsPanel, {
|
||||||
|
settings,
|
||||||
|
scopeType: "tenant",
|
||||||
|
canWrite: hasScope(auth, "admin:settings:write") || hasScope(auth, "files:file:admin")
|
||||||
|
})
|
||||||
|
}]
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "i18n:govoplan-files.files.6ce6c512",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }],
|
translations,
|
||||||
|
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }
|
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
|
||||||
],
|
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"files.fileExplorer": { FolderTree } satisfies FilesFileExplorerUiCapability
|
"files.fileExplorer": {
|
||||||
|
FolderTree,
|
||||||
|
ManagedFileChooser,
|
||||||
|
listFileSpaces,
|
||||||
|
listFiles,
|
||||||
|
listFilesDelta,
|
||||||
|
listFolders,
|
||||||
|
resolveFilePatterns,
|
||||||
|
shareFilesWithTarget
|
||||||
|
} satisfies FilesFileExplorerUiCapability,
|
||||||
|
"files.connectors": {
|
||||||
|
FileConnectorScopeManager: FileConnectorSettingsPanel
|
||||||
|
} satisfies FilesConnectorsUiCapability,
|
||||||
|
"admin.sections": fileConnectorAdminSections
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,134 +35,6 @@
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-heading {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 13px 14px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: .05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-list {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0px 0px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-space + .file-tree-space {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node-wrap {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 26px minmax(0, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
border-radius: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node,
|
|
||||||
.file-tree-select,
|
|
||||||
.file-tree-toggle {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-toggle {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 26px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 0px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-toggle:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: .55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 9px;
|
|
||||||
border-radius: 0px;
|
|
||||||
font: inherit;
|
|
||||||
text-align: left;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-root {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-select {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-weight: 900;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node-wrap:hover,
|
|
||||||
.file-tree-node-wrap:focus-within,
|
|
||||||
.file-tree-node-wrap.is-active,
|
|
||||||
.file-tree-root:hover:not(:disabled),
|
|
||||||
.file-tree-root:focus-visible,
|
|
||||||
.file-tree-root.is-active {
|
|
||||||
background: var(--line);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node:focus-visible,
|
|
||||||
.file-tree-toggle:focus-visible,
|
|
||||||
.file-tree-select:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node-wrap.is-drop-target,
|
|
||||||
.file-tree-root.is-drop-target {
|
|
||||||
background: var(--line);
|
|
||||||
box-shadow: inset 0 0 0 2px var(--line-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-node-wrap.is-selected .file-tree-select {
|
|
||||||
background: #0d6efd;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-tree-children {
|
|
||||||
display: grid;
|
|
||||||
gap: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list-panel {
|
.file-list-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -391,6 +263,11 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-list-virtual-spacer {
|
||||||
|
min-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.file-context-menu {
|
.file-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
@@ -569,6 +446,278 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-dialog:has(.connector-sync-grid) {
|
||||||
|
width: min(820px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-sync-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
min-height: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-path {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row:hover:not(:disabled),
|
||||||
|
.connector-browser-row:focus-visible,
|
||||||
|
.connector-browser-row.is-selected {
|
||||||
|
background: var(--line);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row.is-selected {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row > span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row strong,
|
||||||
|
.connector-browser-row small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-row small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser-empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 120px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-space-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-space-browser {
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-sync-selection {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-sync-selection strong,
|
||||||
|
.connector-sync-selection small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-sync-selection small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-settings-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-settings-empty,
|
||||||
|
.file-connector-profile-row,
|
||||||
|
.file-connector-profile-main,
|
||||||
|
.file-connector-profile-actions,
|
||||||
|
.file-connector-settings-section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-settings-empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-main {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-main strong,
|
||||||
|
.file-connector-profile-main small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-main small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-test-ok {
|
||||||
|
color: var(--success-text, var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-test-error {
|
||||||
|
color: var(--danger-text, var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-actions,
|
||||||
|
.file-connector-settings-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-settings-section {
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-policy-locks {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-policy-sources {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-settings-toggle-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-provider-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-provider-switch {
|
||||||
|
grid-auto-columns: minmax(86px, 1fr);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-provider-switch .segmented-control-option {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.file-connector-profile-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-connector-profile-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.connector-sync-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector-browser {
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1050px) {
|
@media (max-width: 1050px) {
|
||||||
.file-manager-shell {
|
.file-manager-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -698,3 +847,478 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Managed file chooser exposed through the files.fileExplorer capability. */
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
width: min(1080px, calc(100vw - 40px));
|
||||||
|
height: min(820px, calc(100vh - 40px));
|
||||||
|
max-height: min(820px, calc(100vh - 40px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-dialog > .dialog-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-body > .alert {
|
||||||
|
margin: 14px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-loading-frame {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-loading-frame > .managed-file-chooser-layout {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces.file-tree-panel {
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces .file-tree-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces .file-tree-space.is-active {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button,
|
||||||
|
.managed-file-entry {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 9px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button span,
|
||||||
|
.managed-file-entry > span:not(.managed-file-shared-badge) {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button small,
|
||||||
|
.managed-file-entry small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button:hover:not(:disabled),
|
||||||
|
.managed-file-space-button.is-selected {
|
||||||
|
border-color: var(--line-dark);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-space-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-browser.folder-mode {
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-breadcrumb > button,
|
||||||
|
.managed-file-breadcrumb span button {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 5px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-breadcrumb button:hover:not(:disabled) {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-editor label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-head,
|
||||||
|
.managed-file-entry {
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) 110px 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-head button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 5px 0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-head button.is-sorted {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-list {
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry:hover:not(:disabled) {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry:disabled {
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry.is-folder:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry.is-context-only {
|
||||||
|
opacity: .56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry.is-parent:disabled {
|
||||||
|
opacity: .38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-name {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-name strong,
|
||||||
|
.managed-file-entry-name small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-name small {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-size,
|
||||||
|
.managed-file-entry-modified {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-shared-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--success-line, var(--line));
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--success, var(--muted));
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-virtual-spacer {
|
||||||
|
min-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-tree-virtual-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 80px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-results .file-list-table {
|
||||||
|
min-width: 680px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-result-row {
|
||||||
|
width: 100%;
|
||||||
|
border-top: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-result-row .file-list-name > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-result-row .file-list-name strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-result-row .file-list-name small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-pattern-rendered {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-field-action {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-field-action > input,
|
||||||
|
.split-field-action > select,
|
||||||
|
.split-field-action > textarea {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-field-action > button,
|
||||||
|
.split-field-action > .button,
|
||||||
|
.split-field-action > .btn {
|
||||||
|
align-self: stretch;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
.managed-file-entry-head,
|
||||||
|
.managed-file-entry {
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-entry-head > :last-child,
|
||||||
|
.managed-file-entry-modified {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-spaces {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user