mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-08 11:53:07 -04:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae53d5847e | ||
|
|
14b34903a7 | ||
|
|
a44539d5f6 | ||
|
|
69a1350921 | ||
|
|
d7375e9904 | ||
|
|
b4ed8c4aeb | ||
|
|
8fd9a82acf | ||
|
|
cd99a6fd44 | ||
|
|
c24b6f1c33 | ||
|
|
569e630fe0 | ||
|
|
7143dc9cba | ||
|
|
3f83cbc524 | ||
|
|
6ce5943e07 | ||
|
|
7eb08af8ae | ||
|
|
7306abaaf9 | ||
|
|
dd345bf48f | ||
|
|
f2e02e1f88 | ||
|
|
92a2980875 | ||
|
|
b25367a845 | ||
|
|
4d82968bd0 | ||
|
|
90cc015a9c | ||
|
|
6dcb7a1ff2 | ||
|
|
63664db2f6 | ||
|
|
f8e26d3198 | ||
|
|
a6ca2c538e | ||
|
|
56e03f12ce |
No files matched your search
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: bumping-web
|
||||
description: Use when the user asks to bump, update, or upgrade the OpenCloud web assets/frontend to a specific version (e.g. "bump web to v7.0.0", "update web to v7.1.2"). Covers editing the version files, the single commit, and opening the PR against the right branch.
|
||||
---
|
||||
|
||||
# Bumping Web
|
||||
|
||||
## Overview
|
||||
|
||||
Bumping web updates the pinned OpenCloud web frontend (assets + UI-test runner) to a tagged release of [opencloud-eu/web](https://github.com/opencloud-eu/web). It touches exactly two files, lands as one commit, and ships as a PR whose body is the web changelog for that version.
|
||||
|
||||
Template PR: https://github.com/opencloud-eu/opencloud/pull/2733
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **`gh` (GitHub CLI), authenticated** — every lookup and the PR creation go through `gh api` / `gh pr`. Verify with `gh auth status`; if it fails, ask the user to run `gh auth login` (suggest `! gh auth login` so it runs in-session). Do not proceed without it.
|
||||
- `gh` needs the system keyring, so run all `gh` commands with the sandbox disabled.
|
||||
- `git` and `base64` (decoding the changelog) — standard on macOS/Linux.
|
||||
|
||||
## What changes (exactly two files)
|
||||
|
||||
| File | Variable | New value |
|
||||
| ----------------------- | -------------------- | ------------------------------------------------ |
|
||||
| `services/web/Makefile` | `WEB_ASSETS_VERSION` | the version tag, e.g. `v7.0.0` |
|
||||
| `services/web/Makefile` | `WEB_ASSETS_BRANCH` | branch carrying the tag (`main` or `stable-X.Y`) |
|
||||
| `.woodpecker.env` | `WEB_COMMITID` | full commit sha the tag points to |
|
||||
| `.woodpecker.env` | `WEB_BRANCH` | same branch as `WEB_ASSETS_BRANCH` |
|
||||
|
||||
`WEB_ASSETS_BRANCH` and `WEB_BRANCH` are always the same value.
|
||||
|
||||
## Procedure
|
||||
|
||||
Let `VERSION` be the requested tag (always normalize to a leading `v`, e.g. `v7.0.0`).
|
||||
|
||||
### 1. Resolve the commit sha the tag points to
|
||||
|
||||
```bash
|
||||
gh api repos/opencloud-eu/web/commits/$VERSION --jq '.sha'
|
||||
```
|
||||
|
||||
This full sha is the new `WEB_COMMITID`.
|
||||
|
||||
### 2. Determine the branch (`main` vs `stable-X.Y`)
|
||||
|
||||
The branch is the line of development that carries the tag. Detect it:
|
||||
|
||||
```bash
|
||||
gh api "repos/opencloud-eu/web/compare/main...$VERSION" --jq '.status'
|
||||
```
|
||||
|
||||
- `identical` or `behind` → the tagged commit is reachable from `main` → use **`main`**.
|
||||
- `ahead` or `diverged` → the tag lives on a release line → use **`stable-X.Y`** matching the version's major.minor (e.g. `v7.1.2` → `stable-7.1`).
|
||||
|
||||
Sanity-check that the stable branch actually exists:
|
||||
|
||||
```bash
|
||||
gh api repos/opencloud-eu/web/branches --paginate --jq '.[].name' | grep -E 'main|stable'
|
||||
```
|
||||
|
||||
Typically a freshly released minor (`v7.1.0`) still sits on `main`, while later patches on an older line (`v7.0.3` after `7.1` exists) sit on `stable-7.0`.
|
||||
|
||||
### 3. Fetch the changelog for the PR body
|
||||
|
||||
```bash
|
||||
gh api "repos/opencloud-eu/web/contents/CHANGELOG.md?ref=$VERSION" --jq '.content' | base64 -d
|
||||
```
|
||||
|
||||
Take **only the section for this version**. Match the template: start the PR body at the first content heading (e.g. `### 💥 Breaking changes` / `### 📈 Enhancement`) and drop the `# Changelog` title, the `## [x.y.z] - date` header, and the `### ❤️ Thanks to all contributors!` block. Stop before the next `## [...]` version header.
|
||||
|
||||
### 4. Apply the edits
|
||||
|
||||
Edit `services/web/Makefile` (`WEB_ASSETS_VERSION`, `WEB_ASSETS_BRANCH`) and `.woodpecker.env` (`WEB_COMMITID`, `WEB_BRANCH`).
|
||||
|
||||
### 5. Pick the target branch for the PR
|
||||
|
||||
- Major or minor release → target **`main`**.
|
||||
- Patch release → may need to target a stable branch instead. If the user did not specify, **ask them** which branch to target before continuing.
|
||||
|
||||
### 6. Confirm before committing
|
||||
|
||||
Show the user the diff of both files and the target branch, and ask them to confirm. Do not commit until they approve.
|
||||
|
||||
### 7. Commit, push, open PR
|
||||
|
||||
- Create a branch (do not commit on `main`).
|
||||
- One commit, conventional-commits format, **empty body**:
|
||||
```
|
||||
chore: bump web to v7.0.0
|
||||
```
|
||||
- PR title: `[full-ci] chore: bump web to v7.0.0` (the commit message prefixed with `[full-ci] `).
|
||||
- PR base: the branch chosen in step 5.
|
||||
- PR body: the trimmed changelog from step 3.
|
||||
- Add the label `Type:Dependencies`.
|
||||
|
||||
```bash
|
||||
gh pr create --base <target-branch> \
|
||||
--title "[full-ci] chore: bump web to $VERSION" \
|
||||
--label "Type:Dependencies" \
|
||||
--body-file <changelog-file>
|
||||
```
|
||||
|
||||
(`gh` commands need the sandbox disabled — they require the system keyring.)
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
VERSION=v7.0.0
|
||||
gh api repos/opencloud-eu/web/commits/$VERSION --jq '.sha' # WEB_COMMITID
|
||||
gh api "repos/opencloud-eu/web/compare/main...$VERSION" --jq '.status' # main vs stable
|
||||
gh api "repos/opencloud-eu/web/contents/CHANGELOG.md?ref=$VERSION" --jq '.content' | base64 -d # changelog
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Reading `WEB_COMMITID` or the changelog from web `main` instead of from the version tag (`?ref=$VERSION`). Always pin to the tag.
|
||||
- Leaving `WEB_ASSETS_BRANCH`/`WEB_BRANCH` on `main` for a patch that belongs on a stable line.
|
||||
- Forgetting `[full-ci] ` in the PR title or adding a commit body.
|
||||
- Committing before the user confirms the diff and target branch.
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills
|
||||
@@ -1 +0,0 @@
|
||||
github: opencloud-eu
|
||||
@@ -1,30 +0,0 @@
|
||||
### Rolling release template
|
||||
[Release Template](https://github.com/opencloud-eu/opencloud/blob/main/.github/rolling_release_template.md)
|
||||
|
||||
## Prerequisites
|
||||
* [ ] web release
|
||||
* [ ] bump web version
|
||||
* [ ] squash and merge the web release PR
|
||||
* [ ] bump web v.x.y.z in opencloud
|
||||
|
||||
* [ ] reva release
|
||||
* [ ] squash and merge the reva Release PR
|
||||
* [ ] bump reva and update opencloud version in `pkg/version.go`
|
||||
|
||||
## QA Phase
|
||||
* [ ] bump `opencloud_commitid` in web and run all working tests in CI
|
||||
* [ ] compatibility test
|
||||
* [ ] n8n integration QA
|
||||
* [ ] confirmatory testing, if needed
|
||||
|
||||
## Collected bugs
|
||||
|
||||
## After QA Phase
|
||||
* [ ] replace `%%NEXT%%` wuth the release version
|
||||
* [ ] squash and merge Release PR
|
||||
* [ ] publish release notes to the docs
|
||||
* [ ] add migration guide to changelog with prefix `**ACTION REQUIRED:**`, if needed.
|
||||
* [ ] add release notes from web and reva to opencloud changelog
|
||||
* [ ] update the public matrix channel topic
|
||||
* [ ] update https://update.opencloud.eu/server.json
|
||||
* [ ] update the version on demo.opencloud.eu
|
||||
@@ -1,2 +1,4 @@
|
||||
_extends: gh-labels
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -65,4 +65,4 @@ go.work.sum
|
||||
.DS_Store
|
||||
|
||||
# example deployments
|
||||
**/opencloud-sandbox-*
|
||||
**/opencloud-sandbox-*
|
||||
Vendored
-151
@@ -76,157 +76,6 @@
|
||||
"OC_SERVICE_ACCOUNT_SECRET": "service-account-secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OpenCloud server with Groupware",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"buildFlags": [
|
||||
// "-tags", "enable_vips"
|
||||
],
|
||||
"program": "${workspaceFolder}/opencloud/cmd/opencloud",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
// log settings for human developers
|
||||
"OC_LOG_LEVEL": "info",
|
||||
"OC_LOG_PRETTY": "true",
|
||||
"OC_LOG_COLOR": "true",
|
||||
// set insecure options because we don't have valid certificates in dev environments
|
||||
"OC_INSECURE": "true",
|
||||
// enable basic auth for dev setup so that we can use curl for testing
|
||||
"PROXY_ENABLE_BASIC_AUTH": "true",
|
||||
// demo users
|
||||
"IDM_CREATE_DEMO_USERS": "true",
|
||||
// OC_RUN_SERVICES allows to start a subset of services even in the supervised mode
|
||||
//"OC_RUN_SERVICES": "settings,storage-system,graph,idp,idm,ocs,store,thumbnails,web,webdav,frontend,gateway,users,groups,auth-basic,storage-authmachine,storage-users,storage-shares,storage-publiclink,storage-system,app-provider,sharing,proxy,ocdav",
|
||||
|
||||
/*
|
||||
* Keep secrets and passwords in one block to allow easy uncommenting
|
||||
*/
|
||||
// user id of "admin", for user creation and admin role assignement
|
||||
"OC_ADMIN_USER_ID": "some-admin-user-id-0000-000000000000", // FIXME currently must have the length of a UUID, see reva/pkg/storage/utils/decomposedfs/spaces.go:228
|
||||
// admin user default password
|
||||
"IDM_ADMIN_PASSWORD": "admin",
|
||||
// system user
|
||||
"OC_SYSTEM_USER_ID": "some-system-user-id-000-000000000000", // FIXME currently must have the length of a UUID, see reva/pkg/storage/utils/decomposedfs/spaces.go:228
|
||||
"OC_SYSTEM_USER_API_KEY": "some-system-user-machine-auth-api-key",
|
||||
// set some hardcoded secrets
|
||||
"OC_JWT_SECRET": "some-opencloud-jwt-secret",
|
||||
"OC_MACHINE_AUTH_API_KEY": "some-opencloud-machine-auth-api-key",
|
||||
"OC_TRANSFER_SECRET": "some-opencloud-transfer-secret",
|
||||
// collaboration
|
||||
"COLLABORATION_WOPIAPP_SECRET": "some-wopi-secret",
|
||||
// idm ldap
|
||||
"IDM_SVC_PASSWORD": "some-ldap-idm-password",
|
||||
"GRAPH_LDAP_BIND_PASSWORD": "some-ldap-idm-password",
|
||||
// reva ldap
|
||||
"IDM_REVASVC_PASSWORD": "some-ldap-reva-password",
|
||||
"GROUPS_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
|
||||
"USERS_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
|
||||
"AUTH_BASIC_LDAP_BIND_PASSWORD": "some-ldap-reva-password",
|
||||
// idp ldap
|
||||
"IDM_IDPSVC_PASSWORD": "some-ldap-idp-password",
|
||||
"IDP_LDAP_BIND_PASSWORD": "some-ldap-idp-password",
|
||||
// storage users mount ID
|
||||
"GATEWAY_STORAGE_USERS_MOUNT_ID": "storage-users-1",
|
||||
"STORAGE_USERS_MOUNT_ID": "storage-users-1",
|
||||
// graph application ID
|
||||
"GRAPH_APPLICATION_ID": "application-1",
|
||||
|
||||
// service accounts
|
||||
"OC_SERVICE_ACCOUNT_ID": "service-account-id",
|
||||
"OC_SERVICE_ACCOUNT_SECRET": "service-account-secret",
|
||||
|
||||
"OC_ADD_RUN_SERVICES": "auth-api,groupware",
|
||||
|
||||
"GROUPWARE_LOG_LEVEL": "trace",
|
||||
"GROUPWARE_HTTP_TRACE_REQUESTS": "true",
|
||||
"GROUPWARE_HTTP_TRACE_MAX_REQUEST_BODY_SIZE": "8192",
|
||||
"GROUPWARE_HTTP_TRACE_RESPONSES": "true",
|
||||
"GROUPWARE_HTTP_TRACE_MAX_RESPONSE_BODY_SIZE": "8192",
|
||||
"GROUPWARE_JMAP_MASTER_USERNAME": "admin@example.org",
|
||||
"GROUPWARE_JMAP_MASTER_PASSWORD": "admin",
|
||||
"GROUPWARE_SEND_DURATIONS_RESPONSE": "true",
|
||||
"GROUPWARE_TLS_INSECURE": "true",
|
||||
"GROUPWARE_ENABLE_MOCK_DATA": "true",
|
||||
"GROUPWARE_DEBUG_ADDR": "0.0.0.0:9203",
|
||||
|
||||
"AUTHAPI_HTTP_ADDR": "0.0.0.0:10000",
|
||||
"AUTHAPI_AUTH_REQUIRE_SHARED_SECRET": "true",
|
||||
"AUTHAPI_AUTH_SHARED_SECRETS": "stalwart=maethaR9eiXaiph8ahn8ohH6dahPiequ;unused=eeyaigh6hae1zo5ahGeete6oohaiquei",
|
||||
|
||||
"WEB_ASSET_CORE_PATH": "${workspaceFolder}/../web/dist",
|
||||
"WEB_UI_CONFIG_FILE": "${workspaceFolder}/../web/dev/docker/opencloud.web.config.json",
|
||||
"FRONTEND_GROUPWARE_ENABLED": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OpenCloud server with external services",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"buildFlags": [
|
||||
// "-tags", "enable_vips"
|
||||
],
|
||||
"program": "${workspaceFolder}/opencloud/cmd/opencloud",
|
||||
"args": ["server"],
|
||||
"env": {
|
||||
"OC_URL": "https://localhost:9200/",
|
||||
"PROXY_DEBUG_ADDR": "0.0.0.0:9205",
|
||||
"OC_BASE_DATA_PATH": "${env:HOME}/.opencloud-with-external",
|
||||
"OC_CONFIG_DIR": "${env:HOME}/.opencloud-with-external/config",
|
||||
"GROUPWARE_LOG_LEVEL": "trace",
|
||||
"OC_LOG_LEVEL": "info",
|
||||
"OC_LOG_PRETTY": "true",
|
||||
"OC_LOG_COLOR": "true",
|
||||
"OC_INSECURE": "true",
|
||||
"PROXY_ENABLE_BASIC_AUTH": "false",
|
||||
"IDM_CREATE_DEMO_USERS": "false",
|
||||
"OC_LDAP_URI": "ldaps://localhost:636",
|
||||
"OC_LDAP_INSECURE": "true",
|
||||
"OC_LDAP_BIND_DN": "cn=admin,dc=opencloud,dc=eu",
|
||||
"OC_LDAP_BIND_PASSWORD": "admin",
|
||||
"OC_LDAP_GROUP_BASE_DN": "ou=groups,dc=opencloud,dc=eu",
|
||||
"OC_LDAP_GROUP_SCHEMA_ID": "entryUUID",
|
||||
"OC_LDAP_USER_BASE_DN": "ou=users,dc=opencloud,dc=eu",
|
||||
"OC_LDAP_USER_FILTER": "(objectclass=inetOrgPerson)",
|
||||
"OC_LDAP_USER_SCHEMA_ID": "entryUUID",
|
||||
"OC_LDAP_DISABLE_USER_MECHANISM": "none",
|
||||
"OC_LDAP_SERVER_WRITE_ENABLED": "false",
|
||||
"OC_EXCLUDE_RUN_SERVICES": "idm",
|
||||
"OC_ADD_RUN_SERVICES": "notifications,groupware",
|
||||
"NATS_NATS_HOST": "0.0.0.0",
|
||||
"NATS_NATS_PORT": "9233",
|
||||
"FRONTEND_ARCHIVER_MAX_SIZE": "10000000000",
|
||||
"MICRO_REGISTRY_ADDRESS": "127.0.0.1:9233",
|
||||
"NOTIFICATIONS_SMTP_HOST": "localhost",
|
||||
"NOTIFICATIONS_SMTP_PORT": "2500",
|
||||
"NOTIFICATIONS_SMTP_SENDER": "OpenCloud notifications <notifications@cloud.opencloud.test>",
|
||||
"NOTIFICATIONS_SMTP_USERNAME": "notifications@cloud.opencloud.test",
|
||||
"NOTIFICATIONS_SMTP_INSECURE": "true",
|
||||
"NOTIFICATIONS_SMTP_PASSWORD": "",
|
||||
"NOTIFICATIONS_SMTP_AUTHENTICATION": "",
|
||||
"NOTIFICATIONS_SMTP_ENCRYPTION": "none",
|
||||
"PROXY_AUTOPROVISION_ACCOUNTS": "false",
|
||||
"PROXY_ROLE_ASSIGNMENT_DRIVER": "oidc",
|
||||
"OC_OIDC_ISSUER": "https://keycloak.opencloud.test/realms/openCloud",
|
||||
"PROXY_OIDC_REWRITE_WELLKNOWN": "true",
|
||||
"WEB_OIDC_CLIENT_ID": "web",
|
||||
"PROXY_USER_OIDC_CLAIM": "uuid",
|
||||
"PROXY_USER_CS3_CLAIM": "userid",
|
||||
"WEB_OPTION_ACCOUNT_EDIT_LINK_HREF": "https://keycloak.opencloud.test/realms/openCloud/account",
|
||||
"OC_ADMIN_USER_ID": "",
|
||||
"SETTINGS_SETUP_DEFAULT_ASSIGNMENTS": "false",
|
||||
"GRAPH_ASSIGN_DEFAULT_USER_ROLE": "false",
|
||||
"GRAPH_USERNAME_MATCH": "none",
|
||||
"KEYCLOAK_DOMAIN": "keycloak.opencloud.test",
|
||||
"IDM_ADMIN_PASSWORD": "admin",
|
||||
"GRAPH_LDAP_SERVER_UUID": "true",
|
||||
"GRAPH_LDAP_GROUP_CREATE_BASE_DN": "ou=custom,ou=groups,dc=opencloud,dc=eu",
|
||||
"GRAPH_LDAP_REFINT_ENABLED": "true",
|
||||
"GATEWAY_GRPC_ADDR": "0.0.0.0:9142",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fed OpenCloud server",
|
||||
"type": "go",
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
# The test runner source for UI tests
|
||||
WEB_COMMITID=47a1cfb1dbda4479b88e831d089bfa54b1f4bb8d
|
||||
WEB_BRANCH=main
|
||||
WEB_COMMITID=cbe31176647b7e6f0c5ec75a2097072d380c7bd8
|
||||
WEB_BRANCH=stable-7.1
|
||||
|
||||
+124
-203
@@ -71,16 +71,15 @@ OC_DOMAIN = "%s:9200" % OC_SERVER_NAME
|
||||
FED_OC_SERVER_NAME = "federation-opencloud-server"
|
||||
OC_FED_URL = "https://%s:10200" % FED_OC_SERVER_NAME
|
||||
OC_FED_DOMAIN = "%s:10200" % FED_OC_SERVER_NAME
|
||||
MACHINE_AUTH_API_KEY = "fjsdlfgkjsdlktgersoiulersiltjlekir5[345;lesirtuwe542345wert"
|
||||
|
||||
event = {
|
||||
"base": {
|
||||
"event": ["push", "manual"],
|
||||
"branch": "main",
|
||||
"branch": "stable-*",
|
||||
},
|
||||
"cron": {
|
||||
"event": "cron",
|
||||
"branch": "main",
|
||||
"branch": "stable-*",
|
||||
},
|
||||
"pull_request": {
|
||||
"event": "pull_request",
|
||||
@@ -90,30 +89,6 @@ event = {
|
||||
},
|
||||
}
|
||||
|
||||
OPENCLOUD_STORAGES = ["posix", "decomposed"]
|
||||
API_TEST_NIGHTLY_CI_MATRIX = {
|
||||
"posix": [
|
||||
{
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": True,
|
||||
},
|
||||
{
|
||||
"withRemotePhp": True,
|
||||
"enableWatchFs": False,
|
||||
},
|
||||
],
|
||||
"decomposed": [
|
||||
{
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
},
|
||||
{
|
||||
"withRemotePhp": True,
|
||||
"enableWatchFs": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# configuration
|
||||
config = {
|
||||
"cs3ApiTests": {
|
||||
@@ -146,8 +121,7 @@ config = {
|
||||
"apiSettings",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
"withRemotePhp": [False],
|
||||
"emailNeeded": True,
|
||||
"extraTestEnvironment": {
|
||||
"EMAIL_HOST": "email",
|
||||
@@ -170,14 +144,14 @@ config = {
|
||||
#"collaborativePosix",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
},
|
||||
"graphUserGroup": {
|
||||
"suites": [
|
||||
"apiGraphUserGroup",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
},
|
||||
"spaces": {
|
||||
"suites": [
|
||||
@@ -235,7 +209,7 @@ config = {
|
||||
"apiNotification",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
"emailNeeded": True,
|
||||
"extraTestEnvironment": {
|
||||
"EMAIL_HOST": "email",
|
||||
@@ -277,7 +251,7 @@ config = {
|
||||
"apiOcm",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
"federationServer": True,
|
||||
"emailNeeded": True,
|
||||
"extraTestEnvironment": {
|
||||
@@ -313,15 +287,14 @@ config = {
|
||||
"apiAuthApp",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
"withRemotePhp": [False],
|
||||
},
|
||||
"cliCommands": {
|
||||
"suites": [
|
||||
"cliCommands",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
"antivirusNeeded": True,
|
||||
"extraServerEnvironment": {
|
||||
"ANTIVIRUS_SCANNER_TYPE": "clamav",
|
||||
@@ -329,13 +302,14 @@ config = {
|
||||
"OC_ASYNC_UPLOADS": True,
|
||||
"OC_ADD_RUN_SERVICES": "antivirus",
|
||||
},
|
||||
"storages": ["decomposed"],
|
||||
},
|
||||
"multiTenancy": {
|
||||
"suites": [
|
||||
"apiTenancy",
|
||||
],
|
||||
"skip": False,
|
||||
"withRemotePhp": False,
|
||||
"withRemotePhp": [False],
|
||||
"ldapNeeded": True,
|
||||
"extraTestEnvironment": {
|
||||
"USE_PREPARED_LDAP_USERS": True,
|
||||
@@ -429,9 +403,10 @@ config = {
|
||||
"architectures": ["arm64", "amd64"],
|
||||
"production": {
|
||||
# NOTE: need to be updated if new production releases are determined
|
||||
"tags": ["2.0", "4.0"],
|
||||
"tags": ["2.0", "4.0", "7.2"],
|
||||
# NOTE: need to be set to true if patch releases are made from stable-X-branches
|
||||
"skip_rolling": "false",
|
||||
"skip_rolling": False,
|
||||
"skip_daily": True,
|
||||
"repo": docker_repo_slug,
|
||||
"build_type": "production",
|
||||
},
|
||||
@@ -1210,60 +1185,6 @@ def wopiValidatorTests(ctx, storage, wopiServerType):
|
||||
])
|
||||
return [pipeline]
|
||||
|
||||
def build_api_test_workflow_matrix(ctx, storage, suite_cfg, default_cfg):
|
||||
"""
|
||||
Generates a matrix of feature combinations to run API tests with.
|
||||
|
||||
Args:
|
||||
ctx: woodpecker context
|
||||
storage: opencloud storage type
|
||||
suite_cfg: suite config. E.g.: config["localApiTests"]["spaces"]
|
||||
default_cfg: default suite config values
|
||||
|
||||
Returns:
|
||||
A matrix to run API tests with. E.g.:
|
||||
[
|
||||
{
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
},
|
||||
{
|
||||
"withRemotePhp": True,
|
||||
"enableWatchFs": False,
|
||||
},
|
||||
]
|
||||
"""
|
||||
|
||||
# default for PRs and commit push events
|
||||
matrices = [{
|
||||
"withRemotePhp": default_cfg["withRemotePhp"],
|
||||
"enableWatchFs": default_cfg["enableWatchFs"],
|
||||
}]
|
||||
if ctx.build.event == "cron":
|
||||
matrices = API_TEST_NIGHTLY_CI_MATRIX[storage]
|
||||
|
||||
override_with_remote_php = None
|
||||
override_enable_watch_fs = None
|
||||
if "withRemotePhp" in suite_cfg:
|
||||
override_with_remote_php = suite_cfg["withRemotePhp"]
|
||||
if "enableWatchFs" in suite_cfg:
|
||||
override_enable_watch_fs = suite_cfg["enableWatchFs"]
|
||||
|
||||
workflow_metrices = []
|
||||
for m in matrices:
|
||||
matrix = {
|
||||
"withRemotePhp": m["withRemotePhp"],
|
||||
"enableWatchFs": m["enableWatchFs"],
|
||||
}
|
||||
if override_with_remote_php != None:
|
||||
matrix["withRemotePhp"] = override_with_remote_php
|
||||
if override_enable_watch_fs != None:
|
||||
matrix["enableWatchFs"] = override_enable_watch_fs
|
||||
|
||||
if matrix not in workflow_metrices and matrix in matrices:
|
||||
workflow_metrices.append(matrix)
|
||||
return workflow_metrices
|
||||
|
||||
def localApiTestPipeline(ctx):
|
||||
pipelines = []
|
||||
|
||||
@@ -1279,8 +1200,8 @@ def localApiTestPipeline(ctx):
|
||||
"federationServer": False,
|
||||
"collaborationServiceNeeded": False,
|
||||
"extraCollaborationEnvironment": {},
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
"withRemotePhp": [False],
|
||||
"enableWatchFs": [False],
|
||||
"ldapNeeded": False,
|
||||
}
|
||||
|
||||
@@ -1291,69 +1212,68 @@ def localApiTestPipeline(ctx):
|
||||
for item in defaults:
|
||||
params[item] = matrix[item] if item in matrix else defaults[item]
|
||||
|
||||
if ctx.build.event == "cron":
|
||||
params["storages"] = OPENCLOUD_STORAGES
|
||||
|
||||
# skip CLI tests in nightly pipeline
|
||||
if name.startswith("cli"):
|
||||
continue
|
||||
|
||||
# use decomposed storage if specified in the PR title
|
||||
if "[decomposed]" in ctx.build.title.lower():
|
||||
# run CLI tests only with decomposed storage
|
||||
if "[decomposed]" in ctx.build.title.lower() or name.startswith("cli"):
|
||||
params["storages"] = ["decomposed"]
|
||||
|
||||
if ctx.build.event == "cron":
|
||||
params["withRemotePhp"] = [True, False]
|
||||
params["enableWatchFs"] = [True, False]
|
||||
|
||||
# override withRemotePhp if specified in the suite config
|
||||
if "withRemotePhp" in matrix:
|
||||
params["withRemotePhp"] = matrix["withRemotePhp"]
|
||||
|
||||
for storage in params["storages"]:
|
||||
matrices = build_api_test_workflow_matrix(ctx, storage, matrix, defaults)
|
||||
for m in matrices:
|
||||
run_with_remote_php = m["withRemotePhp"]
|
||||
run_with_watch_fs = m["enableWatchFs"]
|
||||
for run_with_remote_php in params["withRemotePhp"]:
|
||||
for run_with_watch_fs_enabled in params["enableWatchFs"]:
|
||||
pipeline_name = "test-API"
|
||||
if name.startswith("cli"):
|
||||
pipeline_name = "test-CLI"
|
||||
pipeline_name += "-%s" % name
|
||||
if not run_with_remote_php:
|
||||
pipeline_name += "-withoutRemotePhp"
|
||||
pipeline_name += "-%s" % storage
|
||||
if run_with_watch_fs_enabled:
|
||||
pipeline_name += "-watchfs"
|
||||
|
||||
pipeline_name = "test-API"
|
||||
if name.startswith("cli"):
|
||||
pipeline_name = "test-CLI"
|
||||
pipeline_name += "-%s" % name
|
||||
pipeline_name += "-%s" % storage
|
||||
if run_with_remote_php:
|
||||
pipeline_name += "-withRemotePhp"
|
||||
if run_with_watch_fs:
|
||||
pipeline_name += "-watchfs"
|
||||
|
||||
pipeline = {
|
||||
"name": pipeline_name,
|
||||
"steps": skipCheckStep(ctx, "acceptance-tests") + evaluateWorkflowStep() + restoreBuildArtifactCache(ctx, dirs["opencloudBinArtifact"], dirs["opencloudBinPath"]) +
|
||||
(tikaService() if params["tikaNeeded"] else []) +
|
||||
(waitForWebOffices(["https://collabora:9980", "https://onlyoffice", "http://fakeoffice:8080"]) if params["collaborationServiceNeeded"] else []) +
|
||||
(waitForClamavService() if params["antivirusNeeded"] else []) +
|
||||
(waitForEmailService() if params["emailNeeded"] else []) +
|
||||
(ldapService() if params["ldapNeeded"] else []) +
|
||||
(waitForLdapService() if params["ldapNeeded"] else []) +
|
||||
opencloudServer(
|
||||
storage,
|
||||
extra_server_environment = params["extraServerEnvironment"],
|
||||
with_wrapper = True,
|
||||
tika_enabled = params["tikaNeeded"],
|
||||
watch_fs_enabled = run_with_watch_fs,
|
||||
) +
|
||||
(opencloudServer(storage, deploy_type = "federation", extra_server_environment = params["extraServerEnvironment"], watch_fs_enabled = run_with_watch_fs) if params["federationServer"] else []) +
|
||||
((wopiCollaborationService("fakeoffice") + wopiCollaborationService("collabora") + wopiCollaborationService("onlyoffice")) if params["collaborationServiceNeeded"] else []) +
|
||||
(openCloudHealthCheck("wopi", ["wopi-collabora:9304", "wopi-onlyoffice:9304", "wopi-fakeoffice:9304"]) if params["collaborationServiceNeeded"] else []) +
|
||||
localApiTest(params["suites"], storage, params["extraTestEnvironment"], run_with_remote_php) +
|
||||
logRequests(),
|
||||
"services": (emailService() if params["emailNeeded"] else []) +
|
||||
(clamavService() if params["antivirusNeeded"] else []) +
|
||||
((fakeOffice() + collaboraService() + onlyofficeService()) if params["collaborationServiceNeeded"] else []),
|
||||
"depends_on": getPipelineNames(buildOpencloudBinaryForTesting(ctx)),
|
||||
"when": [
|
||||
event["base"],
|
||||
event["cron"],
|
||||
event["pull_request"],
|
||||
],
|
||||
}
|
||||
prefixStepCommands(pipeline, [
|
||||
". ./.woodpecker.env",
|
||||
'[ "$SKIP_WORKFLOW" = "true" ] && exit 0',
|
||||
])
|
||||
pipelines.append(pipeline)
|
||||
pipeline = {
|
||||
"name": pipeline_name,
|
||||
"steps": skipCheckStep(ctx, "acceptance-tests") + evaluateWorkflowStep() + restoreBuildArtifactCache(ctx, dirs["opencloudBinArtifact"], dirs["opencloudBinPath"]) +
|
||||
(tikaService() if params["tikaNeeded"] else []) +
|
||||
(waitForWebOffices(["https://collabora:9980", "https://onlyoffice", "http://fakeoffice:8080"]) if params["collaborationServiceNeeded"] else []) +
|
||||
(waitForClamavService() if params["antivirusNeeded"] else []) +
|
||||
(waitForEmailService() if params["emailNeeded"] else []) +
|
||||
(ldapService() if params["ldapNeeded"] else []) +
|
||||
(waitForLdapService() if params["ldapNeeded"] else []) +
|
||||
opencloudServer(
|
||||
storage,
|
||||
extra_server_environment = params["extraServerEnvironment"],
|
||||
with_wrapper = True,
|
||||
tika_enabled = params["tikaNeeded"],
|
||||
watch_fs_enabled = run_with_watch_fs_enabled,
|
||||
) +
|
||||
(opencloudServer(storage, deploy_type = "federation", extra_server_environment = params["extraServerEnvironment"], watch_fs_enabled = run_with_watch_fs_enabled) if params["federationServer"] else []) +
|
||||
((wopiCollaborationService("fakeoffice") + wopiCollaborationService("collabora") + wopiCollaborationService("onlyoffice")) if params["collaborationServiceNeeded"] else []) +
|
||||
(openCloudHealthCheck("wopi", ["wopi-collabora:9304", "wopi-onlyoffice:9304", "wopi-fakeoffice:9304"]) if params["collaborationServiceNeeded"] else []) +
|
||||
localApiTest(params["suites"], storage, params["extraTestEnvironment"], run_with_remote_php) +
|
||||
logRequests(),
|
||||
"services": (emailService() if params["emailNeeded"] else []) +
|
||||
(clamavService() if params["antivirusNeeded"] else []) +
|
||||
((fakeOffice() + collaboraService() + onlyofficeService()) if params["collaborationServiceNeeded"] else []),
|
||||
"depends_on": getPipelineNames(buildOpencloudBinaryForTesting(ctx)),
|
||||
"when": [
|
||||
event["base"],
|
||||
event["cron"],
|
||||
event["pull_request"],
|
||||
],
|
||||
}
|
||||
prefixStepCommands(pipeline, [
|
||||
". ./.woodpecker.env",
|
||||
'[ "$SKIP_WORKFLOW" = "true" ] && exit 0',
|
||||
])
|
||||
pipelines.append(pipeline)
|
||||
return pipelines
|
||||
|
||||
def localApiTest(suites, storage = "decomposed", extra_environment = {}, with_remote_php = False):
|
||||
@@ -1397,8 +1317,8 @@ def localApiTest(suites, storage = "decomposed", extra_environment = {}, with_re
|
||||
|
||||
def coreApiTestPipeline(ctx):
|
||||
defaults = {
|
||||
"withRemotePhp": False,
|
||||
"enableWatchFs": False,
|
||||
"withRemotePhp": [False],
|
||||
"enableWatchFs": [False],
|
||||
"storages": ["posix"],
|
||||
"numberOfParts": 7,
|
||||
"skipExceptParts": [],
|
||||
@@ -1415,60 +1335,63 @@ def coreApiTestPipeline(ctx):
|
||||
for item in defaults:
|
||||
params[item] = matrix[item] if item in matrix else defaults[item]
|
||||
|
||||
if ctx.build.event == "cron":
|
||||
params["storages"] = OPENCLOUD_STORAGES
|
||||
|
||||
# use decomposed storage if specified in the PR title
|
||||
if "[decomposed]" in ctx.build.title.lower():
|
||||
params["storages"] = ["decomposed"]
|
||||
|
||||
if ctx.build.event == "cron":
|
||||
params["withRemotePhp"] = [True, False]
|
||||
params["enableWatchFs"] = [True, False]
|
||||
|
||||
# override withRemotePhp if specified in the suite config
|
||||
if "withRemotePhp" in matrix:
|
||||
params["withRemotePhp"] = matrix["withRemotePhp"]
|
||||
|
||||
debugParts = params["skipExceptParts"]
|
||||
debugPartsEnabled = (len(debugParts) != 0)
|
||||
|
||||
for storage in params["storages"]:
|
||||
for runPart in range(1, params["numberOfParts"] + 1):
|
||||
matrices = build_api_test_workflow_matrix(ctx, storage, matrix, defaults)
|
||||
for m in matrices:
|
||||
run_with_remote_php = m["withRemotePhp"]
|
||||
run_with_watch_fs = m["enableWatchFs"]
|
||||
if not debugPartsEnabled or (debugPartsEnabled and runPart in debugParts):
|
||||
pipeline_name = "test-Core-API-%s" % runPart
|
||||
pipeline_name += "-%s" % storage
|
||||
if run_with_remote_php:
|
||||
pipeline_name += "-withRemotePhp"
|
||||
if run_with_watch_fs:
|
||||
pipeline_name += "-watchfs"
|
||||
for run_with_remote_php in params["withRemotePhp"]:
|
||||
for run_with_watch_fs_enabled in params["enableWatchFs"]:
|
||||
if not debugPartsEnabled or (debugPartsEnabled and runPart in debugParts):
|
||||
pipeline_name = "test-Core-API-%s" % runPart
|
||||
if not run_with_remote_php:
|
||||
pipeline_name += "-withoutRemotePhp"
|
||||
pipeline_name += "-%s" % storage
|
||||
if run_with_watch_fs_enabled:
|
||||
pipeline_name += "-watchfs"
|
||||
|
||||
pipeline = {
|
||||
"name": pipeline_name,
|
||||
"steps": skipCheckStep(ctx, "acceptance-tests") +
|
||||
evaluateWorkflowStep() +
|
||||
restoreBuildArtifactCache(ctx, dirs["opencloudBinArtifact"], dirs["opencloudBinPath"]) +
|
||||
opencloudServer(
|
||||
storage,
|
||||
with_wrapper = True,
|
||||
watch_fs_enabled = run_with_watch_fs,
|
||||
) +
|
||||
coreApiTest(
|
||||
runPart,
|
||||
params["numberOfParts"],
|
||||
run_with_remote_php,
|
||||
storage,
|
||||
) +
|
||||
logRequests(),
|
||||
"services": redisForOCStorage(storage),
|
||||
"depends_on": getPipelineNames(buildOpencloudBinaryForTesting(ctx)),
|
||||
"when": [
|
||||
event["base"],
|
||||
event["cron"],
|
||||
event["pull_request"],
|
||||
],
|
||||
}
|
||||
prefixStepCommands(pipeline, [
|
||||
". ./.woodpecker.env",
|
||||
'[ "$SKIP_WORKFLOW" = "true" ] && exit 0',
|
||||
])
|
||||
pipelines.append(pipeline)
|
||||
pipeline = {
|
||||
"name": pipeline_name,
|
||||
"steps": skipCheckStep(ctx, "acceptance-tests") +
|
||||
evaluateWorkflowStep() +
|
||||
restoreBuildArtifactCache(ctx, dirs["opencloudBinArtifact"], dirs["opencloudBinPath"]) +
|
||||
opencloudServer(
|
||||
storage,
|
||||
with_wrapper = True,
|
||||
watch_fs_enabled = run_with_watch_fs_enabled,
|
||||
) +
|
||||
coreApiTest(
|
||||
runPart,
|
||||
params["numberOfParts"],
|
||||
run_with_remote_php,
|
||||
storage,
|
||||
) +
|
||||
logRequests(),
|
||||
"services": redisForOCStorage(storage),
|
||||
"depends_on": getPipelineNames(buildOpencloudBinaryForTesting(ctx)),
|
||||
"when": [
|
||||
event["base"],
|
||||
event["cron"],
|
||||
event["pull_request"],
|
||||
],
|
||||
}
|
||||
prefixStepCommands(pipeline, [
|
||||
". ./.woodpecker.env",
|
||||
'[ "$SKIP_WORKFLOW" = "true" ] && exit 0',
|
||||
])
|
||||
pipelines.append(pipeline)
|
||||
return pipelines
|
||||
|
||||
def coreApiTest(part_number = 1, number_of_parts = 1, with_remote_php = False, storage = "posix"):
|
||||
@@ -1800,7 +1723,7 @@ def dockerReleases(ctx):
|
||||
docker_releases.append("rolling")
|
||||
|
||||
# on non tag events, do daily build
|
||||
else:
|
||||
elif not config["dockerReleases"]["production"]["skip_daily"]:
|
||||
docker_releases.append("daily")
|
||||
|
||||
for releaseConfigName in docker_releases:
|
||||
@@ -2162,6 +2085,7 @@ def readyReleaseGo():
|
||||
"image": READY_RELEASE_GO,
|
||||
"settings": {
|
||||
"git_email": "devops@opencloud.eu",
|
||||
"release_branch": "stable-7.2",
|
||||
"forge_type": "github",
|
||||
"forge_token": {
|
||||
"from_secret": "github_token",
|
||||
@@ -2415,7 +2339,6 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e
|
||||
"WEBDAV_DEBUG_ADDR": "0.0.0.0:9119",
|
||||
"WEBFINGER_DEBUG_ADDR": "0.0.0.0:9279",
|
||||
"STORAGE_USERS_POSIX_SCAN_DEBOUNCE_DELAY": 0,
|
||||
"OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY,
|
||||
}
|
||||
|
||||
if storage == "posix":
|
||||
@@ -3294,8 +3217,6 @@ def wopiCollaborationService(name):
|
||||
"COLLABORATION_CS3API_DATAGATEWAY_INSECURE": True,
|
||||
"OC_JWT_SECRET": "some-opencloud-jwt-secret",
|
||||
"COLLABORATION_WOPI_SECRET": "some-wopi-secret",
|
||||
"COLLABORATION_EVENTS_ENDPOINT": "%s:9233" % OC_SERVER_NAME,
|
||||
"OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY,
|
||||
}
|
||||
|
||||
if name == "collabora":
|
||||
|
||||
@@ -27,7 +27,6 @@ OC_MODULES = \
|
||||
services/app-provider \
|
||||
services/app-registry \
|
||||
services/audit \
|
||||
services/auth-api \
|
||||
services/auth-app \
|
||||
services/auth-basic \
|
||||
services/auth-bearer \
|
||||
@@ -40,7 +39,6 @@ OC_MODULES = \
|
||||
services/gateway \
|
||||
services/graph \
|
||||
services/groups \
|
||||
services/groupware \
|
||||
services/idm \
|
||||
services/idp \
|
||||
services/invitations \
|
||||
|
||||
@@ -305,21 +305,8 @@ KEYCLOAK_ADMIN_PASSWORD=
|
||||
# Leaving it default stores data in docker internal volumes.
|
||||
#RADICALE_DATA_DIR=/your/local/radicale/data
|
||||
|
||||
### Stalwart Settings ###
|
||||
# Note: the leading colon is required to enable the service.
|
||||
#STALWART=:stalwart.yml
|
||||
# Domain of Stalwart
|
||||
# Defaults to "stalwart.opencloud.test"
|
||||
STALWART_DOMAIN=
|
||||
# LDAP configuration to use for Stalwart:
|
||||
# Can either be either
|
||||
# - idmldap: for the built-in IDP/IDM, using Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
# - idmoidc: built-in IDP/IDM, using OIDC Userinfo between Groupware and Stalwart
|
||||
# - ldap: when using KeyCloak and OpenLDAP, with Master Authentication between Groupware and Stalwart, and LDAP in Stalwart
|
||||
STALWART_AUTH_DIRECTORY=idmldap
|
||||
|
||||
## IMPORTANT ##
|
||||
# This MUST be the last line as it assembles the supplemental compose files to be used.
|
||||
# ALL supplemental configs must be added here, whether commented or not.
|
||||
# Each var must either be empty or contain :path/file.yml
|
||||
COMPOSE_FILE=docker-compose.yml${OPENCLOUD:-}${TIKA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${DECOMPOSED:-}${COLLABORA:-}${MONITORING:-}${IMPORTER:-}${CLAMAV:-}${INBUCKET:-}${EXTENSIONS:-}${UNZIP:-}${DRAWIO:-}${JSONVIEWER:-}${PROGRESSBARS:-}${EXTERNALSITES:-}${KEYCLOAK:-}${LDAP:-}${KEYCLOAK_AUTOPROVISIONING:-}${LDAP_MANAGER:-}${RADICALE:-}${STALWART:-}
|
||||
COMPOSE_FILE=docker-compose.yml${OPENCLOUD:-}${TIKA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${DECOMPOSED:-}${COLLABORA:-}${MONITORING:-}${IMPORTER:-}${CLAMAV:-}${INBUCKET:-}${EXTENSIONS:-}${UNZIP:-}${DRAWIO:-}${JSONVIEWER:-}${PROGRESSBARS:-}${EXTERNALSITES:-}${KEYCLOAK:-}${LDAP:-}${KEYCLOAK_AUTOPROVISIONING:-}${LDAP_MANAGER:-}${RADICALE:-}
|
||||
@@ -1,26 +0,0 @@
|
||||
dn: ou=policies,dc=opencloud,dc=eu
|
||||
objectClass: organizationalUnit
|
||||
objectClass: top
|
||||
ou: policies
|
||||
|
||||
dn: cn=default,ou=policies,dc=opencloud,dc=eu
|
||||
cn: default
|
||||
objectClass: pwdPolicy
|
||||
objectClass: person
|
||||
objectClass: top
|
||||
pwdAllowUserChange: TRUE
|
||||
pwdAttribute: userPassword
|
||||
pwdCheckQuality: 0
|
||||
pwdExpireWarning: 600
|
||||
pwdFailureCountInterval: 30
|
||||
pwdGraceAuthNLimit: 5
|
||||
pwdInHistory: 5
|
||||
pwdLockout: FALSE
|
||||
pwdLockoutDuration: 0
|
||||
pwdMaxAge: 0
|
||||
pwdMaxFailure: 5
|
||||
pwdMinAge: 0
|
||||
pwdMinLength: 1
|
||||
pwdMustChange: FALSE
|
||||
pwdSafeModify: FALSE
|
||||
sn: default
|
||||
@@ -1,2 +0,0 @@
|
||||
/*.crt
|
||||
/*.key
|
||||
@@ -1,21 +0,0 @@
|
||||
# Stalwart Configuration
|
||||
|
||||
The mechanics are currently to mount a different configuration file depending on the environment, as we support two scenarios that are described in [`services/groupware/DEVELOPER.md`](../../../../../services/groupware/DEVELOPER.md):
|
||||
|
||||
* «production» setup, with OpenLDAP and Keycloak containers
|
||||
* «homelab» setup, with the built-in IDM (LDAP) and IDP that run as part of the `opencloud` container
|
||||
|
||||
The Docker Compose setup (in [`stalwart.yml`](../../stalwart.yml)) mounts either [`idmldap.toml`](./idmldap.toml) or [`ldap.toml`](./ldap.toml) depending on how the variable `STALWART_AUTH_DIRECTORY` is set, which is either `idmldap` for the homelab setup, or `ldap` for the production setup.
|
||||
|
||||
This is thus all done automatically, but whenever changes are performed to Stalwart configuration files, they must be reflected across those two files, to keep them in sync, as the only entry that should differ is this one:
|
||||
|
||||
```ruby
|
||||
storage.directory = "ldap"
|
||||
```
|
||||
|
||||
or this:
|
||||
|
||||
```ruby
|
||||
storage.directory = "idmldap"
|
||||
```
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"@type":"RocksDb","path":"/var/lib/stalwart/","blobSize":16834,"bufferSize":134217728,"poolWorkers":null}
|
||||
@@ -1,110 +0,0 @@
|
||||
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.fallback-admin.user = "mailadmin"
|
||||
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.master.user = "master"
|
||||
directory.idmldap.attributes.class = "objectClass"
|
||||
directory.idmldap.attributes.description = "displayName"
|
||||
directory.idmldap.attributes.email = "mail"
|
||||
directory.idmldap.attributes.groups = "memberOf"
|
||||
directory.idmldap.attributes.name = "uid"
|
||||
directory.idmldap.attributes.secret = "userPassword"
|
||||
directory.idmldap.base-dn = "o=libregraph-idm"
|
||||
directory.idmldap.bind.auth.method = "default"
|
||||
directory.idmldap.bind.dn = "uid=reva,ou=sysusers,o=libregraph-idm"
|
||||
directory.idmldap.bind.secret = "admin"
|
||||
directory.idmldap.cache.size = 1048576
|
||||
directory.idmldap.cache.ttl.negative = "10m"
|
||||
directory.idmldap.cache.ttl.positive = "1h"
|
||||
directory.idmldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(mail=?))"
|
||||
directory.idmldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(uid=?))"
|
||||
directory.idmldap.timeout = "15s"
|
||||
directory.idmldap.tls.allow-invalid-certs = true
|
||||
directory.idmldap.tls.enable = true
|
||||
directory.idmldap.type = "ldap"
|
||||
directory.idmldap.url = "ldaps://opencloud:9235"
|
||||
directory.keycloak.auth.method = "user-token"
|
||||
directory.keycloak.cache.size = 1048576
|
||||
directory.keycloak.cache.ttl.negative = "10m"
|
||||
directory.keycloak.cache.ttl.positive = "1h"
|
||||
directory.keycloak.endpoint.method = "introspect"
|
||||
directory.keycloak.endpoint.url = "http://keycloak:8080/realms/openCloud/protocol/openid-connect/userinfo"
|
||||
directory.keycloak.fields.email = "email"
|
||||
directory.keycloak.fields.full-name = "name"
|
||||
directory.keycloak.fields.username = "preferred_username"
|
||||
directory.keycloak.timeout = "15s"
|
||||
directory.keycloak.type = "oidc"
|
||||
directory.ldap.attributes.class = "objectClass"
|
||||
directory.ldap.attributes.description = "displayName"
|
||||
directory.ldap.attributes.email = "mail"
|
||||
directory.ldap.attributes.email-alias = "mailAlias"
|
||||
directory.ldap.attributes.groups = "memberOf"
|
||||
directory.ldap.attributes.name = "uid"
|
||||
directory.ldap.attributes.secret = "userPassword"
|
||||
directory.ldap.attributes.secret-changed = "pwdChangedTime"
|
||||
directory.ldap.base-dn = "dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.auth.dn = "cn=?,ou=users,dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.auth.enable = true
|
||||
directory.ldap.bind.auth.search = true
|
||||
directory.ldap.bind.dn = "cn=admin,dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.secret = "admin"
|
||||
directory.ldap.cache.ttl.negative = "10m"
|
||||
directory.ldap.cache.ttl.positive = "1h"
|
||||
directory.ldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(mail=?)(mailAlias=?)(cn=?)))"
|
||||
directory.ldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(cn=?)))"
|
||||
directory.ldap.timeout = "5s"
|
||||
directory.ldap.tls.allow-invalid-certs = true
|
||||
directory.ldap.tls.enable = true
|
||||
directory.ldap.type = "ldap"
|
||||
directory.ldap.url = "ldap://ldap-server:1389"
|
||||
http.allowed-endpoint = 200
|
||||
http.hsts = true
|
||||
http.permissive-cors = false
|
||||
http.url = "'https://' + config_get('server.hostname')"
|
||||
http.use-x-forwarded = true
|
||||
metrics.prometheus.auth.secret = "secret"
|
||||
metrics.prometheus.auth.username = "metrics"
|
||||
metrics.prometheus.enable = true
|
||||
server.listener.http.bind = "0.0.0.0:8080"
|
||||
server.listener.http.protocol = "http"
|
||||
server.listener.https.bind = "0.0.0.0:443"
|
||||
server.listener.https.protocol = "http"
|
||||
server.listener.https.tls.implicit = true
|
||||
server.listener.imap.bind = "0.0.0.0:143"
|
||||
server.listener.imap.protocol = "imap"
|
||||
server.listener.imaptls.bind = "0.0.0.0:993"
|
||||
server.listener.imaptls.protocol = "imap"
|
||||
server.listener.imaptls.tls.implicit = true
|
||||
server.listener.pop3.bind = "0.0.0.0:110"
|
||||
server.listener.pop3.protocol = "pop3"
|
||||
server.listener.pop3s.bind = "0.0.0.0:995"
|
||||
server.listener.pop3s.protocol = "pop3"
|
||||
server.listener.pop3s.tls.implicit = true
|
||||
server.listener.sieve.bind = "0.0.0.0:4190"
|
||||
server.listener.sieve.protocol = "managesieve"
|
||||
server.listener.smtp.bind = "0.0.0.0:25"
|
||||
server.listener.smtp.protocol = "smtp"
|
||||
server.listener.submission.bind = "0.0.0.0:587"
|
||||
server.listener.submission.protocol = "smtp"
|
||||
server.listener.submissions.bind = "0.0.0.0:465"
|
||||
server.listener.submissions.protocol = "smtp"
|
||||
server.listener.submissions.tls.implicit = true
|
||||
server.max-connections = 8192
|
||||
server.socket.backlog = 1024
|
||||
server.socket.nodelay = true
|
||||
server.socket.reuse-addr = true
|
||||
server.socket.reuse-port = true
|
||||
storage.blob = "rocksdb"
|
||||
storage.data = "rocksdb"
|
||||
storage.directory = "%{env:STALWART_AUTH_DIRECTORY}%"
|
||||
storage.fts = "rocksdb"
|
||||
storage.lookup = "rocksdb"
|
||||
store.rocksdb.compression = "lz4"
|
||||
store.rocksdb.path = "/opt/stalwart/data"
|
||||
store.rocksdb.type = "rocksdb"
|
||||
tracer.console.ansi = true
|
||||
tracer.console.buffered = true
|
||||
tracer.console.enable = true
|
||||
tracer.console.level = "trace"
|
||||
tracer.console.lossy = false
|
||||
tracer.console.multiline = false
|
||||
tracer.console.type = "stdout"
|
||||
File diff suppressed because one or more lines are too long.
@@ -1,67 +0,0 @@
|
||||
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.fallback-admin.user = "mailadmin"
|
||||
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.master.user = "master"
|
||||
directory.oidc.cache.size = 1048576
|
||||
directory.oidc.cache.ttl.negative = "10m"
|
||||
directory.oidc.cache.ttl.positive = "1h"
|
||||
directory.oidc.endpoint.method = "userinfo"
|
||||
directory.oidc.endpoint.url = "http://172.17.0.1:10000/auth/maethaR9eiXaiph8ahn8ohH6dahPiequ"
|
||||
directory.oidc.fields.email = "email"
|
||||
directory.oidc.fields.full-name = "name"
|
||||
directory.oidc.fields.username = "preferred_username"
|
||||
directory.oidc.timeout = "15s"
|
||||
directory.oidc.type = "oidc"
|
||||
http.allowed-endpoint = 200
|
||||
http.hsts = true
|
||||
http.permissive-cors = false
|
||||
http.url = "'https://' + config_get('server.hostname')"
|
||||
http.use-x-forwarded = true
|
||||
metrics.prometheus.auth.secret = "secret"
|
||||
metrics.prometheus.auth.username = "metrics"
|
||||
metrics.prometheus.enable = true
|
||||
server.listener.http.bind = "0.0.0.0:8080"
|
||||
server.listener.http.protocol = "http"
|
||||
server.listener.https.bind = "0.0.0.0:443"
|
||||
server.listener.https.protocol = "http"
|
||||
server.listener.https.tls.implicit = true
|
||||
server.listener.imap.bind = "0.0.0.0:143"
|
||||
server.listener.imap.protocol = "imap"
|
||||
server.listener.imaptls.bind = "0.0.0.0:993"
|
||||
server.listener.imaptls.protocol = "imap"
|
||||
server.listener.imaptls.tls.implicit = true
|
||||
server.listener.pop3.bind = "0.0.0.0:110"
|
||||
server.listener.pop3.protocol = "pop3"
|
||||
server.listener.pop3s.bind = "0.0.0.0:995"
|
||||
server.listener.pop3s.protocol = "pop3"
|
||||
server.listener.pop3s.tls.implicit = true
|
||||
server.listener.sieve.bind = "0.0.0.0:4190"
|
||||
server.listener.sieve.protocol = "managesieve"
|
||||
server.listener.smtp.bind = "0.0.0.0:25"
|
||||
server.listener.smtp.protocol = "smtp"
|
||||
server.listener.submission.bind = "0.0.0.0:587"
|
||||
server.listener.submission.protocol = "smtp"
|
||||
server.listener.submissions.bind = "0.0.0.0:465"
|
||||
server.listener.submissions.protocol = "smtp"
|
||||
server.listener.submissions.tls.implicit = true
|
||||
server.max-connections = 8192
|
||||
server.socket.backlog = 1024
|
||||
server.socket.nodelay = true
|
||||
server.socket.reuse-addr = true
|
||||
server.socket.reuse-port = true
|
||||
sharing.allow-directory-query = false
|
||||
storage.blob = "rocksdb"
|
||||
storage.data = "rocksdb"
|
||||
storage.directory = "oidc"
|
||||
storage.fts = "rocksdb"
|
||||
storage.lookup = "rocksdb"
|
||||
store.rocksdb.compression = "lz4"
|
||||
store.rocksdb.path = "/opt/stalwart/data"
|
||||
store.rocksdb.type = "rocksdb"
|
||||
tracer.console.ansi = true
|
||||
tracer.console.buffered = true
|
||||
tracer.console.enable = true
|
||||
tracer.console.level = "trace"
|
||||
tracer.console.lossy = false
|
||||
tracer.console.multiline = false
|
||||
tracer.console.type = "stdout"
|
||||
@@ -1,110 +0,0 @@
|
||||
authentication.fallback-admin.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.fallback-admin.user = "mailadmin"
|
||||
authentication.master.secret = "$6$4qPYDVhaUHkKcY7s$bB6qhcukb9oFNYRIvaDZgbwxrMa2RvF5dumCjkBFdX19lSNqrgKltf3aPrFMuQQKkZpK2YNuQ83hB1B3NiWzj."
|
||||
authentication.master.user = "master"
|
||||
directory.idmldap.attributes.class = "objectClass"
|
||||
directory.idmldap.attributes.description = "displayName"
|
||||
directory.idmldap.attributes.email = "mail"
|
||||
directory.idmldap.attributes.groups = "memberOf"
|
||||
directory.idmldap.attributes.name = "uid"
|
||||
directory.idmldap.attributes.secret = "userPassword"
|
||||
directory.idmldap.base-dn = "o=libregraph-idm"
|
||||
directory.idmldap.bind.auth.method = "default"
|
||||
directory.idmldap.bind.dn = "uid=reva,ou=sysusers,o=libregraph-idm"
|
||||
directory.idmldap.bind.secret = "admin"
|
||||
directory.idmldap.cache.size = 1048576
|
||||
directory.idmldap.cache.ttl.negative = "10m"
|
||||
directory.idmldap.cache.ttl.positive = "1h"
|
||||
directory.idmldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(mail=?))"
|
||||
directory.idmldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(uid=?))"
|
||||
directory.idmldap.timeout = "15s"
|
||||
directory.idmldap.tls.allow-invalid-certs = true
|
||||
directory.idmldap.tls.enable = true
|
||||
directory.idmldap.type = "ldap"
|
||||
directory.idmldap.url = "ldaps://opencloud:9235"
|
||||
directory.keycloak.auth.method = "user-token"
|
||||
directory.keycloak.cache.size = 1048576
|
||||
directory.keycloak.cache.ttl.negative = "10m"
|
||||
directory.keycloak.cache.ttl.positive = "1h"
|
||||
directory.keycloak.endpoint.method = "introspect"
|
||||
directory.keycloak.endpoint.url = "http://keycloak:8080/realms/openCloud/protocol/openid-connect/userinfo"
|
||||
directory.keycloak.fields.email = "email"
|
||||
directory.keycloak.fields.full-name = "name"
|
||||
directory.keycloak.fields.username = "preferred_username"
|
||||
directory.keycloak.timeout = "15s"
|
||||
directory.keycloak.type = "oidc"
|
||||
directory.ldap.attributes.class = "objectClass"
|
||||
directory.ldap.attributes.description = "displayName"
|
||||
directory.ldap.attributes.email = "mail"
|
||||
directory.ldap.attributes.email-alias = "mailAlias"
|
||||
directory.ldap.attributes.groups = "memberOf"
|
||||
directory.ldap.attributes.name = "uid"
|
||||
directory.ldap.attributes.secret = "userPassword"
|
||||
directory.ldap.attributes.secret-changed = "pwdChangedTime"
|
||||
directory.ldap.base-dn = "dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.auth.dn = "cn=?,ou=users,dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.auth.enable = true
|
||||
directory.ldap.bind.auth.search = true
|
||||
directory.ldap.bind.dn = "cn=admin,dc=opencloud,dc=eu"
|
||||
directory.ldap.bind.secret = "admin"
|
||||
directory.ldap.cache.ttl.negative = "10m"
|
||||
directory.ldap.cache.ttl.positive = "1h"
|
||||
directory.ldap.filter.email = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(mail=?)(mailAlias=?)(cn=?)))"
|
||||
directory.ldap.filter.name = "(&(|(objectClass=person)(objectClass=groupOfNames))(|(uid=?)(cn=?)))"
|
||||
directory.ldap.timeout = "5s"
|
||||
directory.ldap.tls.allow-invalid-certs = true
|
||||
directory.ldap.tls.enable = true
|
||||
directory.ldap.type = "ldap"
|
||||
directory.ldap.url = "ldap://ldap-server:1389"
|
||||
http.allowed-endpoint = 200
|
||||
http.hsts = true
|
||||
http.permissive-cors = false
|
||||
http.url = "'https://' + config_get('server.hostname')"
|
||||
http.use-x-forwarded = true
|
||||
metrics.prometheus.auth.secret = "secret"
|
||||
metrics.prometheus.auth.username = "metrics"
|
||||
metrics.prometheus.enable = true
|
||||
server.listener.http.bind = "0.0.0.0:8080"
|
||||
server.listener.http.protocol = "http"
|
||||
server.listener.https.bind = "0.0.0.0:443"
|
||||
server.listener.https.protocol = "http"
|
||||
server.listener.https.tls.implicit = true
|
||||
server.listener.imap.bind = "0.0.0.0:143"
|
||||
server.listener.imap.protocol = "imap"
|
||||
server.listener.imaptls.bind = "0.0.0.0:993"
|
||||
server.listener.imaptls.protocol = "imap"
|
||||
server.listener.imaptls.tls.implicit = true
|
||||
server.listener.pop3.bind = "0.0.0.0:110"
|
||||
server.listener.pop3.protocol = "pop3"
|
||||
server.listener.pop3s.bind = "0.0.0.0:995"
|
||||
server.listener.pop3s.protocol = "pop3"
|
||||
server.listener.pop3s.tls.implicit = true
|
||||
server.listener.sieve.bind = "0.0.0.0:4190"
|
||||
server.listener.sieve.protocol = "managesieve"
|
||||
server.listener.smtp.bind = "0.0.0.0:25"
|
||||
server.listener.smtp.protocol = "smtp"
|
||||
server.listener.submission.bind = "0.0.0.0:587"
|
||||
server.listener.submission.protocol = "smtp"
|
||||
server.listener.submissions.bind = "0.0.0.0:465"
|
||||
server.listener.submissions.protocol = "smtp"
|
||||
server.listener.submissions.tls.implicit = true
|
||||
server.max-connections = 8192
|
||||
server.socket.backlog = 1024
|
||||
server.socket.nodelay = true
|
||||
server.socket.reuse-addr = true
|
||||
server.socket.reuse-port = true
|
||||
storage.blob = "rocksdb"
|
||||
storage.data = "rocksdb"
|
||||
storage.directory = "ldap"
|
||||
storage.fts = "rocksdb"
|
||||
storage.lookup = "rocksdb"
|
||||
store.rocksdb.compression = "lz4"
|
||||
store.rocksdb.path = "/opt/stalwart/data"
|
||||
store.rocksdb.type = "rocksdb"
|
||||
tracer.console.ansi = true
|
||||
tracer.console.buffered = true
|
||||
tracer.console.enable = true
|
||||
tracer.console.level = "trace"
|
||||
tracer.console.lossy = false
|
||||
tracer.console.multiline = false
|
||||
tracer.console.type = "stdout"
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
services:
|
||||
|
||||
opencloud:
|
||||
command: [ "-c", "opencloud init || true; dlv --listen=:40000 --headless=true --check-go-version=false --api-version=2 --accept-multiclient exec /usr/bin/opencloud server" ]
|
||||
ports:
|
||||
- 40000:40000
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.7.5
|
||||
image: traefik:v3.3.1
|
||||
# release notes: https://github.com/traefik/traefik/releases
|
||||
networks:
|
||||
opencloud-net:
|
||||
@@ -36,9 +36,6 @@ services:
|
||||
- "--accessLog=true"
|
||||
- "--accessLog.format=json"
|
||||
- "--accessLog.fields.headers.names.X-Request-Id=keep"
|
||||
- "--accessLog.fields.headers.names.Trace-Id=keep"
|
||||
# enable the ping endpoint for monitoring
|
||||
- "--ping=true"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
@@ -57,12 +54,6 @@ services:
|
||||
logging:
|
||||
driver: ${LOG_DRIVER:-local}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "traefik", "healthcheck", "--ping=true"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 3s
|
||||
timeout: 3s
|
||||
|
||||
volumes:
|
||||
certs:
|
||||
|
||||
@@ -57,8 +57,6 @@ services:
|
||||
KC_FEATURES: impersonation
|
||||
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
|
||||
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.keycloak.entrypoints=https"
|
||||
|
||||
@@ -24,7 +24,6 @@ services:
|
||||
OC_LDAP_SERVER_WRITE_ENABLED: "false" # assuming the external ldap is not writable
|
||||
# OC_RUN_SERVICES specifies to start all services except glauth, idm and accounts. These are replaced by external services
|
||||
OC_EXCLUDE_RUN_SERVICES: idm
|
||||
STALWART_AUTH_DIRECTORY: "ldap"
|
||||
|
||||
ldap-server:
|
||||
image: bitnamilegacy/openldap:2.6
|
||||
@@ -40,9 +39,6 @@ services:
|
||||
LDAP_TLS_KEY_FILE: /opt/bitnami/openldap/share/openldap.key
|
||||
LDAP_ROOT: "dc=opencloud,dc=eu"
|
||||
LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD:-admin}
|
||||
LDAP_CONFIGURE_PPOLICY: "yes"
|
||||
LDAP_PPOLICY_USE_LOCKOUT: "no"
|
||||
LDAP_PPOLICY_HASH_CLEARTEXT: "no"
|
||||
ports:
|
||||
- "127.0.0.1:389:1389"
|
||||
- "127.0.0.1:636:1636"
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
# enable services that are not started automatically
|
||||
OC_ADD_RUN_SERVICES: ${START_ADDITIONAL_SERVICES}
|
||||
OC_URL: https://${OC_DOMAIN:-cloud.opencloud.test}
|
||||
OC_LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||
OC_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
OC_LOG_COLOR: "${LOG_PRETTY:-false}"
|
||||
OC_LOG_PRETTY: "${LOG_PRETTY:-false}"
|
||||
# do not use SSL between Traefik and OpenCloud
|
||||
@@ -58,23 +58,10 @@ services:
|
||||
COMPANION_DOMAIN: ${COMPANION_DOMAIN:-companion.opencloud.test}
|
||||
# enable to allow using the banned passwords list
|
||||
OC_PASSWORD_POLICY_BANNED_PASSWORDS_LIST: banned-password-list.txt
|
||||
IDM_REVASVC_PASSWORD: "admin"
|
||||
AUTH_BASIC_LDAP_BIND_PASSWORD: "admin"
|
||||
USERS_LDAP_BIND_PASSWORD: "admin"
|
||||
GROUPS_LDAP_BIND_PASSWORD: "admin"
|
||||
IDM_LDAPS_ADDR: 0.0.0.0:9235
|
||||
IDM_LDAPS_CERT: /etc/opencloud/certs/ldaps.crt
|
||||
IDM_LDAPS_KEY: /etc/opencloud/certs/ldaps.key
|
||||
OC_LDAP_CACERT: /etc/opencloud/certs/ldaps.crt
|
||||
GROUPWARE_JMAP_BASE_URL: https://${STALWART_DOMAIN:-stalwart.opencloud.test}
|
||||
GROUPWARE_JMAP_MASTER_USERNAME: "admin@example.org"
|
||||
GROUPWARE_JMAP_MASTER_PASSWORD: "admin"
|
||||
GROUPWARE_TLS_INSECURE: "true"
|
||||
volumes:
|
||||
- ./config/opencloud/app-registry.yaml:/etc/opencloud/app-registry.yaml
|
||||
- ./config/opencloud/csp.yaml:/etc/opencloud/csp.yaml
|
||||
- ./config/opencloud/banned-password-list.txt:/etc/opencloud/banned-password-list.txt
|
||||
- ./config/opencloud/certs:/etc/opencloud/certs
|
||||
# configure the .env file to use own paths instead of docker internal volumes
|
||||
- ${OC_CONFIG_DIR:-opencloud-config}:/etc/opencloud
|
||||
- ${OC_DATA_DIR:-opencloud-data}:/var/lib/opencloud
|
||||
@@ -89,27 +76,6 @@ services:
|
||||
logging:
|
||||
driver: ${LOG_DRIVER:-local}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test $(curl -sSf http://localhost:9104/healthz) == 'OK'"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 3s
|
||||
timeout: 3s
|
||||
|
||||
opencloud-certs:
|
||||
image: alpine/openssl:latest
|
||||
environment:
|
||||
CERTS: /certs
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ ! -f "$$CERTS/ldaps.key" ]; then
|
||||
mkdir -p "$$CERTS"
|
||||
/usr/bin/openssl req -subj '/CN=opencloud.test' -x509 -newkey rsa:4096 -sha256 -days 3650 -batch -nodes -keyout "$$CERTS/ldaps.key" -out "$$CERTS/ldaps.crt"
|
||||
fi
|
||||
chmod 666 "$$CERTS"/ldaps.*
|
||||
volumes:
|
||||
- ./config/opencloud/certs:/certs
|
||||
|
||||
volumes:
|
||||
opencloud-config:
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Snapshots the current configuration of a Stalwart >= 0.16 server.
|
||||
# Requires having stalwart-cli installed.
|
||||
set -euo pipefail
|
||||
exec stalwart-cli -k --url 'https://stalwart.opencloud.test' --user admin --password secret snapshot \
|
||||
--include-secrets \
|
||||
Tenant \
|
||||
Domain \
|
||||
Directory \
|
||||
Authentication \
|
||||
DkimSignature \
|
||||
AcmeProvider \
|
||||
Certificate \
|
||||
DnsServer \
|
||||
Role \
|
||||
Authentication \
|
||||
Account \
|
||||
NetworkListener \
|
||||
Tracer \
|
||||
Sharing \
|
||||
SystemSettings \
|
||||
DataRetention \
|
||||
BlobStore \
|
||||
InMemoryStore \
|
||||
SearchStore \
|
||||
--allow-unresolved PublicKey \
|
||||
"$@"
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Processes a Stalwart snapshot JSON file to perform the following:
|
||||
// - replace masked passwords with their correct value
|
||||
// - replace the defaultHostname system setting
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import readline from 'node:readline'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const inputs = args.length > 0 ? args.map((file) => fs.createReadStream(file)) : [process.stdin]
|
||||
|
||||
async function processLines() {
|
||||
for (const stream of inputs) {
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const item = JSON.parse(trimmed)
|
||||
const t = item['@type']
|
||||
const o = item['object']
|
||||
|
||||
if (t === 'create' && o === 'Account') {
|
||||
const value = item['value'] || {}
|
||||
for (const [id, account] of Object.entries(value)) {
|
||||
const name = account['name']
|
||||
if (name === 'master' || name === 'admin') {
|
||||
const credentials = account['credentials'] || {}
|
||||
for (const [cid, creds] of Object.entries(credentials)) {
|
||||
if (creds['@type'] === 'Password') {
|
||||
creds['secret'] = 'supersecret1234'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (t === 'create' && o === 'Directory') {
|
||||
const value = item['value'] || {}
|
||||
for (const [id, dir] of Object.entries(value)) {
|
||||
if (dir['@type'] === 'Ldap') {
|
||||
if (dir['bindSecret']) {
|
||||
dir['bindSecret']['secret'] = 'admin'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (t === 'update' && o === 'SystemSettings') {
|
||||
if (item['value']) {
|
||||
item['value']['defaultHostname'] = 'stalwart.opencloud.test'
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processLines()
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
services:
|
||||
traefik:
|
||||
networks:
|
||||
opencloud-net:
|
||||
aliases:
|
||||
- ${STALWART_DOMAIN:-stalwart.opencloud.test}
|
||||
|
||||
stalwart:
|
||||
image: ghcr.io/stalwartlabs/stalwart:v0.16.12-alpine
|
||||
hostname: ${STALWART_DOMAIN:-stalwart.opencloud.test}
|
||||
networks:
|
||||
- opencloud-net
|
||||
ports:
|
||||
- "127.0.0.1:8443:443"
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:143:143"
|
||||
- "127.0.0.1:993:993"
|
||||
- "127.0.0.1:1465:465"
|
||||
volumes:
|
||||
- ./config/stalwart/config.json:/etc/stalwart/config.json
|
||||
- stalwart-data:/var/lib/stalwart
|
||||
environment:
|
||||
STALWART_RECOVERY_ADMIN: "admin:secret"
|
||||
STALWART_PUBLIC_URL: "https://${STALWART_DOMAIN:-stalwart.opencloud.test}"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.stalwart.entrypoints=https"
|
||||
- "traefik.http.routers.stalwart.rule=Host(`${STALWART_DOMAIN:-stalwart.opencloud.test}`)"
|
||||
- "traefik.http.routers.stalwart.tls.certresolver=http"
|
||||
- "traefik.http.routers.stalwart.service=stalwart"
|
||||
- "traefik.http.services.stalwart.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: ${LOG_DRIVER:-local}
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ ! -f /var/lib/stalwart/.initialized ]; then
|
||||
echo "Not initialized, starting in recovery mode"
|
||||
STALWART_RECOVERY_MODE=1 /usr/local/bin/stalwart --config /etc/stalwart/config.json &
|
||||
pid=$$!
|
||||
while [ ! -f /var/lib/stalwart/.initialized ]; do sleep 1; done
|
||||
echo "Initialized, stopping recovery mode"
|
||||
kill -TERM $$pid
|
||||
wait $$pid
|
||||
fi
|
||||
echo "Starting Stalwart in production mode"
|
||||
exec /usr/local/bin/stalwart --config /etc/stalwart/config.json
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -LsSf http://localhost:8080/healthz/live|grep -q '\"status\":200'"]
|
||||
interval: 10m
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
timeout: 3s
|
||||
|
||||
stalwart-import:
|
||||
build:
|
||||
context: ./config/stalwart/
|
||||
args:
|
||||
VERSION: "1.0.8"
|
||||
dockerfile_inline: |
|
||||
FROM alpine:latest AS downloader
|
||||
ARG VERSION
|
||||
RUN apk add --no-cache curl tar xz wget
|
||||
RUN arch=$(apk --print-arch) && \
|
||||
mkdir /cli && \
|
||||
curl --proto '=https' --tlsv1.2 -LsSf \
|
||||
https://github.com/stalwartlabs/cli/releases/download/v$${VERSION}/stalwart-cli-$${arch}-unknown-linux-musl.tar.xz \
|
||||
| tar xJf - --strip-components=1 -C /cli
|
||||
RUN mv /cli/stalwart-cli /stalwart-cli && rm -rf /cli/
|
||||
CMD ["/stalwart-cli"]
|
||||
|
||||
networks:
|
||||
- opencloud-net
|
||||
volumes:
|
||||
- ./config/stalwart/${STALWART_AUTH_DIRECTORY:-idmldap}.json:/snapshot.json:ro
|
||||
- stalwart-data:/var/lib/stalwart
|
||||
environment:
|
||||
STALWART_URL: "http://stalwart:8080"
|
||||
STALWART_USER: "admin"
|
||||
STALWART_PASSWORD: "secret"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ -f /var/lib/stalwart/.initialized ]; then
|
||||
echo "Already initialized, skipping stalwart-cli."
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for the Stalwart recovery API to become live"
|
||||
until wget -qO- $$STALWART_URL/healthz/live >/dev/null 2>&1; do sleep 1; done
|
||||
echo "Applying configuration"
|
||||
/stalwart-cli --debug apply --file /snapshot.json
|
||||
touch /var/lib/stalwart/.initialized
|
||||
echo "Successfully initialized"
|
||||
|
||||
depends_on:
|
||||
- stalwart
|
||||
|
||||
stalwart-reset:
|
||||
image: alpine:latest
|
||||
profiles:
|
||||
- maintenance
|
||||
volumes:
|
||||
- stalwart-data:/var/lib/stalwart
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
if [ -f /var/lib/stalwart/.initialized ]; then
|
||||
rm -f /var/lib/stalwart/.initialized
|
||||
echo "Success: Lockfile removed. Next time 'stalwart' boots, it will run in recovery mode."
|
||||
fi
|
||||
|
||||
volumes:
|
||||
stalwart-data:
|
||||
@@ -1,343 +0,0 @@
|
||||
---
|
||||
title: "Authentication with Stalwart"
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
In a groupware environment, not every user will always use the OpenCloud UI to read their emails, some will resort to other [MUAs (Mail User Agents)](https://en.wikipedia.org/wiki/Email_client) that support a subset of features, use older protocols (IMAP, POP, SMTP, CalDAV, CardDAV) and lesser authentication methods (basic authentication). Those email clients will talk to Stalwart directly, as opposed to the OpenCloud UI which will make use of APIs of the OpenCloud Groupware service, since those protocols are provided by Stalwart and implementing them in OpenCloud would offer very little benefits, but definitely a lot of (almost completely) unnecessary effort.
|
||||
|
||||
Those protocols and operations that bypass the OpenCloud UI also need to be authenticated, this in and by Stalwart, and we need to find the best fitting approach that fulfills most or all of the following constraints:
|
||||
|
||||
### Single Provisioning
|
||||
|
||||
We want to avoid multiple provisioning of users, groups, passwords and other resources as much as possible.
|
||||
While it is possible to have e.g. OpenCloud's user management also perform [Management API](https://stalw.art/docs/category/management-api/) calls, one still inevitably ends up in situations where users, user passwords, or other resources are not in sync, which becomes complex to debug and fix, and should thus be avoided if possible.
|
||||
|
||||
To do so, we should strive to have a single source of truth regarding users, their passwords, and similar resources and attributes such as groups, roles, application passwords, etc...
|
||||
|
||||
### Attack Detection
|
||||
|
||||
Coordinated attacks such as [denial of service](https://en.wikipedia.org/wiki/Denial-of-service_attack) attempts don't necessarily focus on a single protocol but are commonly multi-pronged, e.g. by brute forcing the [OIDC API](https://www.keycloak.org/docs/latest/authorization_services/index.html#token-endpoint), the OpenCloud Groupware API, IMAP and SMTP, \*DAV protocols, etc...
|
||||
|
||||
In order to detect those as well as to quickly react by blacklisting clients that are identified to attempt such attacks, it is useful to have a single authentication service for all the components of the system, all protocols, all clients (e.g. [PowerDNS Weakforced](https://github.com/PowerDNS/weakforced), [Nauthilus](https://nauthilus.org/), ...)
|
||||
|
||||
Furthermore, such services typically make use of [DNSBL/RBL services](https://en.wikipedia.org/wiki/Domain_Name_System_blocklist) that allow IP addresses of botnets to be blocked across many services of many providers as a shared defense mechanism.
|
||||
|
||||
As a bonus, a centralized authentication component can also provide metrics and observability capabilities across all those protocols.
|
||||
|
||||
### Custom Authentication Implementations
|
||||
|
||||
Some customers might want custom authentication implementations to integrate with their environment, in which case we would want those to be done once and in the technology stack we're all most familiar with (thus as a service in Go in the OpenCloud framework, and not e.g. a Lua script in Nauthilus, or a Rust plugin in Stalwart, etc...)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
TODO
|
||||
|
||||
*
|
||||
|
||||
## Considered Options
|
||||
|
||||
First off, here is a brief explanation of each of the scenarios that we potentially or absolutely need to support, which we will explore for each implementation option:
|
||||
|
||||
* MUAs with basic authentication
|
||||
* these are external mail clients (Thunderbird, Apple Mail, ...) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) and their primary username and password in clear text (encrypted through the mandatory use of TLS)
|
||||
* MUAs with application password authentication
|
||||
* these are external mail clients (Thunderbird, Apple Mail, ...) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) and one of the application passwords that they created in the OpenCloud UI, which is a useful security mechanism as it reduces the attack surface when one such password is leaked or discovered
|
||||
* MUAs with SASL bearer token authentication
|
||||
* these are more modern external mail clients (Thunderbird) with which users authenticate using legacy protocols (IMAP, POP3, SMTP) but more secure OIDC token based authentication (SASL OAUTHBEARER or SASL XOAUTH2), which closely resembles the OIDC authentication used by the OpenCloud UI towards the OpenCloud backends
|
||||
* JMAP clients with basic authentication
|
||||
* modern mail clients (Thunderbird) that speak the JMAP protocol over HTTP and authenticate using their primary username and password in clear text (encrypted through the use of HTTPS)
|
||||
* JMAP clients with bearer token authentication
|
||||
* modern mail clients (Thunderbird) that speak the JMAP protocol over HTTP and authenticate using an OIDC token (JWT) obtained from an IDP (typically KeyCloak)
|
||||
* OpenCloud Groupware with master authentication
|
||||
* the OpenCloud UI client uses APIs from the OpenCloud Groupware backend (and authenticates using OIDC)
|
||||
* the OpenCloud Groupware backend, in turn, performs JMAP operations with Stalwart, and authenticates using Stalwart's shared secret master authentication protocol
|
||||
* OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud UI client uses APIs from the OpenCloud Groupware backend (and authenticates using OIDC)
|
||||
* the OpenCloud Groupware backend, in turn, performs JMAP operations with Stalwart, and authenticates against Stalwart using bearer authentication with JWTs that it generates itself
|
||||
* in the future, that JWT might also be the JWT that the OpenCloud UI used to authenticate against the OpenCloud Groupware in the first place
|
||||
|
||||
### Stalwart with the LDAP Directory
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
s(Stalwart)
|
||||
l(LDAP)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
c -- JMAP --> s
|
||||
s -- LDAP --> l
|
||||
```
|
||||
|
||||
Clients authenticate directly against Stalwart, that is configured to use an LDAP authentication Directory.
|
||||
An LDAP server (e.g. OpenLDAP) is needed as part of the infrastructure.
|
||||
OpenCloud also has to make use of the same LDAP server.
|
||||
|
||||
* ✅ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin supports plain text authentication by looking up the userPassword attribute in the LDAP server
|
||||
* ❌ MUAs with application password authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin does not support application password as it is hardwired to look up the password in the userPassword attribute in the LDAP server
|
||||
* even if it did support looking up alternative passwords in LDAP, this would hardly be practical as the application passwords are currently created and stored in OpenCloud, which would need to be modified to store them in LDAP in the first place
|
||||
* ❌ MUAs with SASL bearer token authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
|
||||
* ✅ JMAP clients with basic authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin supports plain text authentication by looking up the userPassword attribute in the LDAP server
|
||||
* ❌ JMAP clients with bearer token authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ❌ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart's LDAP Directory plugin does not support verifying OIDC tokens
|
||||
|
||||
### Stalwart with the OIDC Directory
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
s(Stalwart)
|
||||
o(IDP)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
c -- JMAP --> s
|
||||
s -- OIDC HTTP --> o
|
||||
```
|
||||
|
||||
Clients authenticate directly against Stalwart, that is configured to use an OIDC authentication Directory.
|
||||
An OIDC IDP (server) is needed as part of the infrastructure, e.g. KeyCloak.
|
||||
Optionally, an LDAP server (e.g. OpenLDAP) might be used as well, and KeyCloak would look up users and their credentials in LDAP.
|
||||
|
||||
OpenCloud also has to make use of the same LDAP server, or would need to be modified to be capable of only making use of an OIDC IDP (which would include limitations that are yet to be resolved, e.g. the option of using KeyCloak Admin APIs to retreieve groups, group members, ...)
|
||||
|
||||
* ❌ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not support plain text authentication
|
||||
* ❌ MUAs with application password authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not support application passwords
|
||||
* ❓ MUAs with SASL bearer token authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
|
||||
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token
|
||||
* ❌ JMAP clients with basic authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not support plain text authentication
|
||||
* ❓ JMAP clients with bearer token authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
|
||||
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ❓ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart's OIDC Directory plugin does not currently support external IDPs, but is expected to in future versions
|
||||
* as of Stalwart 0.12, this would only work if Stalwart itself is used as the IDP when acquiring a token, which is not the case with this approach as the tokens are generated by the Groupware backend itself
|
||||
|
||||
### Stalwart with the Internal Directory
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
s(Stalwart)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
c -- JMAP --> s
|
||||
```
|
||||
|
||||
Clients authenticate directly against Stalwart, that is configured to use an Internal authentication Directory.
|
||||
Neither an OIDC IDP nor an LDAP server are needed as part of the infrastructure, as principal resources (users, groups) and their credentials exist in Stalwart's storage.
|
||||
|
||||
OpenCloud would not be capable of accessing those resources, which means that provisioning of groups, users, user passwords must be duplicated and kept in sync between Stalwart and OpenCloud.
|
||||
|
||||
* ✅ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin supports plain text authentication
|
||||
* ✅ MUAs with application password authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin supports application passwords
|
||||
* users are able to create those themselves using the self-service web UI of Stalwart
|
||||
* they are not shared with the OpenCloud application passwords though and would need to be provisioned into Stalwart when created in OpenCloud to provide a single UI
|
||||
* ❌ MUAs with SASL bearer token authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin does not support OIDC token authentication
|
||||
* ✅ JMAP clients with basic authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin supports plain text authentication
|
||||
* ❌ JMAP clients with bearer token authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin does not support OIDC token authentication
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ❌ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart's Internal Directory plugin does not support OIDC token authentication
|
||||
|
||||
### Stalwart with the OpenCloud Authentication API
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
s(Stalwart)
|
||||
o(OpenCloud)
|
||||
l(LDAP)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
c -- JMAP --> s
|
||||
s -- REST --> o
|
||||
o -- LDAP --> l
|
||||
```
|
||||
|
||||
Clients authenticate directly against Stalwart, that is configured to use an "External" authentication Directory, that is yet to be developed. (warning)
|
||||
Its protocol is currently not defined, but not particularly relevant at this time, as long as it supports accepting basic and bearer authentication in order to authenticate both username and password credentials as well as OIDC tokens.
|
||||
|
||||
That External Directory implementation forwards the basic or bearer credentials to an endpoint in the OpenCloud backend, that the responds with whether the authentication is successful or not, as well as with additional information that is needed for Stalwart (email address, display name, groups, roles, ...)
|
||||
|
||||
* ✅ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's External Directory supports plain text authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate users by username and password using an LDAP server
|
||||
* note that this option requires having an LDAP server in the environment, including having it accessible by OpenCloud
|
||||
* if that is not the case, then a viable option is also to support OIDC tokens and application passwords
|
||||
* to clarify: this scenario is only about supporting authentication using the "primary" username and password
|
||||
* ✅ MUAs with application password authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's External Directory supports application password authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate against its list of application passwords
|
||||
* this is the ideal scenario for application passwords, since they are already supported by OpenCloud, and can be created and managed using the OpenCloud UI
|
||||
* relaying the authentication operation to OpenCloud also prevents the need for duplicate provisioning of application passwords
|
||||
* ✅ MUAs with SASL bearer token authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's External Directory supports OIDC token authentication by relaying the authentication operation to the OpenCloud backend, which can then either perform local token inspection and authentication by verifying the token's signature, or use the OIDC IDP's token introspection endpoint
|
||||
* ✅ JMAP clients with basic authentication
|
||||
* JMAP clients authenticate directly against Stalwart
|
||||
* Stalwart's External Directory supports plain text authentication by relaying the authentication operation to the OpenCloud backend, which can then authenticate users by username and password using an LDAP server
|
||||
* the same limitations/requirements as for the "MUAs with basic authentication" scenario apply here as well
|
||||
* ✅ JMAP clients with bearer token authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's External Directory supports OIDC token authentication by relaying the authentication operation to the OpenCloud backend, which can then either perform local token inspection and authentication by verifying the token's signature, or use the OIDC IDP's token introspection endpoint
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ✅ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* in the worst case, the External Directory plugin in Stalwart would also perform a forwarding of the authentication operation to OpenCloud, which would obviously be able to verify a token it has created
|
||||
* an optimization might be possible here, if the External Directory implementation permits for the configuration of specific issuers which should then be verifying against a JWK set directly, whereas the fallback behaviour would be to query the OpenCloud Authentication API
|
||||
|
||||
### Stalwart with Nauthilus and LDAP
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
s(Stalwart)
|
||||
n(Nauthilus)
|
||||
l(LDAP)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
c -- JMAP --> s
|
||||
s -- REST --> n
|
||||
n -- LDAP --> l
|
||||
```
|
||||
|
||||
In this scenario, we introduce the [Nauthilus authentication service](https://nauthilus.org/), which has its own API but also a KeyCloak integration plugin.
|
||||
It supports various backends and can also be scripted for more complex combinations.
|
||||
|
||||
⚠️ It would require the implementation of a Stalwart Nauthilus Directory, **that is yet to be developed**.
|
||||
|
||||
We do not make use of any OpenCloud Authentication API but, instead, attempt to have everything go through Nauthilus instead, backed by an LDAP server that then contains the users, groups, and user passwords.
|
||||
|
||||
The upside of using Nauthilus is that it does brute force attack detection and can provide metrics across multiple protocols and clients in a centralized fashion.
|
||||
|
||||
* ✅ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
|
||||
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
|
||||
* ❓ MUAs with application password authentication
|
||||
* Nauthilus has no support for application passwords in itself
|
||||
* a Lua plugin could potentially be used in Nauthilus to detect whether the clear text password matches a regular expression for application passwords and, if that is the case, first attempt to verify it through an API call (that does not exist yet) to the OpenCloud backend, but that would definitely be more complex and less elegant than having a single API
|
||||
* ❓ MUAs with SASL bearer token authentication
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication
|
||||
* ✅ JMAP clients with basic authentication
|
||||
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
|
||||
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
|
||||
* ❓ JMAP clients with bearer token authentication
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ❓ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication
|
||||
* an optimization might be possible here, if the Nauthilus Directory implementation permits for the configuration of specific issuers which should then be verifying against a JWK set directly, whereas the fallback behaviour would be to query the Nauthilus API, but that does sound like a stretch to fit into the concept
|
||||
|
||||
### Stalwart with Nauthilus and an OpenCloud Authentication API
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
c(client)
|
||||
j(client)
|
||||
s(Stalwart)
|
||||
n(Nauthilus)
|
||||
o(OpenCloud)
|
||||
l(LDAP)
|
||||
k(Keycloak)
|
||||
|
||||
c -- IMAP/SMTP --> s
|
||||
j -- JMAP --> s
|
||||
s -- REST --> n
|
||||
subgraph internal auth
|
||||
n -- REST --> o
|
||||
o -- LDAP --> l
|
||||
o -- OIDC --> k
|
||||
end
|
||||
```
|
||||
|
||||
This option also makes use of the [Nauthilus authentication service](https://nauthilus.org/), but instead of it using LDAP to resolve users, we would either make use of its Lua scripting abilities to implement a backend that performs HTTP calls to an OpenCloud Authentication API, or implement an additional Nauthilus backend that uses the Nauthilus API to delegate to another instance, which would then be the OpenCloud Authentication API with support for the Nauthilus API.
|
||||
|
||||
⚠️ As with the previous option, it would require the implementation of a Stalwart Nauthilus Directory, **that is yet to be developed**.
|
||||
|
||||
Interestingly, if the OpenCloud Authentication API follows the Nauthilus API, this scenario can easily be degraded by dropping Nauthilus and, instead, having all services talk to the OpenCloud Authentication API directly.
|
||||
|
||||
* ✅ MUAs with basic authentication
|
||||
* MUAs authenticate directly against Stalwart
|
||||
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
|
||||
* Nauthilus provides a response that contains user attributes from LDAP (display name, email addresses, ...)
|
||||
* ✅ MUAs with application password authentication
|
||||
* Nauthilus would forward the authentication request to the OpenCloud Authentication API, which would support application passwords
|
||||
* ❓ MUAs with SASL bearer token authentication
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
|
||||
* ✅ JMAP clients with basic authentication
|
||||
* Stalwart's Nauthilus Directory supports plain text authentication by relaying the authentication operation to Nauthilus, e.g. using its JSON API
|
||||
* Nauthilus then forwards that request to the OpenCloud Authentication API
|
||||
* the OpenCloud Authentication API, and then Nauthilus, provides a response that contains user attributes from LDAP (display name, email addresses, ...) or claims from the JWT
|
||||
* ❓ JMAP clients with bearer token authentication
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
|
||||
* ✅ OpenCloud Groupware with master authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* Stalwart detects and supports clear text password master authentication regardless of the Directory that is being used, and verifies it against the shared secret password that is configured in the server
|
||||
* ❓ OpenCloud Groupware with generated token authentication
|
||||
* the OpenCloud Groupware backend authenticates directly against Stalwart
|
||||
* it is currently unclear whether Nauthilus supports OIDC token authentication and whether it would be able to forward such requests to the OpenCloud Authentication API
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We need to clarify whether the Nauthilus API allows for a JWT to be submitted for the authentication request, and not only username and password – not to secure the request in itself, but to forward an OIDC token based authentication attempt as part of the payload.
|
||||
|
||||
### Comparing Options
|
||||
|
||||
| | MUA basic | MUA app password | MUA sasl | JMAP clients with basic auth | JMAP clients with JWT auth | Groupware Middleware with master auth | Groupware Middleware with JWT auth |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Stalwart 0.12 with LDAP Directory | ✅ MUA → Stalwart | ❌ not supported with LDAP | ❌ not supported with LDAP | ✅ | ❌ | ✅ | ❌ |
|
||||
| Stalwart 0.12 with OIDC Directory | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
|
||||
| Stalwart 0.12 with Internal Directory | ✅ MUA → Stalwart, must be provisioned in Stalwart | ✅ MUA → Stalwart, must be provisioned in Stalwart | ❌ | ❌ | ❌ unless using Stalwart as IDP | ✅ | ❌ |
|
||||
| Stalwart + OpenCloud Authentication API | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ | ✅ |
|
||||
| Stalwart + Nauthilus + LDAP | ✅ MUA → IMAP proxy → Nauthilus → LDAP | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
|
||||
| Stalwart + Nauthilus + OpenCloud Authentication API | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ MUA → IMAP proxy → Nauthilus → OpenCloud | ✅ | ✅ |
|
||||
| Stalwart + Nauthilus-like OpenCloud Authentication API | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ MUA → Stalwart → OpenCloud | ✅ | ✅ |
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2025-06-24
|
||||
author: Pascal Bleser <p.bleser@opencloud.eu>
|
||||
decision-makers:
|
||||
consulted:
|
||||
informed:
|
||||
title: "Implementing Groupware as a separate Microservice vs integrated in the OpenCloud Stack"
|
||||
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
Should the Groupware backend be an independent microservice or be part of the OpenCloud single binary framework?
|
||||
|
||||
The OpenCloud backend is built on a framework that
|
||||
|
||||
* implements token based authentication between services
|
||||
* allows for a "single binary" deployment mode that runs all services within that one binary
|
||||
* integrates services such as a NATS event bus
|
||||
|
||||
This decision is about whether the Groupware backend service should be implemented within that framework or, instead, be implemented as a standalone backend service.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* single binary deployment strategy is potentially important (TODO how important is it really? stakeholders:?)
|
||||
|
||||
## Considered Options
|
||||
|
||||
* have the Groupware Middleware as an independent microservice
|
||||
* have the Groupware Middleware implemented within the existing OpenCloud framework
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
TODO
|
||||
|
||||
### Consequences
|
||||
|
||||
TODO
|
||||
|
||||
### Confirmation
|
||||
|
||||
TODO
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Independent Microservice
|
||||
|
||||
* (potentially) good: be free from technical decisions made for the existing OpenCloud stack, to avoid carrying potential technical baggage
|
||||
* (potentially) good: make use of a framework that is more fitting for the tasks the Groupware backend needs to accomplish
|
||||
* bad: re-implement framework components that already exist, with the need to maintain those in two separate codebases, or the added complexity of a shared library repository
|
||||
* bad: not have the ability to include the Groupware backend in the single binary deployment
|
||||
* neutral: a separate code repository and delivery for the Groupware backend, which might or might not be of advantage
|
||||
* neutral: may be implemented on a completely different technology stack, including the programming language
|
||||
|
||||
### Part of the framework
|
||||
|
||||
* good: fit into the opinionated choices that were made for the OpenCloud framework so far
|
||||
* good: many aspects are already implemented in the current framework and can be made use of, potentially enhanced for the needs of the Groupware backend
|
||||
* good: the ability to include the Groupware backend in the single binary deployment
|
||||
* neutral: be in the same code repository and part of the same delivery as other services in OpenCloud
|
||||
* neutral: must be implemented in Go on top of the same technology stack
|
||||
@@ -1,294 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2025-06-24
|
||||
author: Pascal Bleser <p.bleser@opencloud.eu>
|
||||
decision-makers:
|
||||
consulted:
|
||||
informed:
|
||||
title: "Resource Linking"
|
||||
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
Which semantic and technical approach to take in order to provide strong integration of the various products and capabilities of OpenCloud, OpenTalk, and potentially other products as well?
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
* a strong integration that allows users to access resources and relationships without having to switch views, which translates into a "mental switch" as well
|
||||
* an innovative approach that differs from the traditional way groupware applications have been designed in the past
|
||||
* TODO more decision drivers from PM
|
||||
* a model that is open and generic enough to integrate many different types of resources and relationships
|
||||
* a model that allows for independent and incremental upgrades to the resources and relationships that can be contributed by each service
|
||||
|
||||
## Considered Options
|
||||
|
||||
* resource linking
|
||||
* application launchers
|
||||
* TODO? can we come up with more ideas?
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
TODO
|
||||
|
||||
### Consequences
|
||||
|
||||
TODO
|
||||
|
||||
### Confirmation
|
||||
|
||||
TODO
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Resource Linking
|
||||
|
||||
This concept primarily resides on the idea of having resources, which have attributes, and relations between them, pretty much as [RDF (Resource Description Framework)](https://www.w3.org/RDF/) does, where the Groupware backend provides services to explore relations of a given resource.
|
||||
|
||||
* good: decoupling of UI, backends as well as other participants, as backends can gradually evolve the relationships and resources they understand and can contribute to over time, as well as for the UI that may just silently ignore resources it does not support yet or does not want to present to the user
|
||||
* good: potential for an asynchronous architecture that would enable the UI to present some resources early without having to wait for those that require more processing time or are provided by services that happen to be under heavier load
|
||||
* good: it should provide ammunition for a modern and original UI that is centered around resources and relationships rather than the usual visual paradigms
|
||||
* bad: it might be a challenge to implement this approach in a performant way with rapid response times, as it could cause additional complexity and storage services (e.g. to denormalize reverse indexes, cache expensive resource graphs, etc...)
|
||||
|
||||
#### URNs
|
||||
|
||||
Each resource has a unique identifier, for which [URNs (Uniform Resource Names)](https://www.rfc-editor.org/rfc/rfc1737) seem the best representation.
|
||||
|
||||
URNs are composed of
|
||||
|
||||
* a namespace identifier
|
||||
* a namespace-specific string
|
||||
|
||||
As a convention, we will use the following:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><code>urn:</code></th>
|
||||
<th>ns</th>
|
||||
<th colspan="2">namespace specific string</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>urn:</code></td>
|
||||
<td><code>oc:</code></td>
|
||||
<td><code><type>:</code></td>
|
||||
<td><code><unique identifier>:</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
##### Examples
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><code>urn:</code></th>
|
||||
<th>namespace</th>
|
||||
<th>type</th>
|
||||
<th>unique id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>urn:</code></td>
|
||||
<td><code>oc:</code></td>
|
||||
<td><code>user:</code></td>
|
||||
<td><code>camina.drummer</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>urn:</code></td>
|
||||
<td><code>oc:</code></td>
|
||||
<td><code>contact:</code></td>
|
||||
<td><code>klaes.ashford</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>urn:</code></td>
|
||||
<td><code>oc:</code></td>
|
||||
<td><code>event:</code></td>
|
||||
<td><code>dd4ea520-e414-41e1-b545-b1c7d4ce57e7</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>urn:</code></td>
|
||||
<td><code>oc:</code></td>
|
||||
<td><code>mail:</code></td>
|
||||
<td><code><1e8074e8-cd56-4358-9f9e-f17cb701b950@opa.org></code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### Exploration API
|
||||
|
||||
Whenever the user puts a resource into focus in the OpenCloud Groupware UI (i.e. by selecting/clicking that resource, e.g. the sender of an email), it may send a request to the Groupware service API to inquire about related resources.
|
||||
|
||||
What those related resources are still stands to be determined, but examples could be along the lines of
|
||||
|
||||
* unread emails from the same sender
|
||||
* emails exchanged with that sender in the last 7 days
|
||||
* files recently shared with that user
|
||||
* spaces or groups in common with that user
|
||||
* OpenTalk meetings planned within the next 3 days
|
||||
|
||||
In order to decouple the Groupware service from which resources and relations are supported,
|
||||
|
||||
* whenever such an exploration request is received, the Groupware service forwards it to all known services, in a "fan-out" model
|
||||
* each service can understand the focused resource, or not, but if it does it may return related resources that it is capable of providing using its data model (e.g. OpenTalk providing related meeting resources, OpenCloud Groupware providing related calendar events, contacts, mails, etc...)
|
||||
* ideally, that happens in an asynchronous fashion, using e.g. [SSE (Server Side Events)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) to push results to the OpenCloud UI to avoid having to wait for the slowest contributor, although that pushes the "reduce" part of this ["map-reduce" operation](https://en.wikipedia.org/wiki/MapReduce) to the client
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
c(client)
|
||||
subgraph backend
|
||||
a(opencloud api)
|
||||
g(groupware)
|
||||
ot(opentalk)
|
||||
u(users)
|
||||
s(stalwart)
|
||||
k(keycloak)
|
||||
end
|
||||
subgraph storage
|
||||
ots@{ shape: cyl, label: "opentalk\nstorage"}
|
||||
ss@{ shape: cyl, label: "stalwart\nstorage"}
|
||||
l@{ shape: cyl, label: "ldap"}
|
||||
end
|
||||
c-->|/related/urn:oc:user:camina.drummer|a
|
||||
a-->|/related/urn:oc:user:camina.drummer|g
|
||||
g-->s
|
||||
s-->ss
|
||||
a-->|/related/urn:oc:user:camina.drummer|ot
|
||||
ot-->ots
|
||||
a-->|/related/urn:oc:user:camina.drummer|u
|
||||
u-->k
|
||||
k-->l
|
||||
|
||||
ot-.->|urn:oc:meeting:232403bc-b98f-4643-a917-80bdcfc7aaba|a
|
||||
g-.->|urn:oc:event:e5193ad3-8f1c-4162-8593-69fe659bcc08|a
|
||||
```
|
||||
|
||||
This allows a decoupling of all the participants, enabling each service to add, remove or alter relationships that it is able to contribute for a given resource type.
|
||||
|
||||
Obviously, the UI needs to be able to understand resource types to know how to represent them, but if it silently ignores resource types that it does not know of, backends can evolve independently from the UI.
|
||||
|
||||
#### JSON-LD
|
||||
|
||||
[JSON-LD (JSON for Linking Data)](https://json-ld.org/) seems like a potent representation format for those relationships in a REST environment.
|
||||
|
||||
It could look something like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": {
|
||||
"@user": "https://schema.opencloud.eu/user.jsonld",
|
||||
"link": "https://schema.opencloud.eu/linked.jsonld"
|
||||
},
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:cdrummer",
|
||||
"name": "Camina Drummer",
|
||||
"email": "camina@opa.org",
|
||||
"roles": ["admin", "pirate"],
|
||||
"link:rooms": [
|
||||
{
|
||||
"@context": {
|
||||
"@room": "https://meta.opencloud.eu/room.jsonld",
|
||||
"link": "https://schema.opencloud.eu/linked.jsonld"
|
||||
},
|
||||
"@id": "urn:oc:room:a3f19df6-6c7d-45fa-b16c-6e168e2a2a43",
|
||||
"name": "OPA Leadership Standup 2355-02-27",
|
||||
"start": "2355-02-27T10:58:15.918Z",
|
||||
"end": "2355-02-27T13:52:59.010Z",
|
||||
"started_by": {
|
||||
"@context": "https://meta.opencloud.eu/user.jsonld",
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:adawes",
|
||||
"name": "Anderson Dawes",
|
||||
"email": "anderson@opa.org"
|
||||
},
|
||||
"link:events": [
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/event.jsonld",
|
||||
"@type": "urn:oc:type:event",
|
||||
"@id": "urn:oc:event:3e041c88-088c-4015-a32e-5560561f6e26",
|
||||
"start": "2355-02-27T11:09:15.918Z",
|
||||
"end": "2355-02-27T13:52:59.010Z",
|
||||
"status": "confirmed",
|
||||
"invited": [
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/user.jsonld",
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:adawes",
|
||||
"name": "Anderson Dawes",
|
||||
"email": "anderson@opa.org"
|
||||
},
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/user.jsonld",
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:kashford",
|
||||
"name": "Klaes Ashford",
|
||||
"email": "klaes@opa.org"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/contact.jsonld",
|
||||
"@type": "urn:oc:type:contact",
|
||||
"@id": "urn:oc:contact:9ccb247d-a728-4d8f-9259-c28cf6cef567",
|
||||
"name": "Naomi Nagata",
|
||||
"email": "naomo@opa.org"
|
||||
},
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/user.jsonld",
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:adawes",
|
||||
"name": "Anderson Dawes",
|
||||
"email": "anderson@opa.org"
|
||||
},
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/user.jsonld",
|
||||
"@type": "urn:oc:type:user",
|
||||
"@id": "urn:oc:user:kashford",
|
||||
"name": "Klaes Ashford",
|
||||
"email": "klaes@opa.org"
|
||||
}
|
||||
],
|
||||
"chat": {
|
||||
"@context": "https://meta.opencloud.eu/file.jsonld",
|
||||
"@type": "urn:oc:type:file",
|
||||
"@id": "urn:oc:file:OPA:chatlogs/2355/02/27/a3f19df6-6c7d-45fa-b16c-6e168e2a2a43.md",
|
||||
"href": "https://cloud.opencloud.eu/spaces/OPA/chatlogs/2355/02/27/a3f19df6-6c7d-45fa-b16c-6e168e2a2a43.md"
|
||||
}
|
||||
}
|
||||
],
|
||||
"link:mails": [
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/mail.jsonld",
|
||||
"@type": "urn:oc:type:mail",
|
||||
"@id": "583b9b66-c0b3-41ba-bf6c-a02ec5f4a638@smtp-07.opa.org",
|
||||
"subject": "About bosmang Fred Johnson",
|
||||
"date": "2355-01-03T09:39:44.919Z"
|
||||
},
|
||||
...
|
||||
],
|
||||
"link:shares": [
|
||||
{
|
||||
"@context": "https://meta.opencloud.eu/share.jsonld",
|
||||
"@type": "urn:oc:type:share",
|
||||
"@id": "841ef259-584d-4ce6-827f-b53f900c988d",
|
||||
"filename": "remember the cant.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Application Launchers
|
||||
|
||||
Have a UI that is comprised of multiple more-or-less separate applications, with an application launcher bar, with each application being an icon in itself in that launcher.
|
||||
|
||||
Similar to what e.g. Google does, or Open-Xchange App Suite.
|
||||
|
||||
* bad: does not make for an integrated application paradigm since users still have to context switch between those applications/views to perform tasks
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2025-06-25
|
||||
author: Pascal Bleser <p.bleser@opencloud.eu>
|
||||
decision-makers:
|
||||
consulted:
|
||||
informed:
|
||||
title: "Groupware Software Stack"
|
||||
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
Which software stack to choose for the implementation of the OpenCloud Groupware service?
|
||||
|
||||
## Considered Options
|
||||
|
||||
* [Go](https://go.dev/) with the OpenCloud framework, as it is used in OpenCloud
|
||||
* Rust, as it is a similarly modern language, with know-how in Opentalk
|
||||
* Java with an opinionated microservice framework (e.g. [Micronaut](https://micronaut.io/))
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The decision was taken to go with the existing Go technology stack used in OpenCloud, since it allows for
|
||||
|
||||
* everyone in the Groupware backend team to contribute
|
||||
* having a single technology stack across all OpenCloud backend features
|
||||
* having the option of a single binary deployment
|
||||
|
||||
### Consequences
|
||||
|
||||
TODO
|
||||
|
||||
### Confirmation
|
||||
|
||||
TODO
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Go
|
||||
|
||||
* good: established in the OpenCloud team, with expertise, potentially broadening the team that can contribute to Groupware development
|
||||
* good: make use of the existing infrastructure and framework, including the single binary deployment option
|
||||
* bad: less mature and capable technology stack, potentially problematic with regards to lack of asynchronous I/O and streamed HTTP processing
|
||||
|
||||
### Rust
|
||||
|
||||
* good: shared knowledge with the team of developers at OpenTalk
|
||||
* bad: little to no experience in the current OpenCloud team
|
||||
|
||||
### Java
|
||||
|
||||
* bad: little to no experience in the current OpenCloud team, with exception of the Groupware members
|
||||
* good: extensive experience with Micronaut with one OpenCloud developer
|
||||
* good: opinionated and well documented
|
||||
* good: cloud native
|
||||
* good: mature technology stack
|
||||
* good: asynchronous I/O and virtual threads make for efficient resource usage
|
||||
* potentially bad: likely to not fit well into low resource environments (although native compilation using GraalVM is possible)
|
||||
* potentially bad: prevents the single binary deployment option from including Groupware
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2025-06-25
|
||||
author: Pascal Bleser <p.bleser@opencloud.eu>
|
||||
decision-makers:
|
||||
consulted:
|
||||
informed:
|
||||
title: "Stalwart as Groupware Backend"
|
||||
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
Which Groupware backend should be used?
|
||||
|
||||
## Considered Options
|
||||
|
||||
* [Stalwart](https://stalw.art/), contains not only mail but also collaborative features in an integrated package
|
||||
* traditional IMAP/POP/SMTP stacks (e.g. [Dovecot](https://www.dovecot.org/) + [Postfix](https://www.postfix.org/))
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The decision was made to go with Stalwart, as it reduces the implementation effort on our end, allowing us for a much faster time-to-market with a significantly smaller team of developers.
|
||||
|
||||
### Consequences
|
||||
|
||||
We will most probably not need to develop much of a calendar or contact stack ourselves, as Stalwart is planning to implement those as part of the upcoming [JMAP](https://jmap.io/spec-core.html) specifications for [contacts](https://jmap.io/spec-contacts.html) and [calendars](https://jmap.io/spec-calendars.html).
|
||||
|
||||
The Groupware API will largely consist of a translation of high-level operations for the UI into JMAP operations sent to Stalwart.
|
||||
|
||||
#### Risks
|
||||
|
||||
On the flip side, there are a number of risks associated with that decision.
|
||||
|
||||
* Stalwart underdelivers on its promises
|
||||
* calendaring provides insufficient features for our implementation (e.g. event series handling being too basic)
|
||||
* not scaling for large deployments
|
||||
* necessary adaptations (e.g. for authentication integration) are rejected upstream
|
||||
* etc...
|
||||
|
||||
### Confirmation
|
||||
|
||||
TODO
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
### Stalwart
|
||||
|
||||
* good: integrated package that contains IMAP/POP, SMTP, anti-spam, AI, encryption at rest and many other features in one
|
||||
* good: modern stack
|
||||
* good: capable of fault tolerance in large deployments through its use of [FoundationDB](https://www.foundationdb.org/)
|
||||
* bad: relatively new project with few to no large scale productive deployments (yet)
|
||||
* bad: significant human [SPoF](https://en.wikipedia.org/wiki/Single_point_of_failure)/[bus factor](https://en.wikipedia.org/wiki/Bus_factor) issue as the development team currently consists of one
|
||||
* good: supports and drives the JMAP protocol ([JMAP Core](https://jmap.io/spec-core.html), [JMAP Mail](https://jmap.io/spec-mail.html), [JMAP Contacts](https://jmap.io/spec-contacts.html), [JMAP Calendars](https://jmap.io/spec-calendars.html), [JMAP Tasks](https://jmap.io/spec-tasks.html), ...),
|
||||
* which provides more high-level operations that we don't need to implement ourselves,
|
||||
* as well as a much cleaner specification that reduces efforts too,
|
||||
* and additionally can be implemented with an efficient stateless HTTP I/O stack
|
||||
* bad: no viable broad JMAP implementation alternatives in case Stalwart does not deliver ([Apache James](https://james.apache.org/) only seems to support a basic subset of JMAP)
|
||||
* good: implements a lot of Groupware "business logic" on its own, reducing the implementation effort on our end,
|
||||
* most notably by not having to deal with IMAP extensions and quirks,
|
||||
* or the complexity of calendar events
|
||||
|
||||
### IMAP/SMTP
|
||||
|
||||
* good: there are a number of alternatives in case a specific implementation does not deliver
|
||||
* good: the best implementation candidates are well-established, used in large amounts of productive deployments, supported by teams of developers
|
||||
* bad: more complex stack composed of numerous components as opposed to an all-in-one implementation
|
||||
* bad: the effort and complexity of having to deal with IMAP,
|
||||
* its complexity due to its extensions and its many quirks,
|
||||
* as well as a significantly less efficient I/O stack that requires stateful session handling
|
||||
* bad: requires the complete implementation of contacts, calendars and tasks in our own stack, as none of those services are provided by IMAP/SMTP backends
|
||||
@@ -1,512 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
date: 2025-07-22
|
||||
author: pbleser-oc
|
||||
consulted: AlexAndBear, butonic, dragotin, fschade, JammingBen, kulmann, martinherfurth, micbar, rhafer
|
||||
title: "API for the Groupware Web UI"
|
||||
---
|
||||
<!-- markdownlint-disable-file MD024 MD033 -->
|
||||
|
||||
## Context
|
||||
|
||||
We need a comprehensive HTTP API for the OpenCloud Web UI to provide access to the following (upcoming) modules and Groupware functionalities:
|
||||
|
||||
* Mail
|
||||
* Contacts
|
||||
* Calendar
|
||||
* Tasks
|
||||
* Chat
|
||||
* Configuration
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph clients
|
||||
ui(OpenCloud UI)
|
||||
muas(Other<br>MUAs)
|
||||
end
|
||||
subgraph Backend
|
||||
subgraph OpenCloud
|
||||
direction TB
|
||||
groupware("OpenCloud<br>Groupware")
|
||||
drive("OpenCloud<br>Drive")
|
||||
end
|
||||
stalwart(Stalwart)
|
||||
end
|
||||
subgraph Storage
|
||||
drive_storage[(drive<br>storage)]
|
||||
stalwart_metadata[(metadata<br>storage)]
|
||||
stalwart_storage[(object<br>storage)]
|
||||
end
|
||||
ui x@==>|?|groupware
|
||||
x@{ animate: true }
|
||||
ui-->|Graph|drive
|
||||
muas-->|IMAP,SMTP,*DAV|stalwart
|
||||
groupware-->drive
|
||||
groupware-->|JMAP|stalwart
|
||||
drive-->drive_storage
|
||||
stalwart-->stalwart_metadata
|
||||
stalwart-->stalwart_storage
|
||||
```
|
||||
|
||||
Additionally, the API must also be able to provide information about related resources and their relationships, as outlined in [the Resource Linking ADR](./0003-groupware-resource-linking.md).
|
||||
|
||||
For the OpenCloud Drive services, the communication between UI client and backend services is performed via the [LibreGraph API](https://github.com/opencloud-eu/libre-graph-api), which is based on [Microsoft Graph](https://developer.microsoft.com/en-us/graph). The goal of this ADR is **not** to question or change that decision, and the choice of an option is merely for the communication with the Groupware backend.
|
||||
|
||||
Communication between the OpenCloud Groupware and Stalwart will make use of the [JMAP (JSON Meta Application Protocol) protocol](https://jmap.io/spec-mail.html).
|
||||
|
||||
The API for the OpenCloud Web UI is **not** supposed to be an abstraction of that and thus may use JMAP data formats.
|
||||
|
||||
Other [MUAs (Mail User Agents)](https://en.wikipedia.org/wiki/Email_client) converse directly with Stalwart using [IMAP](https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol) or [POP3](https://en.wikipedia.org/wiki/Post_Office_Protocol), [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol), [CalDAV](https://en.wikipedia.org/wiki/CalDAV), [CardDAV](https://en.wikipedia.org/wiki/CardDAV), or JMAP itself.
|
||||
|
||||
This ADR concerns the decision regarding which API approach/process/technology/specification to use, not the details of the data model and such, which will need to be fleshed out following the requirements and priorities of the OpenCloud UI Client development, regardless of the selected approach.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
### UI Driven
|
||||
|
||||
The decision must be significantly driven by the OpenCloud UI Client developers, since they are the primary consumers of the API.
|
||||
|
||||
They will also be the sole consumers for a foreseeable while until the OpenCloud Groupware UI reaches a stable feature-complete milestone, which is the earliest point in time for the APIs to be considered stable as well and potentially be consumed by third parties.
|
||||
|
||||
Backend developers are stakeholders in that aspect as well though, as the choice of API approach has an impact on the complexity, costs and maintainability of the backend services as well.
|
||||
|
||||
### Economic Awareness
|
||||
|
||||
Reduction of complexity and implementation efforts, albeit not at all costs, and not only on the short run.
|
||||
|
||||
It is obviously of advantage when an option requires less implementation, or less complexity in its implementation.
|
||||
|
||||
### Efficiency
|
||||
|
||||
Regarding efficiency, the goal is to design an API that is tailored to providing responsiveness ([pagination](https://apisyouwonthate.com/blog/api-design-basics-pagination/), [SSEs (Server-Side Events)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events), ...) and good network performance.
|
||||
|
||||
The latter is achieved by minimizing the number of roundtrips between the client and the servers, which, in turn, is typically achieved through the use of higher level APIs as opposed to a granular API that provides more flexibility but also, by its very nature, requires the combination of multiple request-response roundtrips over the wire.
|
||||
|
||||
### Third Party Consumption
|
||||
|
||||
We are assuming that the APIs are public APIs (not just technically) and may be consumed by SDKs and third parties.
|
||||
|
||||
Implications are that care must be put into providing an API that is stable, versioned, has a changelog, and potentially provided as a product with [LTS (Long-term Support)](https://en.wikipedia.org/wiki/Long-term_support) options.
|
||||
|
||||
This also hints at the necessity of a capability exchange/discovery protocol between clients and the Groupware backend, as we will have different versions of clients and servers in the wild, and they need to be able to understand each other. Crucially, if locally running clients are developed, they can go a long time without being updated.
|
||||
|
||||
## Considered Options
|
||||
|
||||
* [LibreGraph](#libregraph)
|
||||
* [JMAP](#jmap)
|
||||
* [custom REST API](#custom-rest-api) (albeit potentially based on standards, at least partially)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The decision was made to go with the custom REST implementation option, mainly due to
|
||||
|
||||
* the use of LibreGraph providing little benefits
|
||||
* if would provide us with a fleshed out API for groupware
|
||||
* but we would not implement it fully
|
||||
* and it is really an API for Outlook and Exchange, not a generic groupware standard
|
||||
* furthermore, a significant blocker is that it does not provide for a way to support multiple accounts for a user
|
||||
* the experience of implementing and using the LibreGraph API for the Drive components has made light of some challenges that we would not like to repeat
|
||||
* using JMAP directly
|
||||
* is a very interesting option in terms of standards, as it is an RFC,
|
||||
* but we currently see that approach as too risky as per the potential complexity of parsing payloads of JMAP commands and their backreferences, plugging those across commands that must be forwarded as-is to Stalwart and others that need to be handled by the Groupware middleware itself, but also the potential need to reverse engineer the high-level meaning of chained low-level JMAP commands in order to implement enrichment, caches, reverse indexes, etc...
|
||||
* however, it might be a better path forward in the future, especially if JMAP becomes a viable option for replacing the current use of LibreGraph as well
|
||||
|
||||
### Consequences
|
||||
|
||||
* we will need to design an API on our own, from scratch, albeit maximally making use of JMAP data structures
|
||||
* that API will need to be maintained as a product, with documentation, versioning, LTS
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
* [LibreGraph](#proscons-libregraph)
|
||||
* [JMAP](#proscons-jmap)
|
||||
* [Custom REST API](#proscons-custom)
|
||||
|
||||
### <a id="proscons-libregraph"/>LibreGraph
|
||||
|
||||
[LibreGraph](https://github.com/opencloud-eu/libre-graph-api) is an API specification that is heavily inspired by and based on [Microsoft Graph](https://developer.microsoft.com/en-us/graph), of which it is a partial implementation, but also with modifications where necessary.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
GET /v1.0/me/messages?$select=sender,subject&$count=50&$orderby=received
|
||||
```
|
||||
|
||||
#### Good
|
||||
|
||||
* is already in use as the API for OpenCloud Drive operations, with a small stack to use it in the OpenCloud Web UI
|
||||
* provides an API and data model that has already been thought out and used in production (albeit with only few different implementations)
|
||||
|
||||
#### Neutral
|
||||
|
||||
* does not have to follow the Microsoft Graph API, can be customized to our own needs, but in which case it becomes doubtful that there is any benefit in mimicking the Graph API in the first place if we diverge from it
|
||||
* there is no compatibility benefit
|
||||
* the only MUA that uses the Microsoft Graph API is Microsoft Outlook, and it is not a goal to support Microsoft Outlook as a MUA beyond standard IMAP/SMTP/CalDAV/CardDAV services (and that would be Microsoft Graph, not LibreGraph nor any customizations we would require)
|
||||
* we will not implement all of the Microsoft Graph API
|
||||
* we will not implement parts of the Microsoft Graph API as-is either, but will require to make modifications
|
||||
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
|
||||
|
||||
#### Bad
|
||||
|
||||
* not an easy API to implement
|
||||
* although we have libraries that take care of some of the more complex parts, such as parsing [OData](https://www.odata.org/) expressions
|
||||
* really only easy to use when backed by a relational database and an object relational mapping framework using [ASP.NET](https://dotnet.microsoft.com/en-us/apps/aspnet) or [JPA](https://en.wikipedia.org/wiki/Jakarta_Persistence)/[Hibernate](https://hibernate.org/)
|
||||
* its data model and peculiar interpretation of REST are really not [idomatic](https://en.wikipedia.org/wiki/HATEOAS) at all, and are clearly the result of reverse engineering the capabilities of Microsoft SQL Server and Exchange into a "standard" from the back, and then Microsoft Outlook's features and capabilities from the front
|
||||
* not tailored to our needs
|
||||
* we will most probably have a lot of cases in which we have to twist the Graph API to express what the UI needs
|
||||
* will require using complex filters, which then require complex parsing in the backend in order to translate them into JMAP
|
||||
* as opposed to directly using an expressive and maximally matching API in the first place
|
||||
* we are likely to encounter use-cases that are not covered by the Graph API (especially due to our resource linking approach)
|
||||
* does not support multiple accounts per user
|
||||
* would require the addition of an account parameter, as a query parameter or as part of the path, which would make every URL in the API incompatible with Microsoft Graph
|
||||
* more implementation effort than JMAP
|
||||
* the JMAP RFCs already provides a data model, and we would end up converting between them all the time, with incompatibilities (Graph has attributes JMAP doesn't, and the other way around)
|
||||
* possibly (probably?) more implementation effort than a custom REST API, due to its complexity
|
||||
|
||||
#### Decision Drivers
|
||||
|
||||
* UI Driven
|
||||
* some members the OpenCloud Web Team strongly prefers not to use LibreGraph due to its complexity and to the fact that we would have to reftrofit operations into an existing API that was designed by a third party
|
||||
* one upside is that there is already a client stack for performing LibreGraph operations, which could be reused to some degree for the Groupware APIs as well; it does not amount to all that much code though
|
||||
* Economic Awareness
|
||||
* more complexity and more effort as the other options due to the inherent complexity of the specification
|
||||
* a data model is already specified in full, which might save us some time on that front
|
||||
* although probably not really either since the actual data model we will work with on the backend is prescribed by JMAP, and we will only be looking to map attributes betsween JMAP and LibreGraph
|
||||
* the data model is not necessarily thorougly documented either, which will leave room for interpretation, also due to incompatibilities between JMAP and Graph
|
||||
* there will be attributes that are defined in JMAP and that we will receive from Stalwart that will not have a corresponding attribute in Graph (or be a list of values as opposed to a single value), and those will require to either lose some data by squashing it into the Graph data model, or extending the Graph data model which renders us incompatible with it
|
||||
* Efficiency
|
||||
* since the API is not tailored to our needs, we are much more likely to end up performing multiple roundtrips for single high level operations
|
||||
* Third Party Consumption
|
||||
* for some of the operations, we could point to the Microsoft Graph documentation, although that would not make for a great experience either, we would probably need to replicate it
|
||||
* our deviations and extensions will have to be maintained just like the other options
|
||||
* LibreGraph doesn't help with API stability either since
|
||||
* we don't implement all of it, and need to document what we implement and what we don't,
|
||||
* won't be compatible either due to modifications (additional parameters, unsupported parameters, different interpretations),
|
||||
* and will just as equally need to evolve it as the other options, requiring the documentation of changes as well
|
||||
* will be required to be maintained as a public API
|
||||
* documentation
|
||||
* LTS
|
||||
* versioning
|
||||
|
||||
### <a id="proscons-jmap"/>JMAP
|
||||
|
||||
[JMAP (JSON Meta Application Protocol)](https://jmap.io/spec.html) is a set of specifications that are codified in RFCs:
|
||||
|
||||
* [RFC 8620](https://tools.ietf.org/html/rfc8620): core JMAP protocol
|
||||
* [RFC 8261](https://tools.ietf.org/html/rfc8621): JMAP Mail
|
||||
* [RFC 8887](https://www.rfc-editor.org/rfc/rfc8887.html): JMAP subprotocol for WebSocket
|
||||
* [RFC 9404](https://www.rfc-editor.org/rfc/rfc9404.html): JMAP Blob Management Extension
|
||||
* [RFC 9425](https://www.rfc-editor.org/rfc/rfc9425.html): JMAP Quotas
|
||||
* [RFC 9553](https://www.rfc-editor.org/rfc/rfc9553.html): uses JSContact
|
||||
* [RFC 8984](https://www.rfc-editor.org/rfc/rfc8984.html): uses JSCalendar
|
||||
|
||||
of which some are still in development at the time of writing:
|
||||
|
||||
* [JMAP Contacts](https://jmap.io/spec-contacts.html)
|
||||
* [JMAP Calendars](https://jmap.io/spec-calendars.html)
|
||||
* [JMAP Sharing](https://jmap.io/spec-sharing.html)
|
||||
* [JMAP Tasks](https://jmap.io/spec-tasks.html)
|
||||
|
||||
To exemplify the JMAP protocol, the following code block is a JMAP request that
|
||||
|
||||
* fetches the 30 last received emails from a mailbox (folder)
|
||||
* the threads of those emails
|
||||
* email metadata of all of those threads, including a preview
|
||||
|
||||
<details open>
|
||||
<summary>Click here to toggle the display of this example.</summary>
|
||||
|
||||
```json
|
||||
[[ "Email/query", {
|
||||
"accountId": "ue150411c",
|
||||
"filter": {
|
||||
"inMailbox": "fb666a55"
|
||||
},
|
||||
"sort": [{
|
||||
"isAscending": false,
|
||||
"property": "receivedAt"
|
||||
}],
|
||||
"collapseThreads": true,
|
||||
"position": 0,
|
||||
"limit": 30,
|
||||
"calculateTotal": true
|
||||
}, "0" ],
|
||||
[ "Email/get", {
|
||||
"accountId": "ue150411c",
|
||||
"#ids": {
|
||||
"resultOf": "0",
|
||||
"name": "Email/query",
|
||||
"path": "/ids"
|
||||
},
|
||||
"properties": [
|
||||
"threadId"
|
||||
]
|
||||
}, "1" ],
|
||||
[ "Thread/get", {
|
||||
"accountId": "ue150411c",
|
||||
"#ids": {
|
||||
"resultOf": "1",
|
||||
"name": "Email/get",
|
||||
"path": "/list/*/threadId"
|
||||
}
|
||||
}, "2" ],
|
||||
[ "Email/get", {
|
||||
"accountId": "ue150411c",
|
||||
"#ids": {
|
||||
"resultOf": "2",
|
||||
"name": "Thread/get",
|
||||
"path": "/list/*/emailIds"
|
||||
},
|
||||
"properties": [
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"hasAttachment",
|
||||
"from",
|
||||
"subject",
|
||||
"receivedAt",
|
||||
"size",
|
||||
"preview"
|
||||
]
|
||||
}, "3" ]]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### Good
|
||||
|
||||
* flexible protocol that can easily be implemented by clients
|
||||
* potentially does not require implementation efforts on the backend side
|
||||
* would obviously support the full potential of JMAP and Stalwart
|
||||
* we could potentially extend JMAP with our own data models and operations based on the [JMAP Core Protocol](https://jmap.io/spec-core.html), possibly even propose them as RFCs
|
||||
* we can start with JMAP request objects that contain only a few or even only one JMAP methods (indicated by the [maxCallsInRequest capability](https://datatracker.ietf.org/doc/html/rfc8620#section-2)), allowing more calls as we need
|
||||
* clients could implement the funtionality they need using multiple requests in the beginning, then we implement missing functionality on the server
|
||||
* this would allow us to speed up requests that we need while at the same time giving clients the ability to make any necessary individual calls
|
||||
* probably only a partially useful approach since chaining JMAP requests is necessary for even the most mundane operations, to avoid the inefficiency of multiple roundtrips
|
||||
|
||||
#### Neutral
|
||||
|
||||
* the [existing JMAP specifications](https://jmap.io/spec.html) will not cover 100% of the Web UI API needs (e.g. configuration settings[^config], [resource linking](./0003-groupware-resource-linking.md), ...), but that does not prevent us from implementing additional custom APIs, either as non-JMAP REST APIs, or as extensions of JMAP
|
||||
* we would need to gauge whether JMAP communication
|
||||
* should occur directly between the OpenCloud UI and Stalwart,
|
||||
* or whether an OpenCloud Groupware service should be used as an intermediary and as an [anti-corruption layer](https://ddd-practitioners.com/home/glossary/bounded-context/bounded-context-relationship/anticorruption-layer/)
|
||||
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
|
||||
|
||||
[^config]: although Stalwart will most likely have a [JMAP API for application configuration settings as well](https://matrix.to/#/!blIcSTIPwfKMtOEWcg:matrix.org/$CD9C6IZN28bbmN0Arb_Y-RapgsS4XqAqnDgf15yJahM?via=matrix.org&via=mozilla.org&via=chat.opencloud.eu)
|
||||
> Message from [Mauro](https://github.com/mdecimus):
|
||||
>
|
||||
> Hi everyone, I'm curious what you think about standardizing a simple protocol/extension for users to easily manage certain account settings directly from their email clients. For instance, such a protocol could handle:
|
||||
>
|
||||
> * Passwords, app passwords, and MFA settings
|
||||
> * Locale preferences
|
||||
> * Timezone configuration
|
||||
> * Basic email forwarding (without needing custom Sieve scripts)
|
||||
> * Vacation/auto-responses
|
||||
> * Blocking specific email addresses
|
||||
> * Spam reporting (though not strictly a setting)
|
||||
> * Calendar-related preferences
|
||||
> * Encryption-at-rest settings
|
||||
> * Mail auto-expunge policies
|
||||
> * ... and potentially more.
|
||||
>
|
||||
> My initial thought is to implement this as a JMAP extension rather than inventing another protocol similar to ManageSieve, which feels somewhat like a "Frankenstein" IMAP extension.
|
||||
>
|
||||
> Many mailbox providers already offer some or all of these settings through their web interfaces, but a standardized JMAP-based extension could let users adjust these directly within their preferred email clients or via APIs.
|
||||
|
||||
#### Bad
|
||||
|
||||
* potentially bad: most probably too flexible for its own good, as it makes it difficult to reverse-engineer the high-level meaning of a set of JMAP requests in order to capture its semantics, e.g. to implement caching or reverse indexes for performance
|
||||
* since the OpenCloud Drive backends use the LibreGraph API, using a JMAP based API for Groupware bears the risk of having multiple APIs to do the same thing, which we need to be careful about, and avoid if possible
|
||||
|
||||
> [!NOTE]
|
||||
> This seems like a mild "bad" item, but the risk risk here is significant: if it turns out that we need to capture the semantics of API requests to perform additional operations (e.g. caching or indexing for performance reasons, or to decorate the data from Stalwart with information from other services), then we would have to re-implement the whole API as JMAP is too complex to parse to extract semantics from.
|
||||
|
||||
#### Two Approaches
|
||||
|
||||
There are two approaches as to how to implement our protocol based on JMAP:
|
||||
|
||||
* either our clients must split JMAP operations and send some to Stalwart, and others to the Groupware backend (depending on which endpoint is in charge of which API)
|
||||
* or our clients send all the JMAP operations to the Groupware backend, which is then in charge to relay JMAP commands that are to be handled by Stalwart to Stalwart
|
||||
|
||||
##### Directly to Stalwart
|
||||
|
||||
If the OpenCloud UI Client communicates directly with Stalwart (using JMAP), then
|
||||
|
||||
* good: we don't need to implement any sort of "bridge" in the OpenCloud Groupware service (although the implementation effort is likely to be low)
|
||||
* good: we avoid an additional hop in the network, gaining on performance and potentially on throughput
|
||||
* bad: it will have to perform additional API requests for data and features that are not provided by Stalwart with the OpenCloud Groupware service (e.g. [Resource Linking](./0003-groupware-resource-linking.md)) as well, which is likely to lead to an increase in the number of network roundtrips
|
||||
* bad: would be unable to extend the protocol with OpenCloud Groupware specific models and data
|
||||
* bad: would be unable to implement caching or similar performance improvements if necessary
|
||||
* bad: prevents us from implementing infrastructure features that are not present in Stalwart and might never be in the way we would need them, e.g. sharding across multi-site redundancy
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph clients
|
||||
ui(OpenCloud UI)
|
||||
muas(Other<br>MUAs)
|
||||
end
|
||||
subgraph Backend
|
||||
subgraph OpenCloud
|
||||
direction TB
|
||||
groupware("OpenCloud<br>Groupware")
|
||||
drive("OpenCloud<br>Drive")
|
||||
end
|
||||
stalwart(Stalwart)
|
||||
end
|
||||
subgraph Storage
|
||||
drive_storage[(drive<br>storage)]
|
||||
stalwart_metadata[(metadata<br>storage)]
|
||||
stalwart_storage[(object<br>storage)]
|
||||
end
|
||||
ui x@==>|JMAP|stalwart
|
||||
x@{ animate: true }
|
||||
ui y@==>|JMAP or REST|groupware
|
||||
y@{ animate: true }
|
||||
ui-->|Graph|drive
|
||||
muas-->|IMAP,SMTP,*DAV|stalwart
|
||||
groupware-->drive
|
||||
groupware-->|JMAP|stalwart
|
||||
drive-->drive_storage
|
||||
stalwart-->stalwart_metadata
|
||||
stalwart-->stalwart_storage
|
||||
```
|
||||
|
||||
##### Groupware intermediary
|
||||
|
||||
Alternatively, if the OpenCloud UI Client exclusively communicates with the OpenCloud Groupware service (using JMAP), then
|
||||
|
||||
* good: the OpenCloud Groupware service acts as a anti-corruption layer, which would allow us to
|
||||
* implement caching and similar performance improvement measures if necessary (e.g. reverse indexing of costly data)
|
||||
* implement infrastructure features that are not present in Stalwart and might never be in the way we would need them, e.g. sharding across multi-site redundancy
|
||||
* extend the JMAP protocol
|
||||
* good: it enables us to minimize network roundtrips between the OpenCloud UI Client and the OpenCloud Groupware backend as there is no need to perform additional requests elsewhere
|
||||
* bad: we have an additional intermediary hop that "just" relays operations to Stalwart most of the time
|
||||
* due to Go HTTP stack limitations (lack of zero-copy asynchronous I/O),
|
||||
* that might incur a cost of "needlessly" copying data in memory
|
||||
* as well as performing blocking I/O (at the very least since JMAP requests first need to be read in full by te OpenCloud Groupware before they then can be sent to Stalwart more or less as-is, and the same applies to the responses)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph clients
|
||||
ui(OpenCloud UI)
|
||||
muas(Other<br>MUAs)
|
||||
end
|
||||
subgraph Backend
|
||||
subgraph OpenCloud
|
||||
direction TB
|
||||
groupware("OpenCloud<br>Groupware")
|
||||
drive("OpenCloud<br>Drive")
|
||||
end
|
||||
stalwart(Stalwart)
|
||||
end
|
||||
subgraph Storage
|
||||
drive_storage[(drive<br>storage)]
|
||||
stalwart_metadata[(metadata<br>storage)]
|
||||
stalwart_storage[(object<br>storage)]
|
||||
end
|
||||
ui y@==>|JMAP|groupware
|
||||
y@{ animate: true }
|
||||
ui-->|Graph|drive
|
||||
muas-->|IMAP,SMTP,*DAV|stalwart
|
||||
groupware-->drive
|
||||
groupware-->|JMAP|stalwart
|
||||
drive-->drive_storage
|
||||
stalwart-->stalwart_metadata
|
||||
stalwart-->stalwart_storage
|
||||
```
|
||||
|
||||
#### Decision Drivers
|
||||
|
||||
* UI Driven
|
||||
* the UI team did not express any particular preference for this option, but the JMAP protocol is simple to implement on any client
|
||||
* Economic Awareness
|
||||
* there would be less of a need to develop an API, but that doesn't put much into the balance
|
||||
* developing a generic inbound JMAP command processing engine that is capable of resolving backreferences with requests that can be sent out to different backends (Stalwart, Drive, Groupware, OpenTalk, ...) seems risky in terms of complexity, also since Go doesn't have much of a [well-supported Reactive framework](https://github.com/ReactiveX/RxGo)
|
||||
* Efficiency
|
||||
* the ability of the JMAP protocol to chain multiple low-level commands provides for a very efficient way to compose higher-level operations without the need for multiple round-trips
|
||||
* Third Party Consumption
|
||||
* for some of the operations, we could point to the JMAP documentation and RFCs, although that would not make for a great experience either, we would probably need to replicate it
|
||||
* our protocol extensions will have to be maintained just like the other options
|
||||
* will be required to be maintained as a public API
|
||||
* documentation
|
||||
* LTS
|
||||
* versioning
|
||||
|
||||
### <a id="proscons-custom"/>Custom REST API
|
||||
|
||||
A custom REST API would implement the resources and semantics as they are needed by the UI, and would be strongly if not fully UI-driven.
|
||||
|
||||
The data model should remain close or equal to JMAP's, to avoid data loss by converting back and forth.
|
||||
|
||||
We might look into existing specifications for formatting JSON payloads, such as [JSON:API](https://jsonapi.org/) or partial ones such as such as [JSON-LD](https://json-ld.org/) for relationships between resources, but this is currently outside of the scope of this ADR.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph clients
|
||||
ui(OpenCloud UI)
|
||||
muas(Other<br>MUAs)
|
||||
end
|
||||
subgraph Backend
|
||||
subgraph OpenCloud
|
||||
direction TB
|
||||
groupware("OpenCloud<br>Groupware")
|
||||
drive("OpenCloud<br>Drive")
|
||||
end
|
||||
stalwart(Stalwart)
|
||||
end
|
||||
subgraph Storage
|
||||
drive_storage[(drive<br>storage)]
|
||||
stalwart_metadata[(metadata<br>storage)]
|
||||
stalwart_storage[(object<br>storage)]
|
||||
end
|
||||
ui y@==>|REST|groupware
|
||||
y@{ animate: true }
|
||||
ui-->|Graph|drive
|
||||
muas-->|IMAP,SMTP,*DAV|stalwart
|
||||
groupware-->drive
|
||||
groupware-->|JMAP|stalwart
|
||||
drive-->drive_storage
|
||||
stalwart-->stalwart_metadata
|
||||
stalwart-->stalwart_storage
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
GET /groupware/startup/1/?mails=50
|
||||
```
|
||||
|
||||
#### Good
|
||||
|
||||
* completely tailored to the needs of the OpenCloud UI
|
||||
* a higher-level API allows for easily understanding the semantic of each operation, which enables the potential for keeping track of data in order to implement reverse indexes and caching, if necessary to achieve functional or performance goals, as opposed to using a lower-level API such as JMAP which is maximally flexible and difficult to reverse-engineer the meaning of the operation and data
|
||||
* can also be tailored to the capabilities of JMAP without exposing all of its flexibility
|
||||
* provides the potential for expanding upon what JMAP provides
|
||||
* would support the full potential of JMAP and Stalwart since the API would be designed accordingly
|
||||
* allows learning how to use and cache individual JMAP method call responses, allowing to make a better decision in the future if JMAP should be used by clients
|
||||
|
||||
#### Neutral
|
||||
|
||||
* if there is a requirement for considering that API as a public API for third party integrators, then the API also needs to be documented, maintained, versioned, and kept stable as much as possible (this is neutral because it is a requirement that exists with every option)
|
||||
|
||||
#### Bad
|
||||
|
||||
* only partially follows any standards (REST, JSON, JMAP for data models)
|
||||
* requires designing the API from scratch, as opposed to using the Graph API which already prescribes one
|
||||
* although it probably makes sense to re-use the data model of JMAP, which is prescribed in RFCs, also to avoid data loss and copying things around needlessly
|
||||
* since the OpenCloud Drive backends use the LibreGraph API, using a custom REST API for Groupware bears the risk of having multiple APIs to do the same thing, which we need to be careful about, and avoid if possible
|
||||
|
||||
#### Decision Drivers
|
||||
|
||||
* UI Driven
|
||||
* favoured solution for the OpenCloud Web UI team
|
||||
* Economic Awareness
|
||||
* designing a new custom API is not much effort since it is UI requirements driven
|
||||
* maintaining a new custom API or JMAP extensions is not more effort either, since the exact same thing needs to be done with LibreGraph, as it will have numerous exceptions and will require documenting those, as well as which parts of the Microsoft Graph API are implemented and which aren't
|
||||
* Efficiency
|
||||
* the most efficient approach since it is tailored to what is actually needed for the OpenCloud UI, which will allow us to reduce the roundtrips to a minimum
|
||||
* Third Party Consumption
|
||||
* a custom API will be required to be maintained as a public API
|
||||
* documentation
|
||||
* LTS
|
||||
* versioning
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
date: 2025-07-07
|
||||
author: Pascal Bleser <p.bleser@opencloud.eu>
|
||||
decision-makers:
|
||||
consulted:
|
||||
informed:
|
||||
title: "Groupware Configuration Settings"
|
||||
template: https://raw.githubusercontent.com/adr/madr/refs/tags/4.0.0/template/adr-template.md
|
||||
---
|
||||
|
||||
* Status: draft
|
||||
|
||||
## Context
|
||||
|
||||
User Preferences need to be configurable through the UI and persisted in a backend service in order to be reliably available and backed up.
|
||||
|
||||
Such configuration options have default values that need to be set on multiple levels:
|
||||
|
||||
* globally
|
||||
* by tenant
|
||||
* by sub-tenant
|
||||
* by group of users
|
||||
* by user
|
||||
|
||||
Some options might even be client-specific, e.g. differ between the OpenCloud Web UI on desktop and the OpenCloud Web UI on mobile.
|
||||
|
||||
Furthermore, some options might be enforced and may not be overridden on every level (e.g. only globally or by tenant, by not modifiable by users.)
|
||||
|
||||
Ideally, the configuration settings have an architecture that permits pluggable sources.
|
||||
|
||||
This level of necessary complexity has a few drawbacks, the primary one being that it can become difficult to find out why a user sees this or that behavior in their UI, and thus to trace down where a given configuration setting is made (globally, on tenant level, etc...). It is thus critical to include tooling that allows to debug them.
|
||||
|
||||
## Considered Options
|
||||
|
||||
TODO
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
TODO
|
||||
|
||||
### Consequences
|
||||
|
||||
TODO
|
||||
|
||||
### Confirmation
|
||||
|
||||
TODO
|
||||
|
||||
## Pros and Cons of the Options
|
||||
|
||||
TODO
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/opencloud-eu/opencloud
|
||||
|
||||
go 1.25.8
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
@@ -9,21 +9,18 @@ require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||
github.com/Nerzal/gocloak/v13 v13.9.0
|
||||
github.com/ProtonMail/go-crypto v1.1.6
|
||||
github.com/bbalet/stopwords v1.0.0
|
||||
github.com/beevik/etree v1.6.0
|
||||
github.com/blevesearch/bleve/v2 v2.6.0
|
||||
github.com/brianvoe/gofakeit/v7 v7.7.3
|
||||
github.com/blevesearch/bleve/v2 v2.5.7
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible
|
||||
github.com/coreos/go-oidc/v3 v3.19.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/cs3org/go-cs3apis v0.0.0-20260424072047-8d9ef7076ae9
|
||||
github.com/davidbyttow/govips/v2 v2.18.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/ggwhite/go-masker v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/render v1.0.3
|
||||
github.com/go-jose/go-jose/v3 v3.0.5
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
@@ -44,36 +41,30 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/go-tika v0.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gookit/config/v2 v2.2.9
|
||||
github.com/gookit/config/v2 v2.2.7
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
|
||||
github.com/invopop/validation v0.8.0
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1
|
||||
github.com/jhillyerd/enmime/v2 v2.2.0
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/jinzhu/now v1.1.5
|
||||
github.com/justinas/alice v1.2.0
|
||||
github.com/kovidgoyal/imaging v1.8.22
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/leonelquinteros/gotext v1.7.3-0.20260422134830-b012b4ccae69
|
||||
github.com/libregraph/idm v0.5.0
|
||||
github.com/libregraph/lico v0.67.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/miekg/dns v1.1.68
|
||||
github.com/libregraph/lico v0.66.0
|
||||
github.com/mna/pigeon v1.3.0
|
||||
github.com/moby/moby/api v1.54.2
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
|
||||
github.com/nats-io/nats-server/v2 v2.14.3
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/oklog/run v1.2.0
|
||||
github.com/nats-io/nats-server/v2 v2.14.0
|
||||
github.com/nats-io/nats.go v1.51.0
|
||||
github.com/olekukonko/tablewriter v1.1.4
|
||||
github.com/onsi/ginkgo v1.16.5
|
||||
github.com/onsi/ginkgo/v2 v2.32.0
|
||||
github.com/onsi/gomega v1.42.1
|
||||
github.com/open-policy-agent/opa v1.18.2
|
||||
github.com/onsi/ginkgo/v2 v2.28.3
|
||||
github.com/onsi/gomega v1.40.0
|
||||
github.com/open-policy-agent/opa v1.15.2
|
||||
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260310090739-853d972b282d
|
||||
github.com/opencloud-eu/reva/v2 v2.46.4-0.20260625152426-8cff2a7032ec
|
||||
github.com/opencloud-eu/reva/v2 v2.46.6
|
||||
github.com/opensearch-project/opensearch-go/v4 v4.6.0
|
||||
github.com/orcaman/concurrent-map v1.0.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
@@ -81,10 +72,9 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/r3labs/sse/v2 v2.10.0
|
||||
github.com/riandyrn/otelchi v0.12.3
|
||||
github.com/rogpeppe/go-internal v1.15.0
|
||||
github.com/rogpeppe/go-internal v1.14.1
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/sethvargo/go-password v0.3.1
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -92,39 +82,36 @@ require (
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/test-go/testify v1.1.4
|
||||
github.com/testcontainers/testcontainers-go v0.43.0
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.43.0
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.42.0
|
||||
github.com/theckman/yacspin v0.13.12
|
||||
github.com/thejerf/suture/v4 v4.0.6
|
||||
github.com/tidwall/gjson v1.19.0
|
||||
github.com/tidwall/pretty v1.2.1
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tus/tusd/v2 v2.10.0
|
||||
github.com/tus/tusd/v2 v2.9.2
|
||||
github.com/unrolled/secure v1.16.0
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||
github.com/wk8/go-ordered-map v1.0.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
go-micro.dev/v4 v4.11.0
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/contrib/zpages v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
go.opentelemetry.io/contrib/zpages v0.68.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/tools v0.47.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa
|
||||
google.golang.org/grpc v1.82.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -142,7 +129,8 @@ require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/Masterminds/sprig v2.22.0+incompatible // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/ajg/form v1.5.1 // indirect
|
||||
github.com/alexedwards/argon2id v1.0.0 // indirect
|
||||
@@ -150,34 +138,31 @@ require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.2 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.3.11 // indirect
|
||||
github.com/blevesearch/geo v0.2.5 // indirect
|
||||
github.com/blevesearch/go-faiss v1.1.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.22.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
|
||||
github.com/blevesearch/geo v0.2.4 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.26 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.2.0 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.2.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.3 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.3.4 // indirect
|
||||
github.com/blevesearch/zapx/v17 v17.1.2 // indirect
|
||||
github.com/blevesearch/vellum v1.1.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
|
||||
github.com/bluele/gcache v0.0.2 // indirect
|
||||
github.com/bombsimon/logrusr/v3 v3.1.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||
github.com/ceph/go-ceph v0.40.0 // indirect
|
||||
github.com/ceph/go-ceph v0.39.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cevaris/ordered_map v0.0.0-20190319150403-3adeae072e73 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
@@ -197,7 +182,7 @@ require (
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/deckarep/golang-set v1.8.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
|
||||
github.com/dgraph-io/ristretto v0.2.0 // indirect
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
|
||||
@@ -209,14 +194,12 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc // indirect
|
||||
github.com/emersion/go-message v0.18.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/emvi/iso-639-1 v1.1.1 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.5.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gdexlab/go-render v1.0.1 // indirect
|
||||
github.com/go-acme/lego/v4 v4.4.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
@@ -239,24 +222,24 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-test/deep v1.1.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gofrs/uuid v4.4.0+incompatible // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
|
||||
github.com/google/renameio/v2 v2.0.2 // indirect
|
||||
github.com/gookit/goutil v0.8.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gookit/goutil v0.7.4 // indirect
|
||||
github.com/gorilla/handlers v1.5.1 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
|
||||
@@ -267,25 +250,24 @@ require (
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/iancoleman/strcase v0.3.0 // indirect
|
||||
github.com/imdario/mergo v0.3.15 // indirect
|
||||
github.com/inbucket/html2text v0.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juliangruber/go-intersect v1.1.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 // indirect
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/libregraph/oidc-go v1.1.0 // indirect
|
||||
github.com/longsleep/go-metrics v1.0.0 // indirect
|
||||
@@ -299,6 +281,7 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||
github.com/maxymania/go-system v0.0.0-20170110133659-647cc364bf0b // indirect
|
||||
github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 // indirect
|
||||
github.com/miekg/dns v1.1.68 // indirect
|
||||
github.com/mileusna/useragent v1.3.5 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
@@ -308,6 +291,7 @@ require (
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
github.com/moby/moby/client v0.4.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
@@ -318,10 +302,11 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/nxadm/tail v1.4.8 // indirect
|
||||
github.com/oklog/run v1.2.0 // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.2.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.6 // indirect
|
||||
@@ -342,7 +327,7 @@ require (
|
||||
github.com/prometheus/alertmanager v0.31.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/statsd_exporter v0.22.8 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
@@ -359,29 +344,31 @@ require (
|
||||
github.com/sercand/kuberesolver/v5 v5.1.1 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/sethvargo/go-diceware v0.5.0 // indirect
|
||||
github.com/sethvargo/go-password v0.3.1 // indirect
|
||||
github.com/shamaton/msgpack/v2 v2.4.1 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
|
||||
github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spacewander/go-suffix-tree v0.0.0-20191010040751-0865e368c784 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/studio-b12/gowebdav v0.9.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/trustelem/zxcvbn v1.0.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.34 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map v1.0.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
@@ -399,12 +386,13 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
|
||||
@@ -91,8 +91,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
|
||||
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
@@ -113,6 +113,8 @@ github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6Ct
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.61.976/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA=
|
||||
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964 h1:I9YN9WMo3SUh7p/4wKeNvD/IQla3U3SUa61U7ul+xM4=
|
||||
github.com/amoghe/go-crypt v0.0.0-20220222110647-20eada5f5964/go.mod h1:eFiR01PwTcpbzXtdMces7zxg6utvFM5puiWHpWB8D/k=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
@@ -130,8 +132,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go v1.37.27/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bbalet/stopwords v1.0.0 h1:0TnGycCtY0zZi4ltKoOGRFIlZHv0WqpoIGUsObjztfo=
|
||||
github.com/bbalet/stopwords v1.0.0/go.mod h1:sAWrQoDMfqARGIn4s6dp7OW7ISrshUD8IP2q3KoqPjc=
|
||||
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
@@ -145,47 +145,46 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0=
|
||||
github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
|
||||
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||
github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4=
|
||||
github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY=
|
||||
github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4=
|
||||
github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko=
|
||||
github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE=
|
||||
github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4=
|
||||
github.com/blevesearch/go-faiss v1.1.0 h1:xM7Jc0ZUCv5lssG9Ohj3Jv0SdTpxcUABU1dDt9XVsc4=
|
||||
github.com/blevesearch/go-faiss v1.1.0/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
|
||||
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
|
||||
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
|
||||
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
|
||||
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
|
||||
github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
|
||||
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
|
||||
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
|
||||
github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0=
|
||||
github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k=
|
||||
github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY=
|
||||
github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo=
|
||||
github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE=
|
||||
github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM=
|
||||
github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU=
|
||||
github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0=
|
||||
github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg=
|
||||
github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk=
|
||||
github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI=
|
||||
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
|
||||
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
|
||||
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
|
||||
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
@@ -193,14 +192,12 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
|
||||
github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ=
|
||||
github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/brianvoe/gofakeit/v7 v7.7.3 h1:RWOATEGpJ5EVg2nN8nlaEyaV/aB4d6c3GqYrbqQekss=
|
||||
github.com/brianvoe/gofakeit/v7 v7.7.3/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/butonic/go-micro/v4 v4.11.1-0.20241115112658-b5d4de5ed9b3 h1:h8Z0hBv5tg/uZMKu8V47+DKWYVQg0lYP8lXDQq7uRpE=
|
||||
github.com/butonic/go-micro/v4 v4.11.1-0.20241115112658-b5d4de5ed9b3/go.mod h1:eE/tD53n3KbVrzrWxKLxdkGw45Fg1qaNLWjpJMvIUF4=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0 h1:WRZXnLPIer/TWs5aYPaMlmVcOlzmR6Ur6wjLRIQOhTQ=
|
||||
github.com/bytecodealliance/wasmtime-go/v44 v44.0.0/go.mod h1:GP93piU+39CoFVCQ5xfHrPOUtL0APlMnkbblJ2d3YY0=
|
||||
github.com/bytecodealliance/wasmtime-go/v39 v39.0.1 h1:RibaT47yiyCRxMOj/l2cvL8cWiWBSqDXHyqsa9sGcCE=
|
||||
github.com/bytecodealliance/wasmtime-go/v39 v39.0.1/go.mod h1:miR4NYIEBXeDNamZIzpskhJ0z/p8al+lwMWylQ/ZJb4=
|
||||
github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
@@ -211,10 +208,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
|
||||
github.com/ceph/go-ceph v0.40.0 h1:Wz9WOX6i73Hz74mpwhTO9S6IyX3eFPv88VUc7FRMPRk=
|
||||
github.com/ceph/go-ceph v0.40.0/go.mod h1:1oFtT/x/4y+teLsNiogdd/Kj81Gmrw6JM5w1bWnZoc4=
|
||||
github.com/ceph/go-ceph v0.39.0 h1:fzINuBItJqhmTtnC4/iiTY+ONtsOqV7W+B/3xTS/DsY=
|
||||
github.com/ceph/go-ceph v0.39.0/go.mod h1:UId58dqtDKTwnv3OY8rdpC+Ulz/AVpcvZqjXDICcd5c=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -244,8 +239,8 @@ github.com/containerd/platforms v1.0.0-rc.2 h1:0SPgaNZPVWGEi4grZdV8VRYQn78y+nm6a
|
||||
github.com/containerd/platforms v1.0.0-rc.2/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
@@ -282,13 +277,13 @@ github.com/davidbyttow/govips/v2 v2.18.0 h1:pZRshWVYvewP/TZx3yZ7YeC42WyLXg53tHy5
|
||||
github.com/davidbyttow/govips/v2 v2.18.0/go.mod h1:8+nst5zfMoats12PgmmAPh6p5OfjDaXK0BXMFl/vOcM=
|
||||
github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4=
|
||||
github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deepmap/oapi-codegen v1.3.11/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0=
|
||||
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I=
|
||||
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE=
|
||||
github.com/dgraph-io/badger/v4 v4.9.2 h1:Wb5qw8gElqwV1a8msHTeQKova9b1V10heFKMIiPd80E=
|
||||
github.com/dgraph-io/badger/v4 v4.9.2/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
|
||||
github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w=
|
||||
github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0=
|
||||
github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE=
|
||||
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
@@ -327,12 +322,6 @@ github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc h1:6IxmRbXV8WXVkcYcTzk
|
||||
github.com/egirna/icap v0.0.0-20181108071049-d5ee18bd70bc/go.mod h1:FdVN2WHg7zOHhJ7kZQdDorfFhIfqZaHttjAzDDvAXHE=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
|
||||
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
|
||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/emvi/iso-639-1 v1.1.1 h1:7jrl1Sqw9ZYWmCOaH+cpQotLbGr/khwlLPXlBvE8WXU=
|
||||
@@ -360,8 +349,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gdexlab/go-render v1.0.1 h1:rxqB3vo5s4n1kF0ySmoNeSPRYkEsyHgln4jFIQY7v0U=
|
||||
@@ -385,8 +374,8 @@ github.com/go-asn1-ber/asn1-ber v1.4.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkPro
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||
github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s=
|
||||
@@ -475,8 +464,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw=
|
||||
@@ -488,14 +477,15 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
|
||||
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -503,8 +493,6 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
@@ -547,8 +535,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
@@ -599,10 +587,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gookit/config/v2 v2.2.9 h1:ojnE2743wU+DHKWR07x2lWjoONaSkkaTzoNozyHF1b4=
|
||||
github.com/gookit/config/v2 v2.2.9/go.mod h1:PgwcvKHnKZk8w/JdqvdF452OXeYnaHj72OEPhffrniA=
|
||||
github.com/gookit/goutil v0.8.0 h1:efZWxfesXw8+5tQfTfRMSIC6A0ax527/H+A/aIiaSrw=
|
||||
github.com/gookit/goutil v0.8.0/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gookit/config/v2 v2.2.7 h1:P58/uENzkDp7r7Hp8YSZxOhZ/F5a5Y/AzyhDUkQYa9A=
|
||||
github.com/gookit/config/v2 v2.2.7/go.mod h1:QST99HmkZXXD/HkZmOm1OXpgdAnc6Rl9syGl+u62Pi8=
|
||||
github.com/gookit/goutil v0.7.4 h1:OWgUngToNz+bPlX5aP+EMG31DraEU63uvKMwwT3vseM=
|
||||
github.com/gookit/goutil v0.7.4/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
|
||||
github.com/gookit/ini/v2 v2.3.2 h1:W6tzOGE6zOLQelH2xhcH8BIBZPtnEpJgQ+J6SsAKBSw=
|
||||
github.com/gookit/ini/v2 v2.3.2/go.mod h1:StKSqY5niArRwYBS8Z71+iWUt5ow47qt359sS9YQLYY=
|
||||
github.com/gophercloud/gophercloud v0.15.1-0.20210202035223-633d73521055/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075O6i+LY+pXsKCBsb4=
|
||||
@@ -610,8 +598,6 @@ github.com/gophercloud/gophercloud v0.16.0/go.mod h1:wRtmUelyIIv3CSSDI47aUwbs075
|
||||
github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae/go.mod h1:wx8HMD8oQD0Ryhz6+6ykq75PJ79iPyEqYHfwZ4l7OsA=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
|
||||
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
@@ -621,8 +607,6 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
@@ -674,8 +658,6 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
|
||||
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4=
|
||||
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
|
||||
github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||
github.com/inbucket/html2text v0.9.0 h1:ULJmVcBEMAcmLE+/rN815KG1Fx6+a4HhbUxiDiN+qks=
|
||||
github.com/inbucket/html2text v0.9.0/go.mod h1:QDaumzl+/OzlSVbNohhmg+yAy5pKjUjzCKW2BMvztKE=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
@@ -699,11 +681,9 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1 h1:AZGME43Eh2Vv3giG6GeqeLeFXxwxn1/qHItqWZl6U64=
|
||||
github.com/jellydator/ttlcache/v2 v2.11.1/go.mod h1:RtE5Snf0/57e+2cLWFYWCCsLas2Hy3c5Z4n14XmSvTI=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jhillyerd/enmime/v2 v2.2.0 h1:Pe35MB96eZK5Q0XjlvPftOgWypQpd1gcbfJKAt7rsB8=
|
||||
github.com/jhillyerd/enmime/v2 v2.2.0/go.mod h1:SOBXlCemjhiV2DvHhAKnJiWrtJGS/Ffuw4Iy7NjBTaI=
|
||||
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
|
||||
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -739,8 +719,8 @@ github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -756,8 +736,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.22 h1:CtpoRXQpS79xxJsKu8+LUJJE/0i4FLquJZy0QH+QNlM=
|
||||
github.com/kovidgoyal/imaging v1.8.22/go.mod h1:y8wo4JTv4D+skbtQf6fHg8nA1qtagvCcn8J2Nu5k2Jg=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -780,22 +760,22 @@ github.com/leonelquinteros/gotext v1.7.3-0.20260422134830-b012b4ccae69 h1:ZLo0bX
|
||||
github.com/leonelquinteros/gotext v1.7.3-0.20260422134830-b012b4ccae69/go.mod h1:ksG5iXViKefoupjy+0qQjAVoaDnylnQ1ejWl9g14wh8=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
|
||||
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2 h1:7u4HUaD0NQbf2/n5+fyp+T10hNCsAnwKfqn4A4Baif0=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.2/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/libregraph/idm v0.5.0 h1:tDMwKbAOZzdeDYMxVlY5PbSqRKO7dbAW9KT42A51WSk=
|
||||
github.com/libregraph/idm v0.5.0/go.mod h1:BGMwIQ/6orJSPVzJ1x6kgG2JyG9GY05YFmbsnaD80k0=
|
||||
github.com/libregraph/lico v0.67.0 h1:qsVjDVHyEGZxrKU9PEsdim7aKnEz5CVtG3VAbZ77hFU=
|
||||
github.com/libregraph/lico v0.67.0/go.mod h1:kvfaHXyIlualEubTKszOizY65oGasC1oHmFpfQt4ugQ=
|
||||
github.com/libregraph/lico v0.66.0 h1:7T6fD1YF0Ep9n0g4KN6dvWHTlDC3awrQpgsP5GdYCF4=
|
||||
github.com/libregraph/lico v0.66.0/go.mod h1:QI7NfmAkAWQ2y97iVfLv10S8tcvPQjc630uyfHGjIOw=
|
||||
github.com/libregraph/oidc-go v1.1.0 h1:RyudjL3UyQblqeBQI06W53PniWobqODeeyAy6v/HumA=
|
||||
github.com/libregraph/oidc-go v1.1.0/go.mod h1:qW9ubcXvZrfbbWZBaLMuk7bt5qAUMYyt9/NtXQt07Cw=
|
||||
github.com/linode/linodego v0.25.3/go.mod h1:GSBKPpjoQfxEfryoCRcgkuUOCuVtGHWhzI8OMdycNTE=
|
||||
@@ -851,8 +831,6 @@ github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 h1:Z/i1e+gTZrmcGeZy
|
||||
github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103/go.mod h1:o9YPB5aGP8ob35Vy6+vyq3P3bWe7NQWzf+JLiXCiMaE=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/miekg/dns v1.1.40/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
@@ -887,8 +865,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
|
||||
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
|
||||
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
@@ -918,14 +896,14 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.14.3 h1:+xjydPt7rkit67G+04TN0mcO2n+8nveZE7tK/PPV53A=
|
||||
github.com/nats-io/nats-server/v2 v2.14.3/go.mod h1:5IlCtBzfwyzQzPMjmoJ9W2/LKmnJRtNyuOs/OT+NHDY=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.14.0 h1:+8q0HrDFotwLLcGH/legOEOnowunhK+aZ4GYBIWpQlM=
|
||||
github.com/nats-io/nats-server/v2 v2.14.0/go.mod h1:ImVUUDvfClJbb6cuJQRc1VmgDCXKM5ds0OoiG9MVOKo=
|
||||
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
|
||||
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
|
||||
@@ -955,23 +933,23 @@ github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4=
|
||||
github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||
github.com/open-policy-agent/opa v1.18.2 h1:VBiLJpioTuk7XTW1JoQi4ILo+FVxD2/8uD8iP9/OcxY=
|
||||
github.com/open-policy-agent/opa v1.18.2/go.mod h1:9GY+hER4ZEXtxPlMjftVbqJJY9xLtCD3Q0oufRCfAKo=
|
||||
github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
|
||||
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/open-policy-agent/opa v1.15.2 h1:dS9q+0Yvruq/VNvWJc5qCvCchn715OWc3HLHXn/UCCc=
|
||||
github.com/open-policy-agent/opa v1.15.2/go.mod h1:c6SN+7jSsUcKJLQc5P4yhwx8YYDRbjpAiGkBOTqxaa4=
|
||||
github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a h1:Sakl76blJAaM6NxylVkgSzktjo2dS504iDotEFJsh3M=
|
||||
github.com/opencloud-eu/go-micro-plugins/v4/store/nats-js-kv v0.0.0-20250512152754-23325793059a/go.mod h1:pjcozWijkNPbEtX5SIQaxEW/h8VAVZYTLx+70bmB3LY=
|
||||
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89 h1:W1ms+lP5lUUIzjRGDg93WrQfZJZCaV1ZP3KeyXi8bzY=
|
||||
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89/go.mod h1:vigJkNss1N2QEceCuNw/ullDehncuJNFB6mEnzfq9UI=
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260310090739-853d972b282d h1:JcqGDiyrcaQwVyV861TUyQgO7uEmsjkhfm7aQd84dOw=
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260310090739-853d972b282d/go.mod h1:pzatilMEHZFT3qV7C/X3MqOa3NlRQuYhlRhZTL+hN6Q=
|
||||
github.com/opencloud-eu/reva/v2 v2.46.4-0.20260625152426-8cff2a7032ec h1:kL9P87cVpe8GKvo5vzLpJstpiVXFvkZn2+Dco53nfuM=
|
||||
github.com/opencloud-eu/reva/v2 v2.46.4-0.20260625152426-8cff2a7032ec/go.mod h1:l4CG5u8tdWZj6IyavXO9xDdnNTV85oaFudGwEDPvsnk=
|
||||
github.com/opencloud-eu/reva/v2 v2.46.6 h1:Q20wE4A2OpWVyDnRaC4KW1+nzo+DQaK+RK5y3RFnWIg=
|
||||
github.com/opencloud-eu/reva/v2 v2.46.6/go.mod h1:RoFQt+u7edxwzHr1IZ2Y6VaDinMiRPQupAvMBy3WVmE=
|
||||
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4 h1:l2oB/RctH+t8r7QBj5p8thfEHCM/jF35aAY3WQ3hADI=
|
||||
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -1008,8 +986,6 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ
|
||||
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
|
||||
github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -1075,8 +1051,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI=
|
||||
github.com/prometheus/statsd_exporter v0.22.8 h1:Qo2D9ZzaQG+id9i5NYNGmbf1aa/KxKbB9aKfMS+Yib0=
|
||||
github.com/prometheus/statsd_exporter v0.22.8/go.mod h1:/DzwbTEaFTE0Ojz5PqcSk6+PFHOPWGxdXVr6yC8eFOM=
|
||||
@@ -1091,14 +1067,14 @@ github.com/riandyrn/otelchi v0.12.3 h1:KW9gA+97d6mExk8vbh0FRwb2biUvpyYlc8YuxP1Oa
|
||||
github.com/riandyrn/otelchi v0.12.3/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
|
||||
github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -1136,8 +1112,8 @@ github.com/shamaton/msgpack/v2 v2.4.1 h1:JtJ141QoQ3NqgPDsjq2v9VXlaON8SiQOwEaoNLE
|
||||
github.com/shamaton/msgpack/v2 v2.4.1/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs=
|
||||
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
@@ -1186,8 +1162,6 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q
|
||||
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
@@ -1215,10 +1189,10 @@ github.com/tchap/go-patricia/v2 v2.3.3 h1:xfNEsODumaEcCcY3gI0hYPZ/PcpVv5ju6RMAhg
|
||||
github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
|
||||
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.43.0 h1:a1ipjF7d/VxPX1dgVPIk4F+t6YkgMbE2OtBuRQCHJt8=
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.43.0/go.mod h1:OWSeUDiGMUy30iMsAltIJIo9uh/CleLv6KyxjYOsgR8=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.42.0 h1:lXPr5NypYR0O8qcej+JQfsQTOTc3CRv+c4wz3VbBFLI=
|
||||
github.com/testcontainers/testcontainers-go/modules/opensearch v0.42.0/go.mod h1:KJ+m9onB785g9VDRDuhvUvsbkRjnFp3Uz+CHoHpPieA=
|
||||
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
|
||||
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
|
||||
github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4=
|
||||
@@ -1247,20 +1221,20 @@ github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXz
|
||||
github.com/transip/gotransip/v6 v6.2.0/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g=
|
||||
github.com/trustelem/zxcvbn v1.0.1 h1:mp4JFtzdDYGj9WYSD3KQSkwwUumWNFzXaAjckaTYpsc=
|
||||
github.com/trustelem/zxcvbn v1.0.1/go.mod h1:zonUyKeh7sw6psPf/e3DtRqkRyZvAbOfjNz/aO7YQ5s=
|
||||
github.com/tus/tusd/v2 v2.10.0 h1:2yOGmkrDl9RQmRIt/00DR2WvYWOoiEu3CoygILb+WRw=
|
||||
github.com/tus/tusd/v2 v2.10.0/go.mod h1:T/OuJHIAC2NHpkEUyQyyaoWyDNRDcQVpJzWl8tX5GY4=
|
||||
github.com/tus/tusd/v2 v2.9.2 h1:Dd/Dh0CG7+/wom4lDQnnhca+1p5qVwgnbyEBacj1v7c=
|
||||
github.com/tus/tusd/v2 v2.9.2/go.mod h1:+a9uNLru2Qy+CUu7QUIshmQ+X0fLNw77eu8voKVxmgA=
|
||||
github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
|
||||
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/vektah/gqlparser/v2 v2.5.34 h1:MEea5P0qhdcqfBL45ghKE+qr9laidVHTMHjav5h7ckk=
|
||||
github.com/vektah/gqlparser/v2 v2.5.34/go.mod h1:mFdHLGCio7OGX1fby9ZjTW6FN+qxgmbnBcRIeeScE5s=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14/go.mod h1:RWc47jtnVuQv6+lY3c768WtXCas/Xi+U5UFc5xULmYg=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
@@ -1304,8 +1278,8 @@ github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/etcd/api/v3 v3.6.11 h1:XFGTgrJ8nak3kB4NgMG8t7NT+lEeuuvKQAqUHKVgkWQ=
|
||||
go.etcd.io/etcd/api/v3 v3.6.11/go.mod h1:HYfTh0jyh+uFgp6gMbxJteIDYY97yMuYz85Rnw6Gy9o=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.11 h1:e41mp315Yn3QMGPmEzCyLsMINgJXTY/dX8kM++1csxU=
|
||||
@@ -1326,18 +1300,18 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/contrib/zpages v0.69.0 h1:YQC1PumJq6lUGQNrLW14ID9a0dXSBcbC/aC6VRSIARU=
|
||||
go.opentelemetry.io/contrib/zpages v0.69.0/go.mod h1:FGvUcMGN5atRzUIgUsqOi+MiMvlQsfbuYATBHPP4cGs=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/contrib/zpages v0.68.0 h1:H5yrUwxPrbvhzdBxjQD+VXMtPjIBfp8NWNVvQT8E30M=
|
||||
go.opentelemetry.io/contrib/zpages v0.68.0/go.mod h1:sZGctYYO4UOHItj9bx3F+t/s+u1Fv8CHCJ5s2eR2cjU=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
@@ -1363,8 +1337,8 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20180621125126-a49355c7e3f8/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -1386,8 +1360,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1402,8 +1376,8 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -1425,8 +1399,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1477,8 +1451,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1502,8 +1476,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180622082034-63fc586f45fe/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1584,8 +1558,8 @@ golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -1594,8 +1568,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1608,8 +1582,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1670,8 +1644,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk=
|
||||
golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -1733,8 +1707,8 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
@@ -1754,8 +1728,8 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc/examples v0.0.0-20211102180624-670c133e568e h1:m7aQHHqd0q89mRwhwS9Bx2rjyl/hsFAeta+uGrHsQaU=
|
||||
google.golang.org/grpc/examples v0.0.0-20211102180624-670c133e568e/go.mod h1:gID3PKrg7pWKntu9Ss6zTLJ0ttC0X9IHgREOCZwbCVU=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -1809,6 +1783,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/__debug_bin*
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
appprovider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/command"
|
||||
appregistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command"
|
||||
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command"
|
||||
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/command"
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/command"
|
||||
@@ -25,7 +24,6 @@ import (
|
||||
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
|
||||
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
|
||||
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
|
||||
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
|
||||
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
|
||||
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
|
||||
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
|
||||
@@ -140,11 +138,6 @@ var serviceCommands = []register.Command{
|
||||
cfg.Groups.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
func(cfg *config.Config) *cobra.Command {
|
||||
return ServiceCommand(cfg, cfg.Groupware.Service.Name, groupware.GetCommands(cfg.Groupware), func(c *config.Config) {
|
||||
cfg.Groupware.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
func(cfg *config.Config) *cobra.Command {
|
||||
return ServiceCommand(cfg, cfg.IDM.Service.Name, idm.GetCommands(cfg.IDM), func(c *config.Config) {
|
||||
cfg.IDM.Commons = cfg.Commons
|
||||
@@ -265,11 +258,6 @@ var serviceCommands = []register.Command{
|
||||
cfg.Webfinger.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
func(cfg *config.Config) *cobra.Command {
|
||||
return ServiceCommand(cfg, cfg.AuthApi.Service.Name, authapi.GetCommands(cfg.AuthApi), func(c *config.Config) {
|
||||
cfg.AuthApi.Commons = cfg.Commons
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ServiceCommand composes a cobra command from the given inputs.
|
||||
|
||||
@@ -32,7 +32,6 @@ type OpenCloudConfig struct {
|
||||
AuthBearer AuthbearerService `yaml:"auth_bearer"`
|
||||
Users UsersAndGroupsService `yaml:"users"`
|
||||
Groups UsersAndGroupsService `yaml:"groups"`
|
||||
Groupware GroupwareService `yaml:"groupware"`
|
||||
Ocm OcmService `yaml:"ocm"`
|
||||
Thumbnails ThumbnailService `yaml:"thumbnails"`
|
||||
Search Search `yaml:"search"`
|
||||
@@ -127,17 +126,6 @@ type GraphService struct {
|
||||
ServiceAccount ServiceAccount `yaml:"service_account"`
|
||||
}
|
||||
|
||||
// GroupwareSettings is the configuration for the groupware settings
|
||||
type GroupwareSettings struct {
|
||||
WebdavAllowInsecure bool `yaml:"webdav_allow_insecure"`
|
||||
Cs3AllowInsecure bool `yaml:"cs3_allow_insecure"`
|
||||
}
|
||||
|
||||
// GroupwareService is the configuration for the groupware service
|
||||
type GroupwareService struct {
|
||||
Groupware GroupwareSettings
|
||||
}
|
||||
|
||||
// IdmService is the configuration for the IDM service
|
||||
type IdmService struct {
|
||||
ServiceUserPasswords ServiceUserPasswordsSettings `yaml:"service_user_passwords"`
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/command"
|
||||
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/command"
|
||||
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/command"
|
||||
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/command"
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/command"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/command"
|
||||
authmachine "github.com/opencloud-eu/opencloud/services/auth-machine/pkg/command"
|
||||
@@ -36,7 +35,6 @@ import (
|
||||
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/command"
|
||||
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/command"
|
||||
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/command"
|
||||
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/command"
|
||||
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/command"
|
||||
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/command"
|
||||
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/command"
|
||||
@@ -344,16 +342,6 @@ func NewService(ctx context.Context, options ...Option) (*Service, error) {
|
||||
cfg.Notifications.Commons = cfg.Commons
|
||||
return notifications.Execute(cfg.Notifications)
|
||||
})
|
||||
areg(opts.Config.AuthApi.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
|
||||
cfg.AuthApi.Context = ctx
|
||||
cfg.AuthApi.Commons = cfg.Commons
|
||||
return authapi.Execute(cfg.AuthApi)
|
||||
})
|
||||
areg(opts.Config.Groupware.Service.Name, func(ctx context.Context, cfg *occfg.Config) error {
|
||||
cfg.Groupware.Context = ctx
|
||||
cfg.Groupware.Commons = cfg.Commons
|
||||
return groupware.Execute(cfg.Groupware)
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/config"
|
||||
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config"
|
||||
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config"
|
||||
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config"
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config"
|
||||
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config"
|
||||
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config"
|
||||
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config"
|
||||
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config"
|
||||
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config"
|
||||
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config"
|
||||
@@ -89,7 +87,6 @@ type Config struct {
|
||||
AppProvider *appProvider.Config `yaml:"app_provider"`
|
||||
AppRegistry *appRegistry.Config `yaml:"app_registry"`
|
||||
Audit *audit.Config `yaml:"audit"`
|
||||
AuthApi *authapi.Config `yaml:"auth_api"`
|
||||
AuthApp *authapp.Config `yaml:"auth_app"`
|
||||
AuthBasic *authbasic.Config `yaml:"auth_basic"`
|
||||
AuthBearer *authbearer.Config `yaml:"auth_bearer"`
|
||||
@@ -102,7 +99,6 @@ type Config struct {
|
||||
Gateway *gateway.Config `yaml:"gateway"`
|
||||
Graph *graph.Config `yaml:"graph"`
|
||||
Groups *groups.Config `yaml:"groups"`
|
||||
Groupware *groupware.Config `yaml:"groupware"`
|
||||
IDM *idm.Config `yaml:"idm"`
|
||||
IDP *idp.Config `yaml:"idp"`
|
||||
Invitations *invitations.Config `yaml:"invitations"`
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
appProvider "github.com/opencloud-eu/opencloud/services/app-provider/pkg/config/defaults"
|
||||
appRegistry "github.com/opencloud-eu/opencloud/services/app-registry/pkg/config/defaults"
|
||||
audit "github.com/opencloud-eu/opencloud/services/audit/pkg/config/defaults"
|
||||
authapi "github.com/opencloud-eu/opencloud/services/auth-api/pkg/config/defaults"
|
||||
authapp "github.com/opencloud-eu/opencloud/services/auth-app/pkg/config/defaults"
|
||||
authbasic "github.com/opencloud-eu/opencloud/services/auth-basic/pkg/config/defaults"
|
||||
authbearer "github.com/opencloud-eu/opencloud/services/auth-bearer/pkg/config/defaults"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
gateway "github.com/opencloud-eu/opencloud/services/gateway/pkg/config/defaults"
|
||||
graph "github.com/opencloud-eu/opencloud/services/graph/pkg/config/defaults"
|
||||
groups "github.com/opencloud-eu/opencloud/services/groups/pkg/config/defaults"
|
||||
groupware "github.com/opencloud-eu/opencloud/services/groupware/pkg/config/defaults"
|
||||
idm "github.com/opencloud-eu/opencloud/services/idm/pkg/config/defaults"
|
||||
idp "github.com/opencloud-eu/opencloud/services/idp/pkg/config/defaults"
|
||||
invitations "github.com/opencloud-eu/opencloud/services/invitations/pkg/config/defaults"
|
||||
@@ -64,7 +62,6 @@ func DefaultConfig() *Config {
|
||||
AppProvider: appProvider.DefaultConfig(),
|
||||
AppRegistry: appRegistry.DefaultConfig(),
|
||||
Audit: audit.DefaultConfig(),
|
||||
AuthApi: authapi.DefaultConfig(),
|
||||
AuthApp: authapp.DefaultConfig(),
|
||||
AuthBasic: authbasic.DefaultConfig(),
|
||||
AuthBearer: authbearer.DefaultConfig(),
|
||||
@@ -77,7 +74,6 @@ func DefaultConfig() *Config {
|
||||
Gateway: gateway.DefaultConfig(),
|
||||
Graph: graph.DefaultConfig(),
|
||||
Groups: groups.DefaultConfig(),
|
||||
Groupware: groupware.DefaultConfig(),
|
||||
IDM: idm.DefaultConfig(),
|
||||
IDP: idp.DefaultConfig(),
|
||||
Invitations: invitations.DefaultConfig(),
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
)
|
||||
|
||||
type ResourceMention struct {
|
||||
Executant *user.UserId
|
||||
UserIDs []*user.UserId
|
||||
Ref *provider.Reference
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func (ResourceMention) Unmarshal(v []byte) (interface{}, error) {
|
||||
e := ResourceMention{}
|
||||
err := json.Unmarshal(v, &e)
|
||||
return e, err
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
// Provides common utility functions for HTTP.
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type readCloser struct {
|
||||
io.Reader
|
||||
closer func() error
|
||||
}
|
||||
|
||||
func (c readCloser) Close() error {
|
||||
if c.closer != nil {
|
||||
return c.closer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func combinedReader(peekBuffer *bytes.Buffer, body io.Reader) io.Reader {
|
||||
if body != nil && body != http.NoBody {
|
||||
if peekBuffer != nil {
|
||||
return io.MultiReader(peekBuffer, body)
|
||||
} else {
|
||||
return body
|
||||
}
|
||||
} else {
|
||||
if peekBuffer != nil {
|
||||
return peekBuffer
|
||||
} else {
|
||||
return http.NoBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads up to <size> amount of bytes from the response, and returns a ReadCloser that concatenates over those
|
||||
// bytes as well as the rest of the bytes streamed in <body>, to ensure that the whole response can still be
|
||||
// read.
|
||||
//
|
||||
// Note that if <size> is 0, then this function does nothing.
|
||||
//
|
||||
// Furthermore, this function never closes the <body> Reader, that is left to the caller.
|
||||
//
|
||||
// # Parameters
|
||||
// - body: the HTTP response body, which may be nil or http.NoBody
|
||||
// - size: the number of bytes that will be read from the beginning of the response body,
|
||||
// which may be 0 (in which case this function won't do anything)
|
||||
//
|
||||
// # Return values
|
||||
// - a Reader that provides the full response, including the first bytes that were peeked
|
||||
// up to <size> bytes of the beginning of the response body
|
||||
// - the first <size> bytes of the response
|
||||
// - a boolean: whether the peeked bytes contain the full response or whether it's truncated
|
||||
// - an error if an error occured while reading the body response
|
||||
func PeekResponse(body io.ReadCloser, size int64) (io.ReadCloser, []byte, bool, error) {
|
||||
var peekBuffer *bytes.Buffer = nil
|
||||
truncated := false
|
||||
peek := []byte{}
|
||||
if body != nil && body != http.NoBody && size > 0 {
|
||||
peekBuffer = new(bytes.Buffer)
|
||||
limitedReader := io.LimitReader(body, size)
|
||||
tee := io.TeeReader(limitedReader, peekBuffer)
|
||||
if _, err := io.Copy(io.Discard, tee); err != nil {
|
||||
return nil, peek, false, err
|
||||
}
|
||||
|
||||
if int64(peekBuffer.Len()) >= size {
|
||||
truncated = true
|
||||
}
|
||||
|
||||
peek = peekBuffer.Bytes()
|
||||
|
||||
return readCloser{
|
||||
closer: body.Close,
|
||||
Reader: combinedReader(peekBuffer, body),
|
||||
}, peek, truncated, nil
|
||||
} else {
|
||||
return body, peek, truncated, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts information from an HTTP request and passes it to a function, typically for logging.
|
||||
//
|
||||
// It also extracts the <maxBodySize> first bytes of the request body, if maxBodySize is > 0.
|
||||
func DumpHttpRequest(req *http.Request, maxBodySize int64, closure func(method string, uri string, content string, truncated bool)) error {
|
||||
var logBuilder bytes.Buffer
|
||||
uri := ""
|
||||
if req.URL != nil {
|
||||
uri = req.URL.String()
|
||||
}
|
||||
fmt.Fprintf(&logBuilder, "%s %s\n", req.Method, uri)
|
||||
for key, values := range req.Header {
|
||||
if key == "Authorization" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
truncated := false
|
||||
if maxBodySize >= 0 && req.Body != nil && req.Body != http.NoBody {
|
||||
peekBuffer := new(bytes.Buffer)
|
||||
limitedReader := io.LimitReader(req.Body, maxBodySize)
|
||||
tee := io.TeeReader(limitedReader, peekBuffer)
|
||||
if _, err := io.Copy(io.Discard, tee); err != nil {
|
||||
return fmt.Errorf("failed to peek at the request body for tracing: %v", err)
|
||||
} else {
|
||||
logBuilder.Write(peekBuffer.Bytes())
|
||||
if int64(peekBuffer.Len()) >= maxBodySize {
|
||||
truncated = true
|
||||
}
|
||||
fullBodyReader := io.MultiReader(peekBuffer, req.Body)
|
||||
req.Body = io.NopCloser(fullBodyReader)
|
||||
}
|
||||
}
|
||||
closure(req.Method, uri, logBuilder.String(), truncated)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extracts information from an HTTP response and passes it to a function, typically for logging.
|
||||
//
|
||||
// It also extracts the <maxBodySize> first bytes of the response body, if maxBodySize is > 0.
|
||||
func DumpHttpResponse(resp *http.Response, body io.ReadCloser, maxBodySize int64,
|
||||
closure func(method string, uri string, content string, truncated bool),
|
||||
) (io.ReadCloser, error) {
|
||||
var logBuilder bytes.Buffer
|
||||
fmt.Fprintf(&logBuilder, "%s\n", resp.Status)
|
||||
for key, values := range resp.Header {
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(&logBuilder, "%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
var peekBuffer *bytes.Buffer = nil
|
||||
truncated := false
|
||||
if body != nil && body != http.NoBody && maxBodySize > 0 {
|
||||
peekBuffer = new(bytes.Buffer)
|
||||
limitedReader := io.LimitReader(body, maxBodySize)
|
||||
tee := io.TeeReader(limitedReader, peekBuffer)
|
||||
if _, err := io.Copy(io.Discard, tee); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(peekBuffer.Len()) >= maxBodySize {
|
||||
truncated = true
|
||||
}
|
||||
|
||||
logBuilder.Write(peekBuffer.Bytes())
|
||||
}
|
||||
|
||||
req := resp.Request
|
||||
if req != nil {
|
||||
closure(req.Method, req.URL.String(), logBuilder.String(), truncated)
|
||||
} else {
|
||||
closure("", "", logBuilder.String(), truncated)
|
||||
}
|
||||
|
||||
if peekBuffer != nil {
|
||||
return readCloser{
|
||||
closer: body.Close,
|
||||
Reader: combinedReader(peekBuffer, body),
|
||||
}, nil
|
||||
} else {
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPeekResponse(t *testing.T) {
|
||||
require := require.New(t)
|
||||
n := 6
|
||||
text := "hello, world"
|
||||
|
||||
require.Less(n, len(text))
|
||||
body, peek, truncated, err := PeekResponse(io.NopCloser(strings.NewReader(text)), int64(n))
|
||||
require.NoError(err)
|
||||
require.True(truncated)
|
||||
require.Equal(text[:n], string(peek))
|
||||
buf := new(strings.Builder)
|
||||
_, err = io.Copy(buf, body)
|
||||
require.NoError(err)
|
||||
require.Equal(text, buf.String())
|
||||
}
|
||||
|
||||
func TestDumpHttpResponse(t *testing.T) {
|
||||
require := require.New(t)
|
||||
n := 6
|
||||
text := "hello, world"
|
||||
|
||||
h := http.Header{}
|
||||
h.Set("testing", "true")
|
||||
h.Add("aaa", "1")
|
||||
h.Add("aaa", "2")
|
||||
|
||||
resp := &http.Response{
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Header: h,
|
||||
Body: nil,
|
||||
}
|
||||
|
||||
body, err := DumpHttpResponse(resp, io.NopCloser(strings.NewReader(text)), int64(n), func(method, uri, content string, truncated bool) {
|
||||
require.True(truncated)
|
||||
require.Equal(fmt.Sprintf(`200 OK
|
||||
Testing: true
|
||||
Aaa: 1
|
||||
Aaa: 2
|
||||
%s`, text[:n]), content)
|
||||
})
|
||||
require.NoError(err)
|
||||
buf := new(strings.Builder)
|
||||
_, err = io.Copy(buf, body)
|
||||
require.NoError(err)
|
||||
require.Equal(text, buf.String())
|
||||
}
|
||||
|
||||
func TestDumpHttpRequest(t *testing.T) {
|
||||
require := require.New(t)
|
||||
n := 6
|
||||
text := "hello, world"
|
||||
|
||||
h := http.Header{}
|
||||
h.Set("testing", "true")
|
||||
h.Add("aaa", "1")
|
||||
h.Add("aaa", "2")
|
||||
|
||||
req := &http.Request{
|
||||
Method: "GET",
|
||||
Header: h,
|
||||
URL: &url.URL{Scheme: "https", Host: "example.com", Path: "/testing"},
|
||||
Body: io.NopCloser(strings.NewReader(text)),
|
||||
}
|
||||
|
||||
err := DumpHttpRequest(req, int64(n), func(method, uri, content string, truncated bool) {
|
||||
require.True(truncated)
|
||||
require.Equal(fmt.Sprintf(`GET https://example.com/testing
|
||||
Testing: true
|
||||
Aaa: 1
|
||||
Aaa: 2
|
||||
%s`, text[:n]), content)
|
||||
})
|
||||
require.NoError(err)
|
||||
buf := new(strings.Builder)
|
||||
_, err = io.Copy(buf, req.Body)
|
||||
require.NoError(err)
|
||||
require.Equal(text, buf.String())
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/apidoc-examples.json
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
Session *Session
|
||||
Context context.Context
|
||||
Logger *log.Logger
|
||||
AcceptLanguage string
|
||||
}
|
||||
|
||||
func (c Context) WithLogger(newLogger *log.Logger) Context {
|
||||
return Context{Session: c.Session, Context: c.Context, AcceptLanguage: c.AcceptLanguage, Logger: newLogger}
|
||||
}
|
||||
|
||||
func (c Context) WithContext(newContext context.Context) Context {
|
||||
return Context{Session: c.Session, Context: newContext, AcceptLanguage: c.AcceptLanguage, Logger: c.Logger}
|
||||
}
|
||||
|
||||
type ApiClient interface {
|
||||
Command(operation Operation, request Request, ctx Context) (io.ReadCloser, Language, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type WsPushListener interface {
|
||||
OnNotification(username string, stateChange StateChange)
|
||||
}
|
||||
|
||||
type WsClient interface {
|
||||
DisableNotifications() Error
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type WsClientFactory interface {
|
||||
EnableNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type SessionClient interface {
|
||||
GetSession(ctx context.Context, baseurl *url.URL, username string, logger *log.Logger) (SessionResponse, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
type BlobClient interface {
|
||||
UploadBinary(uploadUrl string, operation Operation, endpoint string, contentType string, content io.Reader, ctx Context) (UploadedBlob, Language, Error)
|
||||
DownloadBinary(downloadUrl string, operation Operation, endpoint string, ctx Context) (*BlobDownload, Language, Error)
|
||||
io.Closer
|
||||
}
|
||||
|
||||
const (
|
||||
logOperation = "operation"
|
||||
logFetchBodies = "fetch-bodies"
|
||||
logPosition = "position"
|
||||
logLimit = "limit"
|
||||
logDownloadUrl = "download-url"
|
||||
logBlobId = "blob-id"
|
||||
logSinceState = "since-state"
|
||||
)
|
||||
|
||||
type ResultMetadata interface {
|
||||
GetSessionState() SessionState
|
||||
GetState() State
|
||||
GetLanguage() Language
|
||||
GetDurations() []time.Duration
|
||||
}
|
||||
|
||||
type Result[T any] struct {
|
||||
Payload T
|
||||
SessionState SessionState
|
||||
State State
|
||||
Language Language
|
||||
Durations []time.Duration
|
||||
}
|
||||
|
||||
func RefineResultPayload[A, B any](a Result[A], refiner func(A) (B, bool, error)) (Result[B], error) {
|
||||
if payloads, ok, err := refiner(a.Payload); err != nil {
|
||||
return ZeroResult[B](a.Durations), err
|
||||
} else if ok {
|
||||
return NewResult(payloads, a.SessionState, a.State, a.Language, a.Durations), nil
|
||||
} else {
|
||||
return ZeroResult[B](a.Durations), nil
|
||||
}
|
||||
}
|
||||
|
||||
func RefineResult[A, B any](a Result[A], refiner func(A, SessionState, State, Language) (B, SessionState, State, Language)) Result[B] {
|
||||
b, bss, bs, bl := refiner(a.Payload, a.SessionState, a.State, a.Language)
|
||||
return NewResult(b, bss, bs, bl, a.Durations)
|
||||
}
|
||||
|
||||
func RefineResultSlice[A, B any](a []*Result[A], refiner func([]*A, []*SessionState, []*State, []*Language) (B, SessionState, State, Language, error)) (Result[B], error) {
|
||||
payloads := structs.Map(a, func(e *Result[A]) *A {
|
||||
if e != nil {
|
||||
return &e.Payload
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
sessionStates := structs.Map(a, func(e *Result[A]) *SessionState {
|
||||
if e != nil {
|
||||
return &e.SessionState
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
states := structs.Map(a, func(e *Result[A]) *State {
|
||||
if e != nil {
|
||||
return &e.State
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
languages := structs.Map(a, func(e *Result[A]) *Language {
|
||||
if e != nil {
|
||||
return &e.Language
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
})
|
||||
durations := structs.Flatten(structs.Map(a, func(e *Result[A]) []time.Duration {
|
||||
if e != nil {
|
||||
return e.Durations
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}))
|
||||
b, bss, bs, bl, err := refiner(payloads, sessionStates, states, languages)
|
||||
return NewResult(b, bss, bs, bl, durations), err
|
||||
}
|
||||
|
||||
func (r Result[T]) GetSessionState() SessionState {
|
||||
return r.SessionState
|
||||
}
|
||||
func (r Result[T]) GetState() State {
|
||||
return r.State
|
||||
}
|
||||
func (r Result[T]) GetLanguage() Language {
|
||||
return r.Language
|
||||
}
|
||||
func (r Result[T]) GetDurations() []time.Duration {
|
||||
return r.Durations
|
||||
}
|
||||
|
||||
func NewResult[T any](payload T, sessionState SessionState, state State, language Language, durations []time.Duration) Result[T] {
|
||||
return Result[T]{
|
||||
Payload: payload,
|
||||
SessionState: sessionState,
|
||||
State: state,
|
||||
Language: language,
|
||||
Durations: durations,
|
||||
}
|
||||
}
|
||||
|
||||
func newPartialResult[T any](sessionState SessionState, language Language, durations []time.Duration) Result[T] {
|
||||
return Result[T]{
|
||||
SessionState: sessionState,
|
||||
Language: language,
|
||||
Durations: durations,
|
||||
}
|
||||
}
|
||||
|
||||
func ZeroResult[T any](durations []time.Duration) Result[T] {
|
||||
return Result[T]{Durations: durations}
|
||||
}
|
||||
|
||||
func ZeroResultV[T any]() Result[T] {
|
||||
return Result[T]{Durations: nil}
|
||||
}
|
||||
|
||||
func ZeroResultM[T any](t Result[T]) Result[T] {
|
||||
return Result[T]{Durations: t.GetDurations()}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package jmap
|
||||
|
||||
var NS_ADDRESSBOOKS = ns(JmapContacts)
|
||||
|
||||
func (j *Client) GetAddressbooks(accountId AccountId, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
|
||||
return get(j, "GetAddressbooks", MailboxType,
|
||||
func(accountId AccountId, ids []string) AddressBookGetCommand {
|
||||
return AddressBookGetCommand{AccountId: accountId, Ids: ids}
|
||||
},
|
||||
AddressBookGetResponse{},
|
||||
identity1,
|
||||
accountId, ids,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type AddressBookChanges ChangesTemplate[AddressBook]
|
||||
|
||||
var _ Changes[AddressBook] = AddressBookChanges{}
|
||||
|
||||
func (c AddressBookChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c AddressBookChanges) GetOldState() State { return c.OldState }
|
||||
func (c AddressBookChanges) GetNewState() State { return c.NewState }
|
||||
func (c AddressBookChanges) GetCreated() []AddressBook { return c.Created }
|
||||
func (c AddressBookChanges) GetUpdated() []AddressBook { return c.Updated }
|
||||
func (c AddressBookChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve Address Book changes since a given state.
|
||||
// @apidoc addressbook,changes
|
||||
func (j *Client) GetAddressbookChanges(accountId AccountId, sinceState State, maxChanges uint, ctx Context) (Result[AddressBookChanges], error) {
|
||||
return changesA(j, "GetAddressbookChanges", MailboxType,
|
||||
func() AddressBookChangesCommand {
|
||||
return AddressBookChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
AddressBookChangesResponse{},
|
||||
AddressBookGetResponse{},
|
||||
func(path string, rof string) AddressBookGetRefCommand {
|
||||
return AddressBookGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandAddressBookChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []AddressBook, destroyed []string) AddressBookChanges {
|
||||
return AddressBookChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) CreateAddressBook(accountId AccountId, addressbook AddressBookChange, ctx Context) (Result[*AddressBook], error) {
|
||||
return create(j, "CreateAddressBook", MailboxType,
|
||||
func(accountId AccountId, create map[string]AddressBookChange) AddressBookSetCommand {
|
||||
return AddressBookSetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ids string) AddressBookGetCommand {
|
||||
return AddressBookGetCommand{AccountId: accountId, Ids: []string{ids}}
|
||||
},
|
||||
func(resp AddressBookSetResponse) map[string]*AddressBook {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp AddressBookGetResponse) []AddressBook {
|
||||
return resp.List
|
||||
},
|
||||
accountId, addressbook,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteAddressBook(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteAddressBook", MailboxType,
|
||||
func(accountId AccountId, destroy []string) AddressBookSetCommand {
|
||||
return AddressBookSetCommand{AccountId: accountId, Destroy: destroy}
|
||||
},
|
||||
AddressBookSetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) UpdateAddressBook(accountId AccountId, id string, changes AddressBookChange, ctx Context) (Result[AddressBook], error) {
|
||||
return update(j, "UpdateAddressBook", MailboxType,
|
||||
func(update map[string]PatchObject) AddressBookSetCommand {
|
||||
return AddressBookSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) AddressBookGetCommand {
|
||||
return AddressBookGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp AddressBookSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp AddressBookGetResponse) AddressBook { return resp.List[0] },
|
||||
id, changes,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
var NS_BLOB = ns(JmapBlob)
|
||||
|
||||
func (j *Client) GetBlobMetadata(accountId AccountId, ids []string, ctx Context) (Result[BlobGetResponse], error) {
|
||||
get := BlobGetCommand{
|
||||
AccountId: accountId,
|
||||
Ids: ids,
|
||||
// add BlobPropertyData to retrieve the data
|
||||
Properties: []string{BlobPropertyDigestSha256, BlobPropertyDigestSha512, BlobPropertySize},
|
||||
}
|
||||
cmd, jerr := j.request(ctx, NS_BLOB,
|
||||
invocation(get, "0"),
|
||||
)
|
||||
if jerr != nil {
|
||||
return ZeroResultV[BlobGetResponse](), jerr
|
||||
}
|
||||
|
||||
return command(j, Operation("GetBlobMetadata"), ctx, cmd, func(body *Response) (BlobGetResponse, State, Error) {
|
||||
var response BlobGetResponse
|
||||
err := retrieveGet(ctx, body, get, "0", &response)
|
||||
if err != nil {
|
||||
return BlobGetResponse{}, EmptyState, err
|
||||
}
|
||||
return response, response.State, nil
|
||||
})
|
||||
}
|
||||
|
||||
type UploadedBlobWithHash struct {
|
||||
BlobId string `json:"blobId"`
|
||||
Size int `json:"size,omitzero"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Sha512 string `json:"sha:512,omitempty"`
|
||||
}
|
||||
|
||||
func (j *Client) UploadBlobStream(accountId AccountId, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, error) {
|
||||
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.UploadEndpoint))
|
||||
ctx = ctx.WithLogger(logger)
|
||||
uploadUrl := strings.NewReplacer(
|
||||
"{accountId}", url.PathEscape(string(accountId)),
|
||||
).Replace(ctx.Session.UploadUrlTemplate)
|
||||
return j.blob.UploadBinary(uploadUrl, Operation("UploadBlobStream"), ctx.Session.UploadEndpoint, contentType, body, ctx)
|
||||
}
|
||||
|
||||
func (j *Client) DownloadBlobStream(accountId AccountId, blobId string, name string, typ string, ctx Context) (*BlobDownload, Language, error) { //NOSONAR
|
||||
logger := log.From(ctx.Logger.With().Str(logEndpoint, ctx.Session.DownloadEndpoint))
|
||||
ctx = ctx.WithLogger(logger)
|
||||
downloadUrl := strings.NewReplacer(
|
||||
"{accountId}", url.PathEscape(string(accountId)),
|
||||
"{blobId}", url.PathEscape(blobId),
|
||||
"{name}", url.PathEscape(name),
|
||||
"{type}", url.PathEscape(typ),
|
||||
).Replace(ctx.Session.DownloadUrlTemplate)
|
||||
logger = log.From(logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId))
|
||||
return j.blob.DownloadBinary(downloadUrl, Operation("DownloadBlobStream"), ctx.Session.DownloadEndpoint, ctx)
|
||||
}
|
||||
|
||||
func (j *Client) UploadBlob(accountId AccountId, data []byte, contentType string, ctx Context) (Result[UploadedBlobWithHash], error) {
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
upload := BlobUploadCommand{
|
||||
AccountId: accountId,
|
||||
Create: map[string]UploadObject{
|
||||
"0": {
|
||||
Data: []DataSourceObject{{
|
||||
DataAsBase64: encoded,
|
||||
}},
|
||||
Type: contentType,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
getHash := BlobGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdRef: &ResultReference{
|
||||
ResultOf: "0",
|
||||
Name: CommandBlobUpload,
|
||||
Path: "/ids",
|
||||
},
|
||||
Properties: []string{BlobPropertyDigestSha512},
|
||||
}
|
||||
|
||||
cmd, jerr := j.request(ctx, ns(JmapBlob),
|
||||
invocation(upload, "0"),
|
||||
invocation(getHash, "1"),
|
||||
)
|
||||
if jerr != nil {
|
||||
return ZeroResultV[UploadedBlobWithHash](), jerr
|
||||
}
|
||||
|
||||
return command(j, Operation("UploadBlob"), ctx, cmd, func(body *Response) (UploadedBlobWithHash, State, Error) {
|
||||
var uploadResponse BlobUploadResponse
|
||||
err := retrieveUpload(ctx, body, upload, "0", &uploadResponse)
|
||||
if err != nil {
|
||||
return UploadedBlobWithHash{}, "", err
|
||||
}
|
||||
|
||||
var getResponse BlobGetResponse
|
||||
err = retrieveGet(ctx, body, getHash, "1", &getResponse)
|
||||
if err != nil {
|
||||
return UploadedBlobWithHash{}, "", err
|
||||
}
|
||||
|
||||
if len(uploadResponse.Created) != 1 {
|
||||
ctx.Logger.Error().Msgf("%T.Created has %v entries instead of 1", uploadResponse, len(uploadResponse.Created))
|
||||
return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
upload, ok := uploadResponse.Created["0"]
|
||||
if !ok {
|
||||
ctx.Logger.Error().Msgf("%T.Created has no item '0'", uploadResponse)
|
||||
return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
|
||||
if len(getResponse.List) != 1 {
|
||||
ctx.Logger.Error().Msgf("%T.List has %v entries instead of 1", getResponse, len(getResponse.List))
|
||||
return UploadedBlobWithHash{}, "", jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
get := getResponse.List[0]
|
||||
|
||||
return UploadedBlobWithHash{
|
||||
BlobId: upload.Id,
|
||||
Size: upload.Size,
|
||||
Type: upload.Type,
|
||||
Sha512: get.DigestSha512,
|
||||
}, getResponse.State, nil
|
||||
})
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
type AccountBootstrapResult struct {
|
||||
Identities []Identity `json:"identities,omitempty"`
|
||||
Quotas []Quota `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
var NS_MAIL_QUOTA = ns(JmapMail, JmapQuota)
|
||||
|
||||
func (j *Client) GetBootstrap(accountIds []AccountId, ctx Context) (Result[map[AccountId]AccountBootstrapResult], error) { //NOSONAR
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
|
||||
logger := j.logger("GetBootstrap", ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
calls := make([]Invocation, len(uniqueAccountIds)*2)
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
calls[i*2+0] = invocation(IdentityGetCommand{AccountId: accountId}, mcid(accountId, "I"))
|
||||
calls[i*2+1] = invocation(QuotaGetCommand{AccountId: accountId}, mcid(accountId, "Q"))
|
||||
}
|
||||
|
||||
cmd, err := j.request(ctx, NS_MAIL_QUOTA, calls...)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[AccountId]AccountBootstrapResult](), err
|
||||
}
|
||||
return command(j, Operation("GetBootstrap"), ctx, cmd, func(body *Response) (map[AccountId]AccountBootstrapResult, State, Error) {
|
||||
identityPerAccount := map[AccountId][]Identity{}
|
||||
quotaPerAccount := map[AccountId][]Quota{}
|
||||
identityStatesPerAccount := map[AccountId]State{}
|
||||
quotaStatesPerAccount := map[AccountId]State{}
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
var identityResponse IdentityGetResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandIdentityGet, mcid(accountId, "I"), &identityResponse)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
} else {
|
||||
identityPerAccount[accountId] = identityResponse.List
|
||||
identityStatesPerAccount[accountId] = identityResponse.State
|
||||
}
|
||||
|
||||
var quotaResponse QuotaGetResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandQuotaGet, mcid(accountId, "Q"), "aResponse)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
} else {
|
||||
quotaPerAccount[accountId] = quotaResponse.List
|
||||
quotaStatesPerAccount[accountId] = quotaResponse.State
|
||||
}
|
||||
}
|
||||
|
||||
result := map[AccountId]AccountBootstrapResult{}
|
||||
for accountId, value := range identityPerAccount {
|
||||
r, ok := result[accountId]
|
||||
if !ok {
|
||||
r = AccountBootstrapResult{}
|
||||
}
|
||||
r.Identities = value
|
||||
result[accountId] = r
|
||||
}
|
||||
for accountId, value := range quotaPerAccount {
|
||||
r, ok := result[accountId]
|
||||
if !ok {
|
||||
r = AccountBootstrapResult{}
|
||||
}
|
||||
r.Quotas = value
|
||||
result[accountId] = r
|
||||
}
|
||||
|
||||
return result, squashStateMaps(identityStatesPerAccount, quotaStatesPerAccount), nil
|
||||
})
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package jmap
|
||||
|
||||
var NS_CALENDARS = ns(JmapCalendars)
|
||||
|
||||
func (j *Client) ParseICalendarBlob(accountId AccountId, blobIds []string, ctx Context) (Result[CalendarEventParseResponse], error) {
|
||||
logger := j.logger("ParseICalendarBlob", ctx)
|
||||
|
||||
parse := CalendarEventParseCommand{AccountId: accountId, BlobIds: blobIds}
|
||||
cmd, err := j.request(ctx.WithLogger(logger), NS_CALENDARS,
|
||||
invocation(parse, "0"),
|
||||
)
|
||||
if err != nil {
|
||||
return ZeroResultV[CalendarEventParseResponse](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("ParseICalendarBlob"), ctx, cmd, func(body *Response) (CalendarEventParseResponse, State, Error) {
|
||||
var response CalendarEventParseResponse
|
||||
err = retrieveParse(ctx, body, parse, "0", &response)
|
||||
if err != nil {
|
||||
return CalendarEventParseResponse{}, "", err
|
||||
}
|
||||
return response, "", nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) GetCalendars(accountId AccountId, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
|
||||
return get(j, "GetCalendars", CalendarType,
|
||||
func(accountId AccountId, ids []string) CalendarGetCommand {
|
||||
return CalendarGetCommand{AccountId: accountId, Ids: ids}
|
||||
},
|
||||
CalendarGetResponse{},
|
||||
identity1,
|
||||
accountId, ids,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type CalendarChanges ChangesTemplate[Calendar]
|
||||
|
||||
var _ Changes[Calendar] = CalendarChanges{}
|
||||
|
||||
func (c CalendarChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c CalendarChanges) GetOldState() State { return c.OldState }
|
||||
func (c CalendarChanges) GetNewState() State { return c.NewState }
|
||||
func (c CalendarChanges) GetCreated() []Calendar { return c.Created }
|
||||
func (c CalendarChanges) GetUpdated() []Calendar { return c.Updated }
|
||||
func (c CalendarChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve Calendar changes since a given state.
|
||||
// @apidoc calendar,changes
|
||||
func (j *Client) GetCalendarChanges(accountId AccountId, sinceState State, maxChanges uint, ctx Context) (Result[CalendarChanges], error) {
|
||||
return changes(j, "GetCalendarChanges", CalendarType,
|
||||
func() CalendarChangesCommand {
|
||||
return CalendarChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
CalendarChangesResponse{},
|
||||
func(path string, rof string) CalendarGetRefCommand {
|
||||
return CalendarGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandCalendarChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(resp CalendarGetResponse) []Calendar { return resp.List },
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []Calendar, destroyed []string) CalendarChanges {
|
||||
return CalendarChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) CreateCalendar(accountId AccountId, calendar CalendarChange, ctx Context) (Result[*Calendar], error) {
|
||||
return create(j, "CreateCalendar", CalendarEventType,
|
||||
func(accountId AccountId, create map[string]CalendarChange) CalendarSetCommand {
|
||||
return CalendarSetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ref string) CalendarGetCommand {
|
||||
return CalendarGetCommand{AccountId: accountId, Ids: []string{ref}}
|
||||
},
|
||||
func(resp CalendarSetResponse) map[string]*Calendar {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp CalendarGetResponse) []Calendar {
|
||||
return resp.List
|
||||
},
|
||||
accountId, calendar,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteCalendar(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteCalendar", CalendarEventType,
|
||||
func(accountId AccountId, destroy []string) CalendarSetCommand {
|
||||
return CalendarSetCommand{AccountId: accountId, Destroy: destroy}
|
||||
},
|
||||
CalendarSetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) UpdateCalendar(accountId AccountId, id string, changes CalendarChange, ctx Context) (Result[Calendar], error) {
|
||||
return update(j, "UpdateCalendar", CalendarEventType,
|
||||
func(update map[string]PatchObject) CalendarSetCommand {
|
||||
return CalendarSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) CalendarGetCommand {
|
||||
return CalendarGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp CalendarSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp CalendarGetResponse) Calendar { return resp.List[0] },
|
||||
id, changes,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Note that Quota/changes is currently not supported in Stalwart, as it always gives a
|
||||
// cannotCalculateChanges error back.
|
||||
|
||||
var NS_CHANGES = ns(JmapMail, JmapContacts, JmapCalendars) //, JmapQuota)
|
||||
|
||||
type ObjectChanges struct {
|
||||
MaxChanges uint `json:"maxchanges,omitzero"`
|
||||
Mailboxes *MailboxChangesResponse `json:"mailboxes,omitempty"`
|
||||
Emails *EmailChangesResponse `json:"emails,omitempty"`
|
||||
Calendars *CalendarChangesResponse `json:"calendars,omitempty"`
|
||||
Events *CalendarEventChangesResponse `json:"events,omitempty"`
|
||||
Addressbooks *AddressBookChangesResponse `json:"addressbooks,omitempty"`
|
||||
Contacts *ContactCardChangesResponse `json:"contacts,omitempty"`
|
||||
Identities *IdentityChangesResponse `json:"identities,omitempty"`
|
||||
EmailSubmissions *EmailSubmissionChangesResponse `json:"submissions,omitempty"`
|
||||
// Quotas *QuotaChangesResponse `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
type StateMap struct {
|
||||
Mailboxes *State `json:"mailboxes,omitempty"`
|
||||
Emails *State `json:"emails,omitempty"`
|
||||
Calendars *State `json:"calendars,omitempty"`
|
||||
Events *State `json:"events,omitempty"`
|
||||
Addressbooks *State `json:"addressbooks,omitempty"`
|
||||
Contacts *State `json:"contacts,omitempty"`
|
||||
Identities *State `json:"identities,omitempty"`
|
||||
EmailSubmissions *State `json:"submissions,omitempty"`
|
||||
// Quotas *State `json:"quotas,omitempty"`
|
||||
}
|
||||
|
||||
var _ zerolog.LogObjectMarshaler = StateMap{}
|
||||
|
||||
func (s StateMap) IsZero() bool {
|
||||
return s.Mailboxes == nil && s.Emails == nil && s.Calendars == nil &&
|
||||
s.Events == nil && s.Addressbooks == nil && s.Contacts == nil &&
|
||||
s.Identities == nil && s.EmailSubmissions == nil
|
||||
//s.Quotas == nil
|
||||
}
|
||||
|
||||
func (s StateMap) MarshalZerologObject(e *zerolog.Event) {
|
||||
if s.Mailboxes != nil {
|
||||
e.Str("mailboxes", string(*s.Mailboxes))
|
||||
}
|
||||
if s.Emails != nil {
|
||||
e.Str("emails", string(*s.Emails))
|
||||
}
|
||||
if s.Calendars != nil {
|
||||
e.Str("calendars", string(*s.Calendars))
|
||||
}
|
||||
if s.Events != nil {
|
||||
e.Str("events", string(*s.Events))
|
||||
}
|
||||
if s.Addressbooks != nil {
|
||||
e.Str("addressbooks", string(*s.Addressbooks))
|
||||
}
|
||||
if s.Contacts != nil {
|
||||
e.Str("contacts", string(*s.Contacts))
|
||||
}
|
||||
if s.Identities != nil {
|
||||
e.Str("identities", string(*s.Identities))
|
||||
}
|
||||
if s.EmailSubmissions != nil {
|
||||
e.Str("submissions", string(*s.EmailSubmissions))
|
||||
}
|
||||
// if s.Quotas != nil { e.Str("quotas", string(*s.Quotas)) }
|
||||
}
|
||||
|
||||
// Retrieve the changes in any type of objects at once since a given State.
|
||||
// @api:tags changes
|
||||
func (j *Client) GetChanges(accountId AccountId, stateMap StateMap, maxChanges uint, ctx Context) (Result[ObjectChanges], error) { //NOSONAR
|
||||
logger := log.From(j.logger("GetChanges", ctx).With().Object("state", stateMap).Uint("maxChanges", maxChanges))
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
methodCalls := []Invocation{}
|
||||
if stateMap.Mailboxes != nil {
|
||||
methodCalls = append(methodCalls, invocation(MailboxChangesCommand{AccountId: accountId, SinceState: *stateMap.Mailboxes, MaxChanges: uintPtr(maxChanges)}, "mailboxes"))
|
||||
}
|
||||
if stateMap.Emails != nil {
|
||||
methodCalls = append(methodCalls, invocation(EmailChangesCommand{AccountId: accountId, SinceState: *stateMap.Emails, MaxChanges: uintPtr(maxChanges)}, "emails"))
|
||||
}
|
||||
if stateMap.Calendars != nil {
|
||||
methodCalls = append(methodCalls, invocation(CalendarChangesCommand{AccountId: accountId, SinceState: *stateMap.Calendars, MaxChanges: uintPtr(maxChanges)}, "calendars"))
|
||||
}
|
||||
if stateMap.Events != nil {
|
||||
methodCalls = append(methodCalls, invocation(CalendarEventChangesCommand{AccountId: accountId, SinceState: *stateMap.Events, MaxChanges: uintPtr(maxChanges)}, "events"))
|
||||
}
|
||||
if stateMap.Addressbooks != nil {
|
||||
methodCalls = append(methodCalls, invocation(AddressBookChangesCommand{AccountId: accountId, SinceState: *stateMap.Addressbooks, MaxChanges: uintPtr(maxChanges)}, "addressbooks"))
|
||||
}
|
||||
if stateMap.Contacts != nil {
|
||||
methodCalls = append(methodCalls, invocation(ContactCardChangesCommand{AccountId: accountId, SinceState: *stateMap.Contacts, MaxChanges: uintPtr(maxChanges)}, "contacts"))
|
||||
}
|
||||
if stateMap.Identities != nil {
|
||||
methodCalls = append(methodCalls, invocation(IdentityChangesCommand{AccountId: accountId, SinceState: *stateMap.Identities, MaxChanges: uintPtr(maxChanges)}, "identities"))
|
||||
}
|
||||
if stateMap.EmailSubmissions != nil {
|
||||
methodCalls = append(methodCalls, invocation(EmailSubmissionChangesCommand{AccountId: accountId, SinceState: *stateMap.EmailSubmissions, MaxChanges: uintPtr(maxChanges)}, "submissions"))
|
||||
}
|
||||
// if stateMap.Quotas != nil { methodCalls = append(methodCalls, invocation(CommandQuotaChanges, QuotaChangesCommand{AccountId: accountId, SinceState: *stateMap.Quotas, MaxChanges: posUIntPtr(maxChanges)}, "quotas")) }
|
||||
|
||||
cmd, err := j.request(ctx, NS_CHANGES, methodCalls...)
|
||||
if err != nil {
|
||||
return ZeroResultV[ObjectChanges](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("GetChanges"), ctx, cmd, func(body *Response) (ObjectChanges, State, Error) {
|
||||
changes := ObjectChanges{
|
||||
MaxChanges: maxChanges,
|
||||
}
|
||||
states := map[string]State{}
|
||||
|
||||
var mailboxes MailboxChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandMailboxChanges, "mailboxes", &mailboxes); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Mailboxes = &mailboxes
|
||||
states["mailbox"] = mailboxes.NewState
|
||||
}
|
||||
|
||||
var emails EmailChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandEmailChanges, "emails", &emails); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Emails = &emails
|
||||
states["emails"] = emails.NewState
|
||||
}
|
||||
|
||||
var calendars CalendarChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandCalendarChanges, "calendars", &calendars); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Calendars = &calendars
|
||||
states["calendars"] = calendars.NewState
|
||||
}
|
||||
|
||||
var events CalendarEventChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandCalendarEventChanges, "events", &events); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Events = &events
|
||||
states["events"] = events.NewState
|
||||
}
|
||||
|
||||
var addressbooks AddressBookChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandAddressBookChanges, "addressbooks", &addressbooks); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Addressbooks = &addressbooks
|
||||
states["addressbooks"] = addressbooks.NewState
|
||||
}
|
||||
|
||||
var contacts ContactCardChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandContactCardChanges, "contacts", &contacts); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Contacts = &contacts
|
||||
states["contacts"] = contacts.NewState
|
||||
}
|
||||
|
||||
var identities IdentityChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandIdentityChanges, "identities", &identities); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.Identities = &identities
|
||||
states["identities"] = identities.NewState
|
||||
}
|
||||
|
||||
var submissions EmailSubmissionChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandEmailSubmissionChanges, "submissions", &submissions); err != nil {
|
||||
return ObjectChanges{}, "", err
|
||||
} else if ok {
|
||||
changes.EmailSubmissions = &submissions
|
||||
states["submissions"] = submissions.NewState
|
||||
}
|
||||
|
||||
/*
|
||||
var quotas QuotaChangesResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(logger, body, CommandQuotaChanges, "quotas", "as); err != nil {
|
||||
return Changes{}, "", err
|
||||
} else if ok {
|
||||
changes.Quotas = "as
|
||||
states["quotas"] = quotas.NewState
|
||||
}
|
||||
*/
|
||||
|
||||
return changes, squashKeyedStates(states), nil
|
||||
})
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import "github.com/opencloud-eu/opencloud/pkg/jscontact"
|
||||
|
||||
var NS_CONTACTS = ns(JmapContacts)
|
||||
|
||||
var DEFAULT_CONTACT_CARD_VERSION = jscontact.JSContactVersion_1_0
|
||||
|
||||
func (j *Client) GetContactCards(accountId AccountId, contactIds []string, ctx Context) (Result[ContactCardGetResponse], error) {
|
||||
return get(j, "GetContactCards", ContactCardType,
|
||||
func(accountId AccountId, ids []string) ContactCardGetCommand {
|
||||
return ContactCardGetCommand{AccountId: accountId, Ids: contactIds}
|
||||
},
|
||||
ContactCardGetResponse{},
|
||||
identity1,
|
||||
accountId, contactIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type ContactCardChanges ChangesTemplate[ContactCard]
|
||||
|
||||
var _ Changes[ContactCard] = ContactCardChanges{}
|
||||
|
||||
func (c ContactCardChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c ContactCardChanges) GetOldState() State { return c.OldState }
|
||||
func (c ContactCardChanges) GetNewState() State { return c.NewState }
|
||||
func (c ContactCardChanges) GetCreated() []ContactCard { return c.Created }
|
||||
func (c ContactCardChanges) GetUpdated() []ContactCard { return c.Updated }
|
||||
func (c ContactCardChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve the changes in Contact Cards since a given State.
|
||||
// @api:tags contact,changes
|
||||
func (j *Client) GetContactCardChanges(accountId AccountId, sinceState State, maxChanges uint, ctx Context) (Result[ContactCardChanges], error) {
|
||||
return changes(j, "GetContactCardChanges", ContactCardType,
|
||||
func() ContactCardChangesCommand {
|
||||
return ContactCardChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
ContactCardChangesResponse{},
|
||||
func(path string, rof string) ContactCardGetRefCommand {
|
||||
return ContactCardGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandContactCardChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(resp ContactCardGetResponse) []ContactCard { return resp.List },
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []ContactCard, destroyed []string) ContactCardChanges {
|
||||
return ContactCardChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type ContactCardSearchResults SearchResultsTemplate[ContactCard]
|
||||
|
||||
var _ SearchResults[ContactCard] = &ContactCardSearchResults{}
|
||||
|
||||
func (r *ContactCardSearchResults) GetResults() []ContactCard { return r.Results }
|
||||
func (r *ContactCardSearchResults) GetCanCalculateChanges() ChangeCalculation {
|
||||
return r.CanCalculateChanges
|
||||
}
|
||||
func (r *ContactCardSearchResults) GetPosition() *uint { return r.Position }
|
||||
func (r *ContactCardSearchResults) GetLimit() *uint { return r.Limit }
|
||||
func (r *ContactCardSearchResults) GetTotal() *uint { return r.Total }
|
||||
func (r *ContactCardSearchResults) RemoveResults() { r.Results = nil }
|
||||
func (r *ContactCardSearchResults) SetLimit(limit *uint) { r.Limit = limit }
|
||||
func (r *ContactCardSearchResults) SetPosition(position *uint) { r.Position = position }
|
||||
|
||||
func (j *Client) QueryContactCards(accountIds map[AccountId]QueryParams, //NOSONAR
|
||||
limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator, calculateTotal bool,
|
||||
ctx Context) (Result[map[AccountId]*ContactCardSearchResults], error) {
|
||||
return queryN(j, "QueryContactCards", ContactCardType,
|
||||
[]ContactCardComparator{{Property: ContactCardPropertyUpdated, IsAscending: false}},
|
||||
func(accountId AccountId, qp QueryParams, limit *uint, filter ContactCardFilterElement, sortBy []ContactCardComparator) ContactCardQueryCommand {
|
||||
if qp.Anchor != "" {
|
||||
return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Anchor: qp.Anchor, AnchorOffset: qp.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
|
||||
} else {
|
||||
return ContactCardQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: qp.Position, Limit: limit, CalculateTotal: calculateTotal}
|
||||
}
|
||||
},
|
||||
func(accountId AccountId, cmd Command, path string, rof string) ContactCardGetRefCommand {
|
||||
return ContactCardGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
|
||||
},
|
||||
func(query ContactCardQueryResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
|
||||
return &ContactCardSearchResults{
|
||||
Results: []ContactCard{},
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: valueIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
func(query ContactCardQueryResponse, get ContactCardGetResponse, queryParams QueryParams, limit *uint) *ContactCardSearchResults {
|
||||
return &ContactCardSearchResults{
|
||||
Results: get.List,
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: valueIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
accountIds, limit,
|
||||
filter, sortBy, ctx,
|
||||
)
|
||||
}
|
||||
|
||||
// @api:example create
|
||||
func (j *Client) CreateContactCard(accountId AccountId, contact ContactCardChange, ctx Context) (Result[*ContactCard], error) {
|
||||
if contact.Version == nil {
|
||||
contact.Version = &DEFAULT_CONTACT_CARD_VERSION
|
||||
}
|
||||
return create(j, "CreateContactCard", ContactCardType,
|
||||
func(accountId AccountId, create map[string]ContactCardChange) ContactCardSetCommand {
|
||||
return ContactCardSetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ids string) ContactCardGetCommand {
|
||||
return ContactCardGetCommand{AccountId: accountId, Ids: []string{ids}}
|
||||
},
|
||||
func(resp ContactCardSetResponse) map[string]*ContactCard {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp ContactCardGetResponse) []ContactCard {
|
||||
return resp.List
|
||||
},
|
||||
accountId, contact,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteContactCard(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteContactCard", ContactCardType,
|
||||
func(accountId AccountId, destroy []string) ContactCardSetCommand {
|
||||
return ContactCardSetCommand{AccountId: accountId, Destroy: destroy}
|
||||
},
|
||||
ContactCardSetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
// @api:example update
|
||||
func (j *Client) UpdateContactCard(accountId AccountId, id string, changes ContactCardChange, ctx Context) (Result[ContactCard], error) {
|
||||
return update(j, "UpdateContactCard", ContactCardType,
|
||||
func(update map[string]PatchObject) ContactCardSetCommand {
|
||||
return ContactCardSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) ContactCardGetCommand {
|
||||
return ContactCardGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp ContactCardSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp ContactCardGetResponse) ContactCard { return resp.List[0] },
|
||||
id, changes,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,154 +0,0 @@
|
||||
package jmap
|
||||
|
||||
var NS_CALENDAR_EVENTS = ns(JmapCalendars)
|
||||
|
||||
type CalendarEventSearchResults SearchResultsTemplate[CalendarEvent]
|
||||
|
||||
var _ SearchResults[CalendarEvent] = &CalendarEventSearchResults{}
|
||||
|
||||
func (r *CalendarEventSearchResults) GetResults() []CalendarEvent { return r.Results }
|
||||
func (r *CalendarEventSearchResults) GetCanCalculateChanges() ChangeCalculation {
|
||||
return r.CanCalculateChanges
|
||||
}
|
||||
func (r *CalendarEventSearchResults) GetPosition() *uint { return r.Position }
|
||||
func (r *CalendarEventSearchResults) GetLimit() *uint { return r.Limit }
|
||||
func (r *CalendarEventSearchResults) GetTotal() *uint { return r.Total }
|
||||
func (r *CalendarEventSearchResults) RemoveResults() { r.Results = nil }
|
||||
func (r *CalendarEventSearchResults) SetLimit(limit *uint) { r.Limit = limit }
|
||||
func (r *CalendarEventSearchResults) SetPosition(position *uint) { r.Position = position }
|
||||
|
||||
func (j *Client) GetCalendarEvents(accountId AccountId, eventIds []string, ctx Context) (Result[CalendarEventGetResponse], error) {
|
||||
return get(j, "GetCalendarEvents", CalendarEventType,
|
||||
func(accountId AccountId, ids []string) CalendarEventGetCommand {
|
||||
return CalendarEventGetCommand{AccountId: accountId, Ids: eventIds}
|
||||
},
|
||||
CalendarEventGetResponse{},
|
||||
identity1,
|
||||
accountId, eventIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) QueryCalendarEvents(accountIds map[AccountId]QueryParams, limit *uint, //NOSONAR
|
||||
filter CalendarEventFilterElement, sortBy []CalendarEventComparator, calculateTotal bool,
|
||||
ctx Context) (Result[map[AccountId]*CalendarEventSearchResults], error) {
|
||||
return queryN(j, "QueryCalendarEvents", CalendarEventType,
|
||||
[]CalendarEventComparator{{Property: CalendarEventPropertyStart, IsAscending: false}},
|
||||
func(accountId AccountId, queryParams QueryParams, limit *uint, filter CalendarEventFilterElement, sortBy []CalendarEventComparator) CalendarEventQueryCommand {
|
||||
return CalendarEventQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: queryParams.Position, Anchor: queryParams.Anchor, AnchorOffset: queryParams.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
|
||||
},
|
||||
func(accountId AccountId, cmd Command, path string, rof string) CalendarEventGetRefCommand {
|
||||
return CalendarEventGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
|
||||
},
|
||||
func(query CalendarEventQueryResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
|
||||
return &CalendarEventSearchResults{
|
||||
Results: []CalendarEvent{},
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: valueIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
func(query CalendarEventQueryResponse, get CalendarEventGetResponse, queryParams QueryParams, limit *uint) *CalendarEventSearchResults {
|
||||
return &CalendarEventSearchResults{
|
||||
Results: get.List,
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: valueIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
accountIds, limit,
|
||||
filter, sortBy, ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type CalendarEventChanges ChangesTemplate[CalendarEvent]
|
||||
|
||||
var _ Changes[CalendarEvent] = CalendarEventChanges{}
|
||||
|
||||
func (c CalendarEventChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c CalendarEventChanges) GetOldState() State { return c.OldState }
|
||||
func (c CalendarEventChanges) GetNewState() State { return c.NewState }
|
||||
func (c CalendarEventChanges) GetCreated() []CalendarEvent { return c.Created }
|
||||
func (c CalendarEventChanges) GetUpdated() []CalendarEvent { return c.Updated }
|
||||
func (c CalendarEventChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve the changes in Calendar Events since a given State.
|
||||
// @api:tags event,changes
|
||||
func (j *Client) GetCalendarEventChanges(accountId AccountId, sinceState State, maxChanges uint,
|
||||
ctx Context) (Result[CalendarEventChanges], error) {
|
||||
return changes(j, "GetCalendarEventChanges", CalendarEventType,
|
||||
func() CalendarEventChangesCommand {
|
||||
return CalendarEventChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
CalendarEventChangesResponse{},
|
||||
func(path string, rof string) CalendarEventGetRefCommand {
|
||||
return CalendarEventGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandCalendarEventChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(resp CalendarEventGetResponse) []CalendarEvent { return resp.List },
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []CalendarEvent, destroyed []string) CalendarEventChanges {
|
||||
return CalendarEventChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) CreateCalendarEvent(accountId AccountId, event CalendarEventChange, ctx Context) (Result[*CalendarEvent], error) {
|
||||
return create(j, "CreateCalendarEvent", CalendarEventType,
|
||||
func(accountId AccountId, create map[string]CalendarEventChange) CalendarEventSetCommand {
|
||||
return CalendarEventSetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ref string) CalendarEventGetCommand {
|
||||
return CalendarEventGetCommand{AccountId: accountId, Ids: []string{ref}}
|
||||
},
|
||||
func(resp CalendarEventSetResponse) map[string]*CalendarEvent {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp CalendarEventGetResponse) []CalendarEvent {
|
||||
return resp.List
|
||||
},
|
||||
accountId, event,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteCalendarEvent(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteCalendarEvent", CalendarEventType,
|
||||
func(accountId AccountId, destroy []string) CalendarEventSetCommand {
|
||||
return CalendarEventSetCommand{AccountId: accountId, Destroy: destroy}
|
||||
},
|
||||
CalendarEventSetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) UpdateCalendarEvent(accountId AccountId, id string, changes CalendarEventChange, ctx Context) (Result[CalendarEvent], error) {
|
||||
return update(j, "UpdateCalendarEvent", CalendarEventType,
|
||||
func(update map[string]PatchObject) CalendarEventSetCommand {
|
||||
return CalendarEventSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) CalendarEventGetCommand {
|
||||
return CalendarEventGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp CalendarEventGetResponse) CalendarEvent { return resp.List[0] },
|
||||
id, changes,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
var NS_IDENTITY = ns(JmapMail)
|
||||
|
||||
func (j *Client) GetIdentities(accountId AccountId, identityIds []string, ctx Context) (Result[IdentityGetResponse], error) {
|
||||
return get(j, "GetIdentities", IdentityType,
|
||||
func(accountId AccountId, ids []string) IdentityGetCommand {
|
||||
return IdentityGetCommand{AccountId: accountId, Ids: ids}
|
||||
},
|
||||
IdentityGetResponse{},
|
||||
identity1,
|
||||
accountId, identityIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) GetIdentitiesForAllAccounts(accountIds []AccountId, ctx Context) (Result[map[AccountId][]Identity], error) {
|
||||
return getN(j, "GetIdentitiesForAllAccounts", IdentityType,
|
||||
func(accountId AccountId, ids []string) IdentityGetCommand {
|
||||
return IdentityGetCommand{AccountId: accountId}
|
||||
},
|
||||
IdentityGetResponse{},
|
||||
func(resp IdentityGetResponse) []Identity { return resp.List },
|
||||
identity1,
|
||||
accountIds, []string{},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type IdentitiesAndMailboxesGetResponse struct {
|
||||
Identities map[AccountId][]Identity `json:"identities,omitempty"`
|
||||
NotFound []string `json:"notFound,omitempty"`
|
||||
Mailboxes []Mailbox `json:"mailboxes"`
|
||||
}
|
||||
|
||||
func (j *Client) GetIdentitiesAndMailboxes(mailboxAccountId AccountId, accountIds []AccountId, ctx Context) (Result[IdentitiesAndMailboxesGetResponse], error) {
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
|
||||
logger := j.logger("GetIdentitiesAndMailboxes", ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
calls := make([]Invocation, len(uniqueAccountIds)+1)
|
||||
calls[0] = invocation(MailboxGetCommand{AccountId: mailboxAccountId}, "0")
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
calls[i+1] = invocation(IdentityGetCommand{AccountId: accountId}, strconv.Itoa(i+1))
|
||||
}
|
||||
|
||||
cmd, err := j.request(ctx, NS_IDENTITY, calls...)
|
||||
if err != nil {
|
||||
return ZeroResultV[IdentitiesAndMailboxesGetResponse](), err
|
||||
}
|
||||
return command(j, Operation("GetIdentitiesAndMailboxes"), ctx, cmd, func(body *Response) (IdentitiesAndMailboxesGetResponse, State, Error) {
|
||||
identities := make(map[AccountId][]Identity, len(uniqueAccountIds))
|
||||
stateByAccountId := make(map[AccountId]State, len(uniqueAccountIds))
|
||||
notFound := []string{}
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
var response IdentityGetResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandIdentityGet, strconv.Itoa(i+1), &response)
|
||||
if err != nil {
|
||||
return IdentitiesAndMailboxesGetResponse{}, "", err
|
||||
} else {
|
||||
identities[accountId] = response.List
|
||||
}
|
||||
stateByAccountId[accountId] = response.State
|
||||
notFound = append(notFound, response.NotFound...)
|
||||
}
|
||||
|
||||
var mailboxResponse MailboxGetResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandMailboxGet, "0", &mailboxResponse)
|
||||
if err != nil {
|
||||
return IdentitiesAndMailboxesGetResponse{}, "", err
|
||||
}
|
||||
|
||||
return IdentitiesAndMailboxesGetResponse{
|
||||
Identities: identities,
|
||||
NotFound: structs.Uniq(notFound),
|
||||
Mailboxes: mailboxResponse.List,
|
||||
}, squashState(stateByAccountId), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) CreateIdentity(accountId AccountId, identity IdentityChange, ctx Context) (Result[*Identity], error) {
|
||||
return create(j, "CreateIdentity", IdentityType,
|
||||
func(accountId AccountId, create map[string]IdentityChange) IdentitySetCommand {
|
||||
return IdentitySetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ids string) IdentityGetCommand {
|
||||
return IdentityGetCommand{AccountId: accountId, Ids: []string{ids}}
|
||||
},
|
||||
func(resp IdentitySetResponse) map[string]*Identity {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp IdentityGetResponse) []Identity {
|
||||
return resp.List
|
||||
},
|
||||
accountId, identity,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) UpdateIdentity(accountId AccountId, id string, changes IdentityChange, ctx Context) (Result[Identity], error) {
|
||||
return update(j, "UpdateIdentity", IdentityType,
|
||||
func(update map[string]PatchObject) IdentitySetCommand {
|
||||
return IdentitySetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) IdentityGetCommand {
|
||||
return IdentityGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp IdentitySetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp IdentityGetResponse) Identity { return resp.List[0] },
|
||||
id, changes,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteIdentity(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteIdentity", IdentityType,
|
||||
func(accountId AccountId, destroy []string) IdentitySetCommand {
|
||||
return IdentitySetCommand{AccountId: accountId, Destroy: destroyIds}
|
||||
},
|
||||
IdentitySetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type IdentityChanges ChangesTemplate[Identity]
|
||||
|
||||
var _ Changes[Identity] = IdentityChanges{}
|
||||
|
||||
func (c IdentityChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c IdentityChanges) GetOldState() State { return c.OldState }
|
||||
func (c IdentityChanges) GetNewState() State { return c.NewState }
|
||||
func (c IdentityChanges) GetCreated() []Identity { return c.Created }
|
||||
func (c IdentityChanges) GetUpdated() []Identity { return c.Updated }
|
||||
func (c IdentityChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve the changes in Email Identities since a given State.
|
||||
// @api:tags email,changes
|
||||
func (j *Client) GetIdentityChanges(accountId AccountId, sinceState State, maxChanges uint,
|
||||
ctx Context) (Result[IdentityChanges], error) {
|
||||
return changes(j, "GetIdentityChanges", IdentityType,
|
||||
func() IdentityChangesCommand {
|
||||
return IdentityChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
IdentityChangesResponse{},
|
||||
func(path string, rof string) IdentityGetRefCommand {
|
||||
return IdentityGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandIdentityChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(resp IdentityGetResponse) []Identity { return resp.List },
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []Identity, destroyed []string) IdentityChanges {
|
||||
return IdentityChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
var NS_MAILBOX = ns(JmapMail)
|
||||
|
||||
func (j *Client) GetMailbox(accountId AccountId, ids []string, ctx Context) (Result[MailboxGetResponse], error) {
|
||||
return get(j, "GetMailbox", MailboxType,
|
||||
func(accountId AccountId, ids []string) MailboxGetCommand {
|
||||
return MailboxGetCommand{AccountId: accountId, Ids: ids}
|
||||
},
|
||||
MailboxGetResponse{},
|
||||
identity1,
|
||||
accountId, ids,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) GetAllMailboxes(accountIds []AccountId, ctx Context) (Result[map[AccountId][]Mailbox], error) {
|
||||
return getAN(j, "GetAllMailboxes", MailboxType,
|
||||
func(accountId AccountId, ids []string) MailboxGetCommand {
|
||||
return MailboxGetCommand{AccountId: accountId}
|
||||
},
|
||||
MailboxGetResponse{},
|
||||
identity1,
|
||||
accountIds, []string{},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) SearchMailboxes(accountIds []AccountId, filter MailboxFilterElement, ctx Context) (Result[map[AccountId][]Mailbox], error) {
|
||||
logger := j.logger("SearchMailboxes", ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
|
||||
invocations := make([]Invocation, len(uniqueAccountIds)*2)
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
invocations[i*2+0] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: filter}, mcid(accountId, "0"))
|
||||
invocations[i*2+1] = invocation(MailboxGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandMailboxQuery,
|
||||
Path: "/ids/*",
|
||||
ResultOf: mcid(accountId, "0"),
|
||||
},
|
||||
}, mcid(accountId, "1"))
|
||||
}
|
||||
cmd, err := j.request(ctx, NS_MAILBOX, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[AccountId][]Mailbox](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("SearchMailboxes"), ctx, cmd, func(body *Response) (map[AccountId][]Mailbox, State, Error) {
|
||||
resp := map[AccountId][]Mailbox{}
|
||||
stateByAccountid := map[AccountId]State{}
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
var response MailboxGetResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandMailboxGet, mcid(accountId, "1"), &response)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
resp[accountId] = response.List
|
||||
stateByAccountid[accountId] = response.State
|
||||
}
|
||||
return resp, squashState(stateByAccountid), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) SearchMailboxIdsPerRole(accountIds []AccountId, roles []string, ctx Context) (Result[map[AccountId]map[string]string], error) { //NOSONAR
|
||||
logger := j.logger("SearchMailboxIdsPerRole", ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
|
||||
invocations := make([]Invocation, len(uniqueAccountIds)*len(roles))
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
for j, role := range roles {
|
||||
invocations[i*len(roles)+j] = invocation(&MailboxQueryCommand{AccountId: accountId, Filter: MailboxFilterCondition{Role: role}}, mcid(accountId, role))
|
||||
}
|
||||
}
|
||||
cmd, err := j.request(ctx, NS_MAILBOX, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[AccountId]map[string]string](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("SearchMailboxIdsPerRole"), ctx, cmd, func(body *Response) (map[AccountId]map[string]string, State, Error) {
|
||||
resp := map[AccountId]map[string]string{}
|
||||
stateByAccountid := map[AccountId]State{}
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
mailboxIdsByRole := map[string]string{}
|
||||
for _, role := range roles {
|
||||
var response MailboxQueryResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandMailboxQuery, mcid(accountId, role), &response)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(response.Ids) == 1 {
|
||||
mailboxIdsByRole[role] = response.Ids[0]
|
||||
}
|
||||
if _, ok := stateByAccountid[accountId]; !ok {
|
||||
stateByAccountid[accountId] = response.QueryState
|
||||
}
|
||||
}
|
||||
resp[accountId] = mailboxIdsByRole
|
||||
}
|
||||
return resp, squashState(stateByAccountid), nil
|
||||
})
|
||||
}
|
||||
|
||||
type MailboxChanges ChangesTemplate[Mailbox]
|
||||
|
||||
var _ Changes[Mailbox] = MailboxChanges{}
|
||||
|
||||
func (c MailboxChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c MailboxChanges) GetOldState() State { return c.OldState }
|
||||
func (c MailboxChanges) GetNewState() State { return c.NewState }
|
||||
func (c MailboxChanges) GetCreated() []Mailbox { return c.Created }
|
||||
func (c MailboxChanges) GetUpdated() []Mailbox { return c.Updated }
|
||||
func (c MailboxChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
func newMailboxChanges(oldState, newState State, hasMoreChanges bool, created, updated []Mailbox, destroyed []string) MailboxChanges {
|
||||
return MailboxChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve Mailbox changes since a given state.
|
||||
// @apidoc mailboxes,changes
|
||||
func (j *Client) GetMailboxChanges(accountId AccountId, sinceState State, maxChanges uint, ctx Context) (Result[MailboxChanges], error) {
|
||||
return changesA(j, "GetMailboxChanges", MailboxType,
|
||||
func() MailboxChangesCommand {
|
||||
return MailboxChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
MailboxChangesResponse{},
|
||||
MailboxGetResponse{},
|
||||
func(path string, rof string) MailboxGetRefCommand {
|
||||
return MailboxGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandMailboxChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
newMailboxChanges,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
// Retrieve Mailbox changes of multiple Accounts.
|
||||
// @api:tags email,changes
|
||||
func (j *Client) GetMailboxChangesForMultipleAccounts(accountIds []AccountId, //NOSONAR
|
||||
sinceStateMap map[AccountId]State, maxChanges uint,
|
||||
ctx Context) (Result[map[AccountId]MailboxChanges], error) {
|
||||
return changesN(j, "GetMailboxChangesForMultipleAccounts", MailboxType,
|
||||
accountIds, sinceStateMap,
|
||||
func(accountId AccountId, state State) MailboxChangesCommand {
|
||||
return MailboxChangesCommand{AccountId: accountId, SinceState: state, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
MailboxChangesResponse{},
|
||||
func(accountId AccountId, path string, ref string) MailboxGetRefCommand {
|
||||
return MailboxGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: CommandMailboxChanges, Path: path, ResultOf: ref}}
|
||||
},
|
||||
func(resp MailboxGetResponse) []Mailbox { return resp.List },
|
||||
newMailboxChanges,
|
||||
identity1,
|
||||
func(resp MailboxGetResponse) State { return resp.State },
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) GetMailboxRolesForMultipleAccounts(accountIds []AccountId, ctx Context) (Result[map[AccountId]*[]string], error) {
|
||||
return queryN(j, "GetMailboxRolesForMultipleAccounts", MailboxType,
|
||||
[]MailboxComparator{{Property: MailboxPropertySortOrder, IsAscending: true}},
|
||||
func(accountId AccountId, _ QueryParams, _ *uint, filter MailboxFilterCondition, sortBy []MailboxComparator) MailboxQueryCommand {
|
||||
return MailboxQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, SortAsTree: false, FilterAsTree: false, Position: 0, Anchor: "", AnchorOffset: nil, Limit: nil, CalculateTotal: false}
|
||||
},
|
||||
func(accountId AccountId, cmd Command, path, rof string) MailboxGetRefCommand {
|
||||
return MailboxGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
|
||||
},
|
||||
func(_ MailboxQueryResponse, _ QueryParams, _ *uint) *[]string {
|
||||
return nil // TODO this should never be called
|
||||
},
|
||||
func(_ MailboxQueryResponse, get MailboxGetResponse, _ QueryParams, _ *uint) *[]string {
|
||||
roles := structs.Map(get.List, func(m Mailbox) string { return m.Role })
|
||||
slices.Sort(roles)
|
||||
return &roles
|
||||
},
|
||||
ToNullQueryParams(accountIds), nil, MailboxFilterCondition{HasAnyRole: truep}, nil,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) GetInboxNameForMultipleAccounts(accountIds []AccountId, ctx Context) (Result[map[AccountId]string], error) {
|
||||
logger := j.logger("GetInboxNameForMultipleAccounts", ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
n := len(uniqueAccountIds)
|
||||
if n < 1 {
|
||||
return ZeroResultV[map[AccountId]string](), nil
|
||||
}
|
||||
|
||||
invocations := make([]Invocation, n*2)
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
invocations[i*2+0] = invocation(&MailboxQueryCommand{
|
||||
AccountId: accountId,
|
||||
Filter: MailboxFilterCondition{
|
||||
Role: JmapMailboxRoleInbox,
|
||||
},
|
||||
}, mcid(accountId, "0"))
|
||||
}
|
||||
|
||||
cmd, err := j.request(ctx, NS_MAILBOX, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[AccountId]string](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("GetInboxNameForMultipleAccounts"), ctx, cmd, func(body *Response) (map[AccountId]string, State, Error) {
|
||||
resp := make(map[AccountId]string, n)
|
||||
stateByAccountId := make(map[AccountId]State, n)
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
var r MailboxQueryResponse
|
||||
err = retrieveResponseMatchParameters(ctx, body, CommandMailboxGet, mcid(accountId, "0"), &r)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
switch len(r.Ids) {
|
||||
case 0:
|
||||
// skip: account has no inbox?
|
||||
case 1:
|
||||
resp[accountId] = r.Ids[0]
|
||||
stateByAccountId[accountId] = r.QueryState
|
||||
default:
|
||||
logger.Warn().Msgf("multiple ids for mailbox role='%v' for accountId='%v'", JmapMailboxRoleInbox, accountId)
|
||||
resp[accountId] = r.Ids[0]
|
||||
stateByAccountId[accountId] = r.QueryState
|
||||
}
|
||||
}
|
||||
return resp, squashState(stateByAccountId), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) UpdateMailbox(accountId AccountId, mailboxId string, change MailboxChange, //NOSONAR
|
||||
ctx Context) (Result[Mailbox], error) {
|
||||
return update(j, "UpdateMailbox", MailboxType,
|
||||
func(update map[string]PatchObject) MailboxSetCommand {
|
||||
return MailboxSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(id string) MailboxGetCommand {
|
||||
return MailboxGetCommand{AccountId: accountId, Ids: []string{id}}
|
||||
},
|
||||
func(resp MailboxSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp MailboxGetResponse) Mailbox { return resp.List[0] },
|
||||
mailboxId, change,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) CreateMailbox(accountId AccountId, mailbox MailboxChange, ctx Context) (Result[*Mailbox], error) {
|
||||
return create(j, "CreateMailbox", MailboxType,
|
||||
func(accountId AccountId, create map[string]MailboxChange) MailboxSetCommand {
|
||||
return MailboxSetCommand{AccountId: accountId, Create: create}
|
||||
},
|
||||
func(accountId AccountId, ids string) MailboxGetCommand {
|
||||
return MailboxGetCommand{AccountId: accountId, Ids: []string{ids}}
|
||||
},
|
||||
func(resp MailboxSetResponse) map[string]*Mailbox {
|
||||
return resp.Created
|
||||
},
|
||||
func(resp MailboxGetResponse) []Mailbox {
|
||||
return resp.List
|
||||
},
|
||||
accountId, mailbox,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) DeleteMailboxes(accountId AccountId, destroyIds []string, ctx Context) (Result[map[string]SetError], error) {
|
||||
return destroy(j, "DeleteMailboxes", MailboxType,
|
||||
func(accountId AccountId, destroy []string) MailboxSetCommand {
|
||||
return MailboxSetCommand{AccountId: accountId, Destroy: destroyIds}
|
||||
},
|
||||
MailboxSetResponse{},
|
||||
accountId, destroyIds,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
var NS_OBJECTS = ns(JmapMail, JmapSubmission, JmapContacts, JmapCalendars, JmapQuota)
|
||||
|
||||
type Objects struct {
|
||||
Mailboxes *MailboxGetResponse `json:"mailboxes,omitempty"`
|
||||
Emails *EmailGetResponse `json:"emails,omitempty"`
|
||||
Calendars *CalendarGetResponse `json:"calendars,omitempty"`
|
||||
Events *CalendarEventGetResponse `json:"events,omitempty"`
|
||||
Addressbooks *AddressBookGetResponse `json:"addressbooks,omitempty"`
|
||||
Contacts *ContactCardGetResponse `json:"contacts,omitempty"`
|
||||
Quotas *QuotaGetResponse `json:"quotas,omitempty"`
|
||||
Identities *IdentityGetResponse `json:"identities,omitempty"`
|
||||
EmailSubmissions *EmailSubmissionGetResponse `json:"submissions,omitempty"`
|
||||
}
|
||||
|
||||
// Retrieve objects of all types by their identifiers in a single batch.
|
||||
// @api:tags changes
|
||||
func (j *Client) GetObjects(accountId AccountId, //NOSONAR
|
||||
mailboxIds []string, emailIds []string,
|
||||
addressbookIds []string, contactIds []string,
|
||||
calendarIds []string, eventIds []string,
|
||||
quotaIds []string, identityIds []string,
|
||||
emailSubmissionIds []string,
|
||||
ctx Context,
|
||||
) (Result[Objects], error) {
|
||||
l := j.logger("GetObjects", ctx).With()
|
||||
if len(mailboxIds) > 0 {
|
||||
l = l.Array("mailboxIds", log.SafeStringArray(mailboxIds))
|
||||
}
|
||||
if len(emailIds) > 0 {
|
||||
l = l.Array("emailIds", log.SafeStringArray(emailIds))
|
||||
}
|
||||
if len(addressbookIds) > 0 {
|
||||
l = l.Array("addressbookIds", log.SafeStringArray(addressbookIds))
|
||||
}
|
||||
if len(contactIds) > 0 {
|
||||
l = l.Array("contactIds", log.SafeStringArray(contactIds))
|
||||
}
|
||||
if len(calendarIds) > 0 {
|
||||
l = l.Array("calendarIds", log.SafeStringArray(calendarIds))
|
||||
}
|
||||
if len(eventIds) > 0 {
|
||||
l = l.Array("eventIds", log.SafeStringArray(eventIds))
|
||||
}
|
||||
if len(quotaIds) > 0 {
|
||||
l = l.Array("quotaIds", log.SafeStringArray(quotaIds))
|
||||
}
|
||||
if len(identityIds) > 0 {
|
||||
l = l.Array("identityIds", log.SafeStringArray(identityIds))
|
||||
}
|
||||
if len(emailSubmissionIds) > 0 {
|
||||
l = l.Array("emailSubmissionIds", log.SafeStringArray(emailSubmissionIds))
|
||||
}
|
||||
logger := log.From(l)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
methodCalls := []Invocation{}
|
||||
if len(mailboxIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(MailboxGetCommand{AccountId: accountId, Ids: mailboxIds}, "mailboxes"))
|
||||
}
|
||||
if len(emailIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(EmailGetCommand{AccountId: accountId, Ids: emailIds}, "emails"))
|
||||
}
|
||||
if len(addressbookIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(AddressBookGetCommand{AccountId: accountId, Ids: addressbookIds}, "addressbooks"))
|
||||
}
|
||||
if len(contactIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(ContactCardGetCommand{AccountId: accountId, Ids: contactIds}, "contacts"))
|
||||
}
|
||||
if len(calendarIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(CalendarGetCommand{AccountId: accountId, Ids: calendarIds}, "calendars"))
|
||||
}
|
||||
if len(eventIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(CalendarEventGetCommand{AccountId: accountId, Ids: eventIds}, "events"))
|
||||
}
|
||||
if len(quotaIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(QuotaGetCommand{AccountId: accountId, Ids: quotaIds}, "quotas"))
|
||||
}
|
||||
if len(identityIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(IdentityGetCommand{AccountId: accountId, Ids: identityIds}, "identities"))
|
||||
}
|
||||
if len(emailSubmissionIds) > 0 {
|
||||
methodCalls = append(methodCalls, invocation(EmailSubmissionGetCommand{AccountId: accountId, Ids: emailSubmissionIds}, "emailSubmissionIds"))
|
||||
}
|
||||
|
||||
cmd, err := j.request(ctx, NS_OBJECTS, methodCalls...)
|
||||
if err != nil {
|
||||
return ZeroResultV[Objects](), err
|
||||
}
|
||||
|
||||
return command(j, Operation("GetObjects"), ctx, cmd, func(body *Response) (Objects, State, Error) {
|
||||
objs := Objects{}
|
||||
states := map[string]State{}
|
||||
|
||||
var mailboxes MailboxGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandMailboxGet, "mailboxes", &mailboxes); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Mailboxes = &mailboxes
|
||||
states["mailbox"] = mailboxes.State
|
||||
}
|
||||
|
||||
var emails EmailGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandEmailGet, "emails", &emails); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Emails = &emails
|
||||
states["email"] = emails.State
|
||||
}
|
||||
|
||||
var calendars CalendarGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandCalendarGet, "calendars", &calendars); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Calendars = &calendars
|
||||
states["calendar"] = calendars.State
|
||||
}
|
||||
|
||||
var events CalendarEventGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandCalendarEventGet, "events", &events); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Events = &events
|
||||
states["event"] = events.State
|
||||
}
|
||||
|
||||
var addressbooks AddressBookGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandAddressBookGet, "addressbooks", &addressbooks); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Addressbooks = &addressbooks
|
||||
states["addressbook"] = addressbooks.State
|
||||
}
|
||||
|
||||
var contacts ContactCardGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandContactCardGet, "contacts", &contacts); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Contacts = &contacts
|
||||
states["contact"] = contacts.State
|
||||
}
|
||||
|
||||
var quotas QuotaGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandQuotaGet, "quotas", "as); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Quotas = "as
|
||||
states["quota"] = quotas.State
|
||||
}
|
||||
|
||||
var identities IdentityGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandIdentityGet, "identities", &identities); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.Identities = &identities
|
||||
states["identity"] = identities.State
|
||||
}
|
||||
|
||||
var submissions EmailSubmissionGetResponse
|
||||
if ok, err := tryRetrieveResponseMatchParameters(ctx, body, CommandEmailSubmissionGet, "submissions", &submissions); err != nil {
|
||||
return Objects{}, "", err
|
||||
} else if ok {
|
||||
objs.EmailSubmissions = &submissions
|
||||
states["submissions"] = submissions.State
|
||||
}
|
||||
|
||||
return objs, squashKeyedStates(states), nil
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package jmap
|
||||
|
||||
var NS_PRINCIPALS = ns(JmapPrincipals)
|
||||
|
||||
func (j *Client) GetPrincipals(accountId AccountId, ids []PrincipalId, ctx Context) (Result[PrincipalGetResponse], error) {
|
||||
return get(j, "GetPrincipals", PrincipalType,
|
||||
func(accountId AccountId, ids []PrincipalId) PrincipalGetCommand {
|
||||
return PrincipalGetCommand{AccountId: accountId, Ids: ids}
|
||||
},
|
||||
PrincipalGetResponse{},
|
||||
identity1,
|
||||
accountId, ids,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type PrincipalSearchResults SearchResultsTemplate[Principal]
|
||||
|
||||
var _ SearchResults[Principal] = &PrincipalSearchResults{}
|
||||
|
||||
func (r *PrincipalSearchResults) GetResults() []Principal { return r.Results }
|
||||
func (r *PrincipalSearchResults) GetCanCalculateChanges() ChangeCalculation {
|
||||
return r.CanCalculateChanges
|
||||
}
|
||||
func (r *PrincipalSearchResults) GetPosition() *uint { return r.Position }
|
||||
func (r *PrincipalSearchResults) GetLimit() *uint { return r.Limit }
|
||||
func (r *PrincipalSearchResults) GetTotal() *uint { return r.Total }
|
||||
func (r *PrincipalSearchResults) RemoveResults() { r.Results = nil }
|
||||
func (r *PrincipalSearchResults) SetLimit(limit *uint) { r.Limit = limit }
|
||||
func (r *PrincipalSearchResults) SetPosition(position *uint) { r.Position = position }
|
||||
|
||||
func (j *Client) QueryPrincipals(accountIds map[AccountId]QueryParams, limit *uint, //NOSONAR
|
||||
filter PrincipalFilterElement, sortBy []PrincipalComparator, calculateTotal bool,
|
||||
ctx Context) (Result[map[AccountId]*PrincipalSearchResults], error) {
|
||||
return queryN(j, "QueryPrincipals", PrincipalType,
|
||||
[]PrincipalComparator{{Property: PrincipalPropertyName, IsAscending: true}},
|
||||
func(accountId AccountId, p QueryParams, limit *uint, filter PrincipalFilterElement, sortBy []PrincipalComparator) PrincipalQueryCommand {
|
||||
return PrincipalQueryCommand{AccountId: accountId, Filter: filter, Sort: sortBy, Position: p.Position, Anchor: p.Anchor, AnchorOffset: p.AnchorOffset, Limit: limit, CalculateTotal: calculateTotal}
|
||||
},
|
||||
func(accountId AccountId, cmd Command, path, rof string) PrincipalGetRefCommand {
|
||||
return PrincipalGetRefCommand{AccountId: accountId, IdsRef: &ResultReference{Name: cmd, Path: path, ResultOf: rof}}
|
||||
},
|
||||
func(query PrincipalQueryResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
|
||||
return &PrincipalSearchResults{
|
||||
Results: []Principal{},
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: ptrIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
func(query PrincipalQueryResponse, get PrincipalGetResponse, queryParams QueryParams, limit *uint) *PrincipalSearchResults {
|
||||
return &PrincipalSearchResults{
|
||||
Results: get.List,
|
||||
CanCalculateChanges: ChangeCalculation(query.CanCalculateChanges),
|
||||
Position: valueIf(query.Position, queryParams.Anchor == ""),
|
||||
Total: ptrIf(query.Total, calculateTotal),
|
||||
Limit: valueIf(query.Limit, limit != nil),
|
||||
}
|
||||
},
|
||||
accountIds, limit, filter, sortBy,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package jmap
|
||||
|
||||
var NS_QUOTA = ns(JmapQuota)
|
||||
|
||||
func (j *Client) GetQuotas(accountIds []AccountId, ctx Context) (Result[map[AccountId]QuotaGetResponse], error) {
|
||||
return getN(j, "GetQuotas", QuotaType,
|
||||
func(accountId AccountId, ids []string) QuotaGetCommand {
|
||||
return QuotaGetCommand{AccountId: accountId}
|
||||
},
|
||||
QuotaGetResponse{},
|
||||
identity1,
|
||||
identity1,
|
||||
accountIds, []string{},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
type QuotaChanges ChangesTemplate[Quota]
|
||||
|
||||
var _ Changes[Quota] = QuotaChanges{}
|
||||
|
||||
func (c QuotaChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c QuotaChanges) GetOldState() State { return c.OldState }
|
||||
func (c QuotaChanges) GetNewState() State { return c.NewState }
|
||||
func (c QuotaChanges) GetCreated() []Quota { return c.Created }
|
||||
func (c QuotaChanges) GetUpdated() []Quota { return c.Updated }
|
||||
func (c QuotaChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
// Retrieve the changes in Quotas since a given State.
|
||||
// @api:tags quota,changes
|
||||
func (j *Client) GetQuotaChanges(accountId AccountId, sinceState State, maxChanges uint,
|
||||
ctx Context) (Result[QuotaChanges], error) {
|
||||
return changesA(j, "GetQuotaChanges", QuotaType,
|
||||
func() QuotaChangesCommand {
|
||||
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
QuotaChangesResponse{},
|
||||
QuotaGetResponse{},
|
||||
func(path string, rof string) QuotaGetRefCommand {
|
||||
return QuotaGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandQuotaChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(oldState, newState State, hasMoreChanges bool, created, updated []Quota, destroyed []string) QuotaChanges {
|
||||
return QuotaChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Destroyed: destroyed,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func (j *Client) GetQuotaUsageChanges(accountId AccountId, sinceState State, maxChanges uint,
|
||||
ctx Context) (Result[QuotaChanges], error) {
|
||||
return updates(j, "GetQuotaUsageChanges", QuotaType,
|
||||
func() QuotaChangesCommand {
|
||||
return QuotaChangesCommand{AccountId: accountId, SinceState: sinceState, MaxChanges: uintPtr(maxChanges)}
|
||||
},
|
||||
QuotaChangesResponse{},
|
||||
func(path string, rof string) QuotaGetRefCommand {
|
||||
return QuotaGetRefCommand{
|
||||
AccountId: accountId,
|
||||
IdsRef: &ResultReference{
|
||||
Name: CommandQuotaChanges,
|
||||
Path: path,
|
||||
ResultOf: rof,
|
||||
},
|
||||
PropertiesRef: &ResultReference{
|
||||
Name: CommandQuotaChanges,
|
||||
Path: "/updatedProperties",
|
||||
ResultOf: rof,
|
||||
},
|
||||
}
|
||||
},
|
||||
func(resp QuotaGetResponse) []Quota { return resp.List },
|
||||
func(oldState, newState State, hasMoreChanges bool, updated []Quota) QuotaChanges {
|
||||
return QuotaChanges{
|
||||
OldState: oldState,
|
||||
NewState: newState,
|
||||
HasMoreChanges: hasMoreChanges,
|
||||
Updated: updated,
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var NS_VACATION = ns(JmapVacationResponse)
|
||||
|
||||
const (
|
||||
vacationResponseId = "singleton"
|
||||
)
|
||||
|
||||
func (j *Client) GetVacationResponse(accountId AccountId, ctx Context) (Result[VacationResponseGetResponse], error) {
|
||||
return get(j, "GetVacationResponse", VacationResponseType,
|
||||
func(accountId AccountId, ids []string) VacationResponseGetCommand {
|
||||
return VacationResponseGetCommand{AccountId: accountId}
|
||||
},
|
||||
VacationResponseGetResponse{},
|
||||
identity1,
|
||||
accountId, []string{},
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
// Same as VacationResponse but without the id.
|
||||
type VacationResponseChange struct {
|
||||
// Should a vacation response be sent if a message arrives between the "fromDate" and "toDate"?
|
||||
IsEnabled *bool `json:"isEnabled,omitempty"`
|
||||
// If "isEnabled" is true, messages that arrive on or after this date-time (but before the "toDate" if defined) should receive the
|
||||
// user's vacation response. If null, the vacation response is effective immediately.
|
||||
FromDate *time.Time `json:"fromDate,omitzero"`
|
||||
// If "isEnabled" is true, messages that arrive before this date-time but on or after the "fromDate" if defined) should receive the
|
||||
// user's vacation response. If null, the vacation response is effective indefinitely.
|
||||
ToDate *time.Time `json:"toDate,omitzero"`
|
||||
// The subject that will be used by the message sent in response to messages when the vacation response is enabled.
|
||||
// If null, an appropriate subject SHOULD be set by the server.
|
||||
Subject string `json:"subject,omitempty"`
|
||||
// The plaintext body to send in response to messages when the vacation response is enabled.
|
||||
// If this is null, the server SHOULD generate a plaintext body part from the "htmlBody" when sending vacation responses
|
||||
// but MAY choose to send the response as HTML only. If both "textBody" and "htmlBody" are null, an appropriate default
|
||||
// body SHOULD be generated for responses by the server.
|
||||
TextBody string `json:"textBody,omitempty"`
|
||||
// The HTML body to send in response to messages when the vacation response is enabled.
|
||||
// If this is null, the server MAY choose to generate an HTML body part from the "textBody" when sending vacation responses
|
||||
// or MAY choose to send the response as plaintext only.
|
||||
HtmlBody string `json:"htmlBody,omitempty"`
|
||||
}
|
||||
|
||||
var _ Change[VacationResponse] = VacationResponseChange{}
|
||||
|
||||
func (m VacationResponseChange) AsPatch() (PatchObject, error) {
|
||||
return toPatchObject(m)
|
||||
}
|
||||
|
||||
func (m VacationResponseChange) GetMarker() VacationResponse {
|
||||
return VacationResponse{}
|
||||
}
|
||||
|
||||
type VacationResponseChanges ChangesTemplate[VacationResponse]
|
||||
|
||||
var _ Changes[VacationResponse] = VacationResponseChanges{}
|
||||
|
||||
func (c VacationResponseChanges) GetHasMoreChanges() bool { return c.HasMoreChanges }
|
||||
func (c VacationResponseChanges) GetOldState() State { return c.OldState }
|
||||
func (c VacationResponseChanges) GetNewState() State { return c.NewState }
|
||||
func (c VacationResponseChanges) GetCreated() []VacationResponse { return c.Created }
|
||||
func (c VacationResponseChanges) GetUpdated() []VacationResponse { return c.Updated }
|
||||
func (c VacationResponseChanges) GetDestroyed() []string { return c.Destroyed }
|
||||
|
||||
func (j *Client) SetVacationResponse(accountId AccountId, change VacationResponseChange,
|
||||
ctx Context) (Result[VacationResponse], error) {
|
||||
return update(j, "SetVacationResponse", VacationResponseType,
|
||||
func(update map[string]PatchObject) VacationResponseSetCommand {
|
||||
return VacationResponseSetCommand{AccountId: accountId, Update: update}
|
||||
},
|
||||
func(_ string) VacationResponseGetCommand {
|
||||
return VacationResponseGetCommand{AccountId: accountId}
|
||||
},
|
||||
func(resp VacationResponseSetResponse) map[string]SetError { return resp.NotUpdated },
|
||||
func(resp VacationResponseGetResponse) VacationResponse { return resp.List[0] },
|
||||
vacationResponseId, change,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import "context"
|
||||
|
||||
func (j *Client) EnablePushNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error)) (WsClient, error) {
|
||||
return j.ws.EnableNotifications(ctx, pushState, sessionProvider, j)
|
||||
}
|
||||
|
||||
func (j *Client) AddWsPushListener(listener WsPushListener) {
|
||||
j.wsPushListeners.add(listener)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
session SessionClient
|
||||
api ApiClient
|
||||
blob BlobClient
|
||||
ws WsClientFactory
|
||||
sessionEventListeners *eventListeners[SessionEventListener]
|
||||
wsPushListeners *eventListeners[WsPushListener]
|
||||
io.Closer
|
||||
WsPushListener
|
||||
}
|
||||
|
||||
type ApiSupplier interface {
|
||||
Api() ApiClient
|
||||
}
|
||||
|
||||
type Hooks interface {
|
||||
OnSessionOutdated(session *Session, newState SessionState)
|
||||
}
|
||||
|
||||
var _ io.Closer = &Client{}
|
||||
var _ WsPushListener = &Client{}
|
||||
var _ ApiSupplier = &Client{}
|
||||
var _ Hooks = &Client{}
|
||||
|
||||
func (j *Client) Close() error {
|
||||
return errors.Join(j.api.Close(), j.session.Close(), j.blob.Close(), j.ws.Close())
|
||||
}
|
||||
|
||||
func (j *Client) Api() ApiClient {
|
||||
return j.api
|
||||
}
|
||||
|
||||
func NewClient(session SessionClient, api ApiClient, blob BlobClient, ws WsClientFactory) *Client {
|
||||
return &Client{
|
||||
session: session,
|
||||
api: api,
|
||||
blob: blob,
|
||||
ws: ws,
|
||||
sessionEventListeners: newEventListeners[SessionEventListener](),
|
||||
wsPushListeners: newEventListeners[WsPushListener](),
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Client) AddSessionEventListener(listener SessionEventListener) {
|
||||
j.sessionEventListeners.add(listener)
|
||||
}
|
||||
|
||||
func (j *Client) OnSessionOutdated(session *Session, newSessionState SessionState) {
|
||||
j.sessionEventListeners.signal(func(listener SessionEventListener) {
|
||||
listener.OnSessionOutdated(session, newSessionState)
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Client) OnNotification(username string, stateChange StateChange) {
|
||||
j.wsPushListeners.signal(func(listener WsPushListener) {
|
||||
listener.OnNotification(username, stateChange)
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve JMAP well-known data from the Stalwart server and create a Session from that.
|
||||
func (j *Client) FetchSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (Session, Error) {
|
||||
sessionResponse, err := j.session.GetSession(ctx, sessionUrl, username, logger)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
return newSession(sessionResponse)
|
||||
}
|
||||
|
||||
func (j *Client) logger(operation string, ctx Context) *log.Logger {
|
||||
l := ctx.Logger.With().Str(logOperation, operation)
|
||||
return log.From(l)
|
||||
}
|
||||
|
||||
func (j *Client) loggerParams(operation string, ctx Context, params func(zerolog.Context) zerolog.Context) *log.Logger {
|
||||
l := ctx.Logger.With().Str(logOperation, operation)
|
||||
if params != nil {
|
||||
l = params(l)
|
||||
}
|
||||
return log.From(l)
|
||||
}
|
||||
|
||||
func (j *Client) maxCallsCheck(calls int, ctx Context) Error {
|
||||
if calls > ctx.Session.Capabilities.Core.MaxCallsInRequest {
|
||||
ctx.Logger.Error().
|
||||
Int("max-calls-in-request", ctx.Session.Capabilities.Core.MaxCallsInRequest).
|
||||
Int("calls-in-request", calls).
|
||||
Msgf("number of calls in request payload (%d) exceeds the allowed maximum (%d)", ctx.Session.Capabilities.Core.MaxCallsInRequest, calls)
|
||||
return jmapError(errTooManyMethodCalls, JmapErrorTooManyMethodCalls)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Construct a Request from the given list of Invocation objects.
|
||||
//
|
||||
// If an issue occurs, then it is logged prior to returning it.
|
||||
func (j *Client) request(ctx Context, using []JmapNamespace, methodCalls ...Invocation) (Request, Error) {
|
||||
sanitized := structs.Filter(methodCalls, func(inv Invocation) bool { return inv.Command != "" })
|
||||
|
||||
err := j.maxCallsCheck(len(sanitized), ctx)
|
||||
if err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
|
||||
if using == nil {
|
||||
using = JmapNamespaces
|
||||
}
|
||||
if !slices.Contains(using, JmapCore) {
|
||||
using = slices.Insert(using, 0, JmapCore)
|
||||
}
|
||||
return Request{
|
||||
Using: using,
|
||||
MethodCalls: sanitized,
|
||||
CreatedIds: nil,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
JmapErrorAuthenticationFailed = iota
|
||||
JmapErrorInvalidHttpRequest
|
||||
JmapErrorServerResponse
|
||||
JmapErrorReadingResponseBody
|
||||
JmapErrorDecodingResponseBody
|
||||
JmapErrorEncodingRequestBody
|
||||
JmapErrorCreatingRequest
|
||||
JmapErrorSendingRequest
|
||||
JmapErrorInvalidSessionResponse
|
||||
JmapErrorInvalidJmapRequestPayload
|
||||
JmapErrorInvalidJmapResponsePayload
|
||||
JmapErrorSetError
|
||||
JmapErrorTooManyMethodCalls
|
||||
JmapErrorUnspecifiedType
|
||||
JmapErrorServerUnavailable
|
||||
JmapErrorServerFail
|
||||
JmapErrorUnknownMethod
|
||||
JmapErrorInvalidArguments
|
||||
JmapErrorInvalidResultReference
|
||||
JmapErrorForbidden
|
||||
JmapErrorAccountNotFound
|
||||
JmapErrorAccountNotSupportedByMethod
|
||||
JmapErrorAccountReadOnly
|
||||
JmapErrorFailedToEstablishWssConnection
|
||||
JmapErrorWssConnectionResponseMissingJmapSubprotocol
|
||||
JmapErrorWssFailedToSendWebSocketPushEnable
|
||||
JmapErrorWssFailedToSendWebSocketPushDisable
|
||||
JmapErrorWssFailedToClose
|
||||
JmapErrorWssFailedToRetrieveSession
|
||||
JmapErrorSocketPushUnsupported
|
||||
JmapErrorMissingCreatedObject
|
||||
JmapErrorInvalidObjectState
|
||||
JmapErrorPatchObjectSerialization
|
||||
JmapErrorInvalidProperties
|
||||
JmapErrorRequestTracing
|
||||
)
|
||||
|
||||
var (
|
||||
errTooManyMethodCalls = errors.New("the amount of methodCalls in the request body would exceed the maximum that is configured in the session")
|
||||
)
|
||||
|
||||
type Error interface {
|
||||
Code() int
|
||||
error
|
||||
}
|
||||
|
||||
type JmapError struct {
|
||||
code int
|
||||
err error
|
||||
typ string
|
||||
description string
|
||||
}
|
||||
|
||||
var _ Error = &JmapError{}
|
||||
var _ error = &JmapError{}
|
||||
var _ error = JmapError{}
|
||||
|
||||
func (e JmapError) Code() int {
|
||||
return e.code
|
||||
}
|
||||
func (e JmapError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
func (e JmapError) Error() string {
|
||||
if e.err != nil {
|
||||
return e.err.Error()
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
func (e JmapError) Type() string {
|
||||
return e.typ
|
||||
}
|
||||
func (e JmapError) Description() string {
|
||||
return e.description
|
||||
}
|
||||
|
||||
func jmapErrorCode(statusCode int) int {
|
||||
code := JmapErrorServerResponse
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized:
|
||||
code = JmapErrorAuthenticationFailed
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func jmapError(err error, code int) Error {
|
||||
if err != nil {
|
||||
return JmapError{code: code, err: err}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func jmapResponseError(code int, err error, typ string, description string) JmapError {
|
||||
return JmapError{
|
||||
code: code,
|
||||
err: err,
|
||||
typ: typ,
|
||||
description: description,
|
||||
}
|
||||
}
|
||||
|
||||
func setErrorError(err SetError, objectType ObjectType) Error {
|
||||
var e error
|
||||
if len(err.Properties) > 0 {
|
||||
e = fmt.Errorf("failed to modify %s due to %s error in properties [%s]: %s", objectType, err.Type, strings.Join(err.Properties, ", "), err.Description)
|
||||
} else {
|
||||
e = fmt.Errorf("failed to modify %s due to %s error: %s", objectType, err.Type, err.Description)
|
||||
}
|
||||
code := JmapErrorSetError
|
||||
switch err.Type {
|
||||
case SetErrorTypeInvalidProperties:
|
||||
code = JmapErrorInvalidProperties
|
||||
}
|
||||
return JmapError{code: code, err: e}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package jmap
|
||||
|
||||
// This is for functions that are only supposed to be visible in tests.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"iter"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
func valuesOf(p *packages.Package) iter.Seq[*ast.ValueSpec] { //NOSONAR
|
||||
return func(yield func(*ast.ValueSpec) bool) {
|
||||
for _, syn := range p.Syntax {
|
||||
for _, decl := range syn.Decls {
|
||||
g, ok := decl.(*ast.GenDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, s := range g.Specs {
|
||||
e, ok := s.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !yield(e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseConsts(pkgID string, suffix string, typeName string) (map[string]string, error) { //NOSONAR
|
||||
result := map[string]string{}
|
||||
{
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.LoadSyntax,
|
||||
Dir: ".",
|
||||
Tests: false,
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if packages.PrintErrors(pkgs) > 0 {
|
||||
return nil, fmt.Errorf("failed to parse the package '%s'", pkgID)
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
if p.ID != pkgID {
|
||||
continue
|
||||
}
|
||||
for v := range valuesOf(p) {
|
||||
for i, ident := range v.Names {
|
||||
if ident != nil && strings.HasSuffix(ident.Name, suffix) {
|
||||
value := v.Values[i]
|
||||
switch c := value.(type) {
|
||||
case *ast.CallExpr:
|
||||
switch f := c.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
if f.Name == typeName {
|
||||
switch a := c.Args[0].(type) {
|
||||
case *ast.BasicLit:
|
||||
if a.Kind == token.STRING {
|
||||
result[ident.Name] = strings.Trim(a.Value, `"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,802 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
ochttp "github.com/opencloud-eu/opencloud/pkg/http"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
)
|
||||
|
||||
// Implementation of ApiClient, SessionClient and BlobClient that uses
|
||||
// HTTP to perform JMAP operations.
|
||||
type HttpJmapClient struct {
|
||||
client *http.Client
|
||||
userAgent string
|
||||
authenticator HttpJmapClientAuthenticator
|
||||
listener HttpJmapApiClientEventListener
|
||||
traceRequests bool
|
||||
traceMaxRequestBodySize int64
|
||||
traceResponses bool
|
||||
traceMaxResponseBodySize int64
|
||||
}
|
||||
|
||||
var (
|
||||
_ ApiClient = &HttpJmapClient{}
|
||||
_ SessionClient = &HttpJmapClient{}
|
||||
_ BlobClient = &HttpJmapClient{}
|
||||
)
|
||||
|
||||
const (
|
||||
logEndpoint = "endpoint"
|
||||
logUri = "uri"
|
||||
logMethod = "method"
|
||||
logHttpStatus = "status"
|
||||
logHttpStatusCode = "status-code"
|
||||
logHttpUrl = "url"
|
||||
logProto = "proto"
|
||||
logProtoJmap = "jmap"
|
||||
logProtoJmapWs = "jmapws"
|
||||
logType = "type"
|
||||
logTypeRequest = "request"
|
||||
logTypeResponse = "response"
|
||||
logTypePush = "push"
|
||||
logDuration = "duration"
|
||||
logBodyTruncated = "truncated"
|
||||
logAuthenticatorId = "auth-id"
|
||||
|
||||
responseBodyPeekSize = 2 * 1024
|
||||
)
|
||||
|
||||
// Record JMAP HTTP execution events that may occur, e.g. using metrics.
|
||||
type HttpJmapApiClientEventListener interface {
|
||||
OnSuccessfulRequest(endpoint string, op Operation, status int)
|
||||
OnFailedRequest(endpoint string, op Operation, err error)
|
||||
OnFailedRequestWithStatus(endpoint string, op Operation, status int)
|
||||
OnResponseBodyReadingError(endpoint string, op Operation, err error)
|
||||
OnResponseBodyUnmarshallingError(endpoint string, op Operation, err error)
|
||||
OnSuccessfulWsRequest(endpoint string, op Operation, status int)
|
||||
OnFailedWsHandshakeRequestWithStatus(endpoint string, op Operation, status int)
|
||||
}
|
||||
|
||||
type nullHttpJmapApiClientEventListener struct {
|
||||
}
|
||||
|
||||
func (l nullHttpJmapApiClientEventListener) OnSuccessfulRequest(endpoint string, op Operation, status int) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnFailedRequest(endpoint string, op Operation, err error) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnFailedRequestWithStatus(endpoint string, op Operation, status int) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnResponseBodyReadingError(endpoint string, op Operation, err error) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnResponseBodyUnmarshallingError(endpoint string, op Operation, err error) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnSuccessfulWsRequest(endpoint string, op Operation, status int) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
func (l nullHttpJmapApiClientEventListener) OnFailedWsHandshakeRequestWithStatus(endpoint string, op Operation, status int) {
|
||||
// null implementation does nothing
|
||||
}
|
||||
|
||||
var _ HttpJmapApiClientEventListener = nullHttpJmapApiClientEventListener{}
|
||||
|
||||
type HttpJmapClientAuthenticator interface {
|
||||
GetId() string
|
||||
Authenticate(ctx context.Context, username string, logger *log.Logger, req *http.Request) Error
|
||||
AuthenticateWS(ctx context.Context, username string, logger *log.Logger, headers http.Header) Error
|
||||
}
|
||||
|
||||
type MasterAuthHttpJmapClientAuthenticator struct {
|
||||
masterUser string
|
||||
masterPassword string
|
||||
}
|
||||
|
||||
func NewMasterAuthHttpJmapClientAuthenticator(masterUser string, masterPassword string) HttpJmapClientAuthenticator {
|
||||
return &MasterAuthHttpJmapClientAuthenticator{masterUser: masterUser, masterPassword: masterPassword}
|
||||
}
|
||||
|
||||
var _ HttpJmapClientAuthenticator = &MasterAuthHttpJmapClientAuthenticator{}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) auth(username string, headers http.Header) {
|
||||
// not nice to read, but heavily optimized to prevent needless memory allocations, since
|
||||
// this hot path will be used all the time
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Basic ")
|
||||
enc := base64.NewEncoder(base64.StdEncoding, &sb)
|
||||
enc.Write([]byte(username))
|
||||
if username != h.masterUser {
|
||||
enc.Write([]byte("%"))
|
||||
enc.Write([]byte(h.masterUser))
|
||||
}
|
||||
enc.Write([]byte(":"))
|
||||
enc.Write([]byte(h.masterPassword))
|
||||
enc.Close()
|
||||
headers.Set("Authorization", sb.String())
|
||||
}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) GetId() string {
|
||||
return "master"
|
||||
}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) Authenticate(_ context.Context, username string, _ *log.Logger, req *http.Request) Error {
|
||||
h.auth(username, req.Header)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MasterAuthHttpJmapClientAuthenticator) AuthenticateWS(_ context.Context, username string, _ *log.Logger, headers http.Header) Error {
|
||||
h.auth(username, headers)
|
||||
return nil
|
||||
}
|
||||
|
||||
// An implementation of HttpJmapApiClientMetricsRecorder that does nothing.
|
||||
func NewNullHttpJmapApiClientEventListener() HttpJmapApiClientEventListener {
|
||||
return nullHttpJmapApiClientEventListener{}
|
||||
}
|
||||
|
||||
func NewHttpJmapClient(client *http.Client, authenticator HttpJmapClientAuthenticator, listener HttpJmapApiClientEventListener,
|
||||
traceRequests bool, traceMaxRequestBodySize int64,
|
||||
traceResponses bool, traceMaxResponseBodySize int64,
|
||||
) *HttpJmapClient {
|
||||
return &HttpJmapClient{
|
||||
client: client,
|
||||
authenticator: authenticator,
|
||||
userAgent: "OpenCloud/" + version.GetString(),
|
||||
listener: listener,
|
||||
traceRequests: traceRequests,
|
||||
traceMaxRequestBodySize: traceMaxRequestBodySize,
|
||||
traceResponses: traceResponses,
|
||||
traceMaxResponseBodySize: traceMaxResponseBodySize,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) Close() error {
|
||||
h.client.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
type AuthenticationError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e AuthenticationError) Error() string {
|
||||
return fmt.Sprintf("failed to find user for authentication: %v", e.Err.Error())
|
||||
}
|
||||
func (e AuthenticationError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) auth(ctx context.Context, username string, logger *log.Logger, req *http.Request) (string, Error) {
|
||||
return h.authenticator.GetId(), h.authenticator.Authenticate(ctx, username, logger, req)
|
||||
}
|
||||
|
||||
var (
|
||||
errNilBaseUrl = errors.New("sessionUrl is nil")
|
||||
)
|
||||
|
||||
func (h *HttpJmapClient) beforeRequest(_ context.Context, logger *log.Logger, _ string, endpoint string, req *http.Request) Error {
|
||||
l := logger.Trace()
|
||||
if h.traceRequests && l.Enabled() {
|
||||
if err := ochttp.DumpHttpRequest(req, h.traceMaxRequestBodySize, func(method string, uri string, content string, truncated bool) {
|
||||
if truncated {
|
||||
l = l.Bool(logBodyTruncated, true)
|
||||
}
|
||||
l.Str(logMethod, req.Method).Str(logUri, req.URL.String()).
|
||||
Str(logEndpoint, endpoint).Str(logProto, logProtoJmap).Str(logType, logTypeRequest).
|
||||
Msg(content)
|
||||
}); err != nil {
|
||||
return jmapError(err, JmapErrorRequestTracing)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) response(peekSize int64, _ context.Context, logger *log.Logger, _ string, endpoint string, duration time.Duration, resp *http.Response) (io.ReadCloser, []byte, bool, error) {
|
||||
whole := resp.Body
|
||||
peek := []byte{}
|
||||
truncated := false
|
||||
var err error
|
||||
|
||||
if peekSize > 0 {
|
||||
whole, peek, truncated, err = ochttp.PeekResponse(resp.Body, peekSize)
|
||||
if err != nil {
|
||||
return whole, peek, truncated, err
|
||||
}
|
||||
}
|
||||
l := logger.Trace()
|
||||
if h.traceResponses && l.Enabled() {
|
||||
body, err := ochttp.DumpHttpResponse(resp, whole, h.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) {
|
||||
l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint).
|
||||
Str(logMethod, method).Str(logUri, uri).
|
||||
Str(logHttpStatus, log.SafeString(resp.Status)).Int(logHttpStatusCode, resp.StatusCode).
|
||||
Dur(logDuration, duration).
|
||||
Msg(content)
|
||||
})
|
||||
if err != nil {
|
||||
return body, peek, truncated, err
|
||||
} else {
|
||||
return body, peek, truncated, nil
|
||||
}
|
||||
} else {
|
||||
return whole, peek, truncated, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) GetSession(ctx context.Context, sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
|
||||
if sessionUrl == nil {
|
||||
logger.Error().Msg("sessionUrl is nil")
|
||||
return SessionResponse{}, jmapError(errNilBaseUrl, JmapErrorInvalidHttpRequest)
|
||||
}
|
||||
// See the JMAP specification on Service Autodiscovery: https://jmap.io/spec-core.html#service-autodiscovery
|
||||
// There are two standardised autodiscovery methods in use for Internet protocols:
|
||||
// - DNS SRV (see [@!RFC2782], [@!RFC6186], and [@!RFC6764])
|
||||
// - .well-known/servicename (see [@!RFC8615])
|
||||
// We are currently only supporting RFC8615, using the baseurl that was configured in this HttpJmapApiClient.
|
||||
//sessionUrl := baseurl.JoinPath(".well-known", "jmap")
|
||||
sessionUrlStr := sessionUrl.String()
|
||||
endpoint := endpointOf(sessionUrl)
|
||||
logger = log.From(logger.With().Str(logEndpoint, endpoint))
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, sessionUrlStr, nil)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create GET request for %v", sessionUrl)
|
||||
return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest)
|
||||
}
|
||||
req.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate") // spec recommendation
|
||||
req.Header.Add("Content-Type", "application/json") //NOSONAR
|
||||
req.Header.Add("User-Agent", h.userAgent) //NOSONAR
|
||||
|
||||
if err := h.beforeRequest(ctx, logger, username, endpoint, req); err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
|
||||
if authenticatorId, err := h.auth(ctx, username, logger, req); err != nil {
|
||||
return SessionResponse{}, err
|
||||
} else {
|
||||
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
res, err := h.client.Do(req)
|
||||
duration := time.Since(before)
|
||||
if err != nil {
|
||||
h.listener.OnFailedRequest(endpoint, Operation("GetSession"), err)
|
||||
logger.Error().Err(err).Msgf("failed to perform GET %v", sessionUrl)
|
||||
return SessionResponse{}, jmapError(err, JmapErrorInvalidHttpRequest)
|
||||
}
|
||||
|
||||
// dump the response regardless of the status code
|
||||
body, _, _, err := h.response(responseBodyPeekSize, ctx, logger, username, endpoint, duration, res)
|
||||
|
||||
// since we are not returning a stream from this function, we have to close the response body
|
||||
// before leaving the scope of the function
|
||||
defer func() {
|
||||
if err := body.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
|
||||
}
|
||||
}()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
h.listener.OnFailedRequestWithStatus(endpoint, Operation("GetSession"), res.StatusCode)
|
||||
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 200")
|
||||
return SessionResponse{}, jmapError(fmt.Errorf("JMAP API response status is %v", res.Status), jmapErrorCode(res.StatusCode))
|
||||
}
|
||||
|
||||
h.listener.OnSuccessfulRequest(endpoint, Operation("GetSession"), res.StatusCode)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body") //NOSONAR
|
||||
h.listener.OnResponseBodyReadingError(endpoint, Operation("GetSession"), err)
|
||||
return SessionResponse{}, jmapError(err, JmapErrorReadingResponseBody)
|
||||
}
|
||||
|
||||
var data SessionResponse
|
||||
if err := json.NewDecoder(body).Decode(&data); err != nil {
|
||||
logger.Error().Str(logHttpUrl, log.SafeString(sessionUrlStr)).Err(err).Msg("failed to decode JSON payload from .well-known/jmap response")
|
||||
h.listener.OnResponseBodyUnmarshallingError(endpoint, Operation("GetSession"), err)
|
||||
return SessionResponse{}, jmapError(err, JmapErrorDecodingResponseBody)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) Command(operation Operation, request Request, ctx Context) (io.ReadCloser, Language, Error) { //NOSONAR
|
||||
session := ctx.Session
|
||||
logger := ctx.Logger
|
||||
acceptLanguage := ctx.AcceptLanguage
|
||||
cotx := ctx.Context
|
||||
|
||||
jmapUrl := session.JmapUrl.String()
|
||||
endpoint := session.JmapEndpoint
|
||||
logger = log.From(logger.With().Str(logEndpoint, endpoint))
|
||||
|
||||
bodyBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to marshall JSON payload")
|
||||
return nil, "", jmapError(err, JmapErrorEncodingRequestBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(cotx, http.MethodPost, jmapUrl, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create POST request for %v", jmapUrl)
|
||||
return nil, "", jmapError(err, JmapErrorCreatingRequest)
|
||||
}
|
||||
|
||||
// Some JMAP APIs use the Accept-Language header to determine which language to use to translate
|
||||
// texts in attributes.
|
||||
if acceptLanguage != "" {
|
||||
req.Header.Add("Accept-Language", acceptLanguage) //NOSONAR
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "application/json") //NOSONAR
|
||||
req.Header.Add("User-Agent", h.userAgent) //NOSONAR
|
||||
|
||||
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
|
||||
|
||||
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
|
||||
return nil, "", err
|
||||
} else {
|
||||
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
res, err := h.client.Do(req)
|
||||
duration := time.Since(before)
|
||||
if err != nil {
|
||||
h.listener.OnFailedRequest(endpoint, operation, err)
|
||||
logger.Error().Err(err).Msgf("failed to perform POST %v", jmapUrl)
|
||||
return nil, "", jmapError(err, JmapErrorSendingRequest)
|
||||
}
|
||||
|
||||
// note that we are omitting the usual deferred response body closer here since we are returning
|
||||
// an io.ReadCloser to the body and if we close it when leaving the scope of this function, the
|
||||
// caller won't be able to read the response; instead, it is up to the caller to close the
|
||||
// ReadCloser we are returning from here
|
||||
|
||||
body, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res)
|
||||
language := Language(res.Header.Get("Content-Language")) //NOSONAR
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body")
|
||||
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
|
||||
return nil, language, jmapError(err, JmapErrorServerResponse)
|
||||
}
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
|
||||
logger.Error().Str(logEndpoint, endpoint).Str(logHttpStatus, log.SafeString(res.Status)).Msg("HTTP response status code is not 2xx") //NOSONAR
|
||||
return nil, language, jmapError(fmt.Errorf("JMAP server responsed with '%s'", res.Status), jmapErrorCode(res.StatusCode))
|
||||
}
|
||||
|
||||
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
|
||||
|
||||
return body, language, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) UploadBinary(uploadUrl string, operation Operation, endpoint string, contentType string, body io.Reader, ctx Context) (UploadedBlob, Language, Error) { //NOSONAR
|
||||
session := ctx.Session
|
||||
logger := ctx.Logger
|
||||
acceptLanguage := ctx.AcceptLanguage
|
||||
cotx := ctx.Context
|
||||
|
||||
logger = log.From(logger.With().Str(logEndpoint, endpoint))
|
||||
|
||||
req, err := http.NewRequestWithContext(cotx, http.MethodPost, uploadUrl, body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create POST request for %v", uploadUrl)
|
||||
return UploadedBlob{}, "", jmapError(err, JmapErrorCreatingRequest)
|
||||
}
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("User-Agent", h.userAgent)
|
||||
if acceptLanguage != "" {
|
||||
req.Header.Add("Accept-Language", acceptLanguage)
|
||||
}
|
||||
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
|
||||
|
||||
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
|
||||
return UploadedBlob{}, "", err
|
||||
} else {
|
||||
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
res, err := h.client.Do(req)
|
||||
duration := time.Since(before)
|
||||
if err != nil {
|
||||
h.listener.OnFailedRequest(endpoint, operation, err)
|
||||
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
|
||||
return UploadedBlob{}, "", jmapError(err, JmapErrorSendingRequest)
|
||||
}
|
||||
|
||||
responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res)
|
||||
|
||||
// since we are not returning a stream from this function, we have to close the response body
|
||||
// before leaving the scope of the function
|
||||
defer func() {
|
||||
if err := responseBody.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
|
||||
}
|
||||
}()
|
||||
language := Language(res.Header.Get("Content-Language"))
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body")
|
||||
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
|
||||
return UploadedBlob{}, language, jmapError(err, JmapErrorServerResponse)
|
||||
}
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
|
||||
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
|
||||
return UploadedBlob{}, language, jmapError(err, jmapErrorCode(res.StatusCode))
|
||||
}
|
||||
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
|
||||
|
||||
var result UploadedBlob
|
||||
if err := json.NewDecoder(responseBody).Decode(&result); err != nil {
|
||||
logger.Error().Str(logHttpUrl, log.SafeString(uploadUrl)).Err(err).Msg("failed to decode JSON payload from the upload response")
|
||||
h.listener.OnResponseBodyUnmarshallingError(endpoint, operation, err)
|
||||
return UploadedBlob{}, language, jmapError(err, JmapErrorDecodingResponseBody)
|
||||
}
|
||||
|
||||
return result, language, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapClient) DownloadBinary(downloadUrl string, operation Operation, endpoint string, ctx Context) (*BlobDownload, Language, Error) { //NOSONAR
|
||||
session := ctx.Session
|
||||
logger := ctx.Logger
|
||||
acceptLanguage := ctx.AcceptLanguage
|
||||
cotx := ctx.Context
|
||||
|
||||
logger = log.From(logger.With().Str(logEndpoint, endpoint))
|
||||
|
||||
req, err := http.NewRequestWithContext(cotx, http.MethodGet, downloadUrl, nil)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create GET request for %v", downloadUrl)
|
||||
return nil, "", jmapError(err, JmapErrorCreatingRequest)
|
||||
}
|
||||
req.Header.Add("User-Agent", h.userAgent)
|
||||
if acceptLanguage != "" {
|
||||
req.Header.Add("Accept-Language", acceptLanguage)
|
||||
}
|
||||
h.beforeRequest(cotx, logger, session.Username, endpoint, req)
|
||||
|
||||
if authenticatorId, err := h.auth(cotx, session.Username, logger, req); err != nil {
|
||||
return nil, "", err
|
||||
} else {
|
||||
logger = log.From(logger.With().Str(logAuthenticatorId, authenticatorId))
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
res, err := h.client.Do(req)
|
||||
duration := time.Since(before)
|
||||
if err != nil {
|
||||
h.listener.OnFailedRequest(endpoint, operation, err)
|
||||
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
|
||||
return nil, "", jmapError(err, JmapErrorSendingRequest)
|
||||
}
|
||||
|
||||
// note that we are omitting the usual deferred response body closer here since we are returning
|
||||
// an io.ReadCloser to the body and if we close it when leaving the scope of this function, the
|
||||
// caller won't be able to read the response; instead, it is up to the caller to close the
|
||||
// ReadCloser we are returning from here
|
||||
|
||||
responseBody, _, _, err := h.response(responseBodyPeekSize, cotx, logger, session.Username, endpoint, duration, res)
|
||||
|
||||
language := Language(res.Header.Get("Content-Language"))
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body")
|
||||
h.listener.OnResponseBodyReadingError(endpoint, operation, err)
|
||||
return nil, language, jmapError(err, JmapErrorServerResponse)
|
||||
}
|
||||
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return nil, language, nil
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
h.listener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
|
||||
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 2xx")
|
||||
return nil, language, jmapError(err, jmapErrorCode(res.StatusCode))
|
||||
}
|
||||
h.listener.OnSuccessfulRequest(endpoint, operation, res.StatusCode)
|
||||
|
||||
sizeStr := res.Header.Get("Content-Length")
|
||||
size := -1
|
||||
if sizeStr != "" {
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msgf("failed to parse Content-Length blob download response header value '%v'", sizeStr)
|
||||
size = -1
|
||||
}
|
||||
}
|
||||
|
||||
return &BlobDownload{
|
||||
Body: responseBody,
|
||||
Size: size,
|
||||
Type: res.Header.Get("Content-Type"),
|
||||
ContentDisposition: res.Header.Get("Content-Disposition"),
|
||||
CacheControl: res.Header.Get("Cache-Control"),
|
||||
}, language, nil
|
||||
}
|
||||
|
||||
type WebSocketPushEnableType string
|
||||
type WebSocketPushDisableType string
|
||||
|
||||
const (
|
||||
WebSocketPushTypeEnable = WebSocketPushEnableType("WebSocketPushEnable")
|
||||
WebSocketPushTypeDisable = WebSocketPushDisableType("WebSocketPushDisable")
|
||||
)
|
||||
|
||||
type WebSocketPushEnable struct {
|
||||
// This MUST be the string "WebSocketPushEnable".
|
||||
Type WebSocketPushEnableType `json:"@type"`
|
||||
|
||||
// A list of data type names (e.g., "Mailbox" or "Email") that the client is interested in.
|
||||
//
|
||||
// A StateChange notification will only be sent if the data for one of these types changes.
|
||||
// Other types are omitted from the TypeState object.
|
||||
//
|
||||
// If null, changes will be pushed for all supported data types.
|
||||
DataTypes *[]string `json:"dataTypes"`
|
||||
|
||||
// The last "pushState" token that the client received from the server.
|
||||
|
||||
// Upon receipt of a "pushState" token, the server SHOULD immediately send all changes since that state token.
|
||||
PushState State `json:"pushState,omitempty"`
|
||||
}
|
||||
|
||||
type WebSocketPushDisable struct {
|
||||
// This MUST be the string "WebSocketPushDisable".
|
||||
Type WebSocketPushDisableType `json:"@type"`
|
||||
}
|
||||
|
||||
type HttpWsClientFactory struct {
|
||||
dialer *websocket.Dialer
|
||||
authenticator HttpJmapClientAuthenticator
|
||||
logger *log.Logger
|
||||
eventListener HttpJmapApiClientEventListener
|
||||
traceWsResponses bool
|
||||
traceMaxResponseBodySize int64
|
||||
}
|
||||
|
||||
var _ WsClientFactory = &HttpWsClientFactory{}
|
||||
|
||||
func NewHttpWsClientFactory(dialer *websocket.Dialer, authenticator HttpJmapClientAuthenticator, logger *log.Logger,
|
||||
eventListener HttpJmapApiClientEventListener,
|
||||
traceWsResponses bool, traceMaxResponseBodySize int64,
|
||||
) (*HttpWsClientFactory, error) {
|
||||
// RFC 8887: Section 4.2:
|
||||
// Otherwise, the client MUST make an authenticated HTTP request [RFC7235] on the encrypted connection
|
||||
// and MUST include the value "jmap" in the list of protocols for the "Sec-WebSocket-Protocol" header
|
||||
// field.
|
||||
dialer.Subprotocols = []string{"jmap"}
|
||||
|
||||
return &HttpWsClientFactory{
|
||||
dialer: dialer,
|
||||
authenticator: authenticator,
|
||||
logger: logger,
|
||||
eventListener: eventListener,
|
||||
traceWsResponses: true,
|
||||
traceMaxResponseBodySize: 4 * 1024,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) auth(ctx context.Context, username string, logger *log.Logger, h http.Header) Error {
|
||||
return w.authenticator.AuthenticateWS(ctx, username, logger, h)
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) connect(operation Operation, ctx context.Context, sessionProvider func() (*Session, error)) (*websocket.Conn, string, string, Error) {
|
||||
session, err := sessionProvider()
|
||||
if err != nil {
|
||||
return nil, "", "", jmapError(err, JmapErrorWssFailedToRetrieveSession)
|
||||
}
|
||||
if session == nil {
|
||||
return nil, "", "", jmapError(fmt.Errorf("WSS connection failed to retrieve JMAP session"), JmapErrorWssFailedToRetrieveSession)
|
||||
}
|
||||
|
||||
if !session.SupportsWebsocketPush {
|
||||
return nil, "", "", jmapError(fmt.Errorf("WSS connection returned a session that does not support websocket push"), JmapErrorSocketPushUnsupported)
|
||||
}
|
||||
|
||||
username := session.Username
|
||||
u := session.WebsocketUrl
|
||||
endpoint := session.WebsocketEndpoint
|
||||
|
||||
logger := log.From(w.logger.With().Str("username", log.SafeString(username)).Str("url", log.SafeString(u.String())))
|
||||
|
||||
h := http.Header{}
|
||||
w.auth(ctx, username, logger, h)
|
||||
w.logger.Trace().Str("username", log.SafeString(username)).Str("url", log.SafeString(u.String())).Msgf("connecting") // TODO more/better attributes here for WS connection attempts
|
||||
before := time.Now()
|
||||
c, res, err := w.dialer.DialContext(ctx, u.String(), h)
|
||||
duration := time.Since(before)
|
||||
if err != nil {
|
||||
return nil, "", endpoint, jmapError(err, JmapErrorFailedToEstablishWssConnection)
|
||||
}
|
||||
|
||||
var body io.ReadCloser = nil
|
||||
{
|
||||
l := logger.Trace()
|
||||
if w.traceWsResponses && l.Enabled() {
|
||||
body, err = ochttp.DumpHttpResponse(res, res.Body, w.traceMaxResponseBodySize, func(method, uri, content string, truncated bool) {
|
||||
l.Str(logProto, logProtoJmap).Str(logType, logTypeResponse).Str(logEndpoint, endpoint).
|
||||
Str(logMethod, method).Str(logUri, uri).
|
||||
Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).
|
||||
Dur(logDuration, duration).
|
||||
Msg(content)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// we are not using the response body at all, at least for now
|
||||
defer func() {
|
||||
if body != nil {
|
||||
if err := body.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if res.StatusCode != 101 {
|
||||
w.eventListener.OnFailedRequestWithStatus(endpoint, operation, res.StatusCode)
|
||||
logger.Error().Str(logHttpStatus, log.SafeString(res.Status)).Int(logHttpStatusCode, res.StatusCode).Msg("HTTP response status code is not 101")
|
||||
return nil, "", endpoint, jmapError(fmt.Errorf("JMAP WS API response status is %v", res.Status), jmapErrorCode(res.StatusCode))
|
||||
} else {
|
||||
w.eventListener.OnSuccessfulWsRequest(endpoint, operation, res.StatusCode)
|
||||
}
|
||||
|
||||
// RFC 8887: Section 4.2:
|
||||
// The reply from the server MUST also contain a corresponding "Sec-WebSocket-Protocol" header
|
||||
// field with a value of "jmap" in order for a JMAP subprotocol connection to be established.
|
||||
if !slices.Contains(res.Header.Values("Sec-WebSocket-Protocol"), "jmap") {
|
||||
return nil, "", endpoint, jmapError(fmt.Errorf("WSS connection header does not contain Sec-WebSocket-Protocol:jmap"), JmapErrorWssConnectionResponseMissingJmapSubprotocol)
|
||||
}
|
||||
|
||||
return c, username, endpoint, nil
|
||||
}
|
||||
|
||||
type HttpWsClient struct {
|
||||
client *HttpWsClientFactory
|
||||
username string
|
||||
sessionProvider func() (*Session, error)
|
||||
c *websocket.Conn
|
||||
logger *log.Logger
|
||||
endpoint string
|
||||
listener WsPushListener
|
||||
WsClient
|
||||
}
|
||||
|
||||
func (w *HttpWsClient) readPump() { //NOSONAR
|
||||
logger := log.From(w.logger.With().Str("username", w.username))
|
||||
defer func() {
|
||||
if err := w.c.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
logger.Warn().Err(err).Msg("failed to close websocket connection")
|
||||
}
|
||||
}()
|
||||
//w.c.SetReadLimit(maxMessageSize)
|
||||
//c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
//c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||
|
||||
for {
|
||||
if _, message, err := w.c.ReadMessage(); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
logger.Error().Err(err).Msg("unexpected close")
|
||||
}
|
||||
break
|
||||
} else {
|
||||
if logger.Trace().Enabled() {
|
||||
logger.Trace().Str(logEndpoint, w.endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypePush).Msg(string(message))
|
||||
}
|
||||
|
||||
var peek struct {
|
||||
Type string `json:"@type"`
|
||||
}
|
||||
if err := json.Unmarshal(message, &peek); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to deserialized pushed WS message")
|
||||
continue
|
||||
}
|
||||
switch peek.Type {
|
||||
case string(TypeOfStateChange):
|
||||
var stateChange StateChange
|
||||
if err := json.Unmarshal(message, &stateChange); err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to deserialized pushed WS message into a %T", stateChange)
|
||||
continue
|
||||
} else {
|
||||
if w.listener != nil {
|
||||
w.listener.OnNotification(w.username, stateChange)
|
||||
} else {
|
||||
logger.Warn().Msgf("no listener to be notified of %v", stateChange)
|
||||
}
|
||||
}
|
||||
default:
|
||||
logger.Warn().Msgf("unsupported pushed WS message JMAP @type: '%s'", peek.Type)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) EnableNotifications(ctx context.Context, pushState State, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
|
||||
c, username, endpoint, jerr := w.connect(Operation("EnableNotifications"), ctx, sessionProvider)
|
||||
if jerr != nil {
|
||||
return nil, jerr
|
||||
}
|
||||
|
||||
msg := WebSocketPushEnable{
|
||||
Type: WebSocketPushTypeEnable,
|
||||
DataTypes: nil, // = all datatypes
|
||||
PushState: pushState, // will be omitted if empty string
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, jmapError(err, JmapErrorWssFailedToSendWebSocketPushEnable)
|
||||
}
|
||||
|
||||
if w.logger.Trace().Enabled() {
|
||||
w.logger.Trace().Str(logEndpoint, endpoint).Str(logProto, logProtoJmapWs).Str(logType, logTypeRequest).Msg(string(data))
|
||||
}
|
||||
if err := c.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return nil, jmapError(err, JmapErrorWssFailedToSendWebSocketPushEnable)
|
||||
}
|
||||
|
||||
wsc := &HttpWsClient{
|
||||
client: w,
|
||||
username: username,
|
||||
sessionProvider: sessionProvider,
|
||||
c: c,
|
||||
logger: w.logger,
|
||||
endpoint: endpoint,
|
||||
listener: listener,
|
||||
}
|
||||
|
||||
go wsc.readPump()
|
||||
|
||||
return wsc, nil
|
||||
}
|
||||
|
||||
func (w *HttpWsClientFactory) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HttpWsClient) DisableNotifications() Error {
|
||||
if c.c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
werr := c.c.WriteJSON(WebSocketPushDisable{Type: WebSocketPushTypeDisable})
|
||||
merr := c.c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
cerr := c.c.Close()
|
||||
|
||||
if werr != nil {
|
||||
return jmapError(werr, JmapErrorWssFailedToClose)
|
||||
}
|
||||
if merr != nil {
|
||||
return jmapError(merr, JmapErrorWssFailedToClose)
|
||||
}
|
||||
if cerr != nil {
|
||||
return jmapError(cerr, JmapErrorWssFailedToClose)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HttpWsClient) Close() error {
|
||||
return c.DisableNotifications()
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testNopReadCloser struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *testNopReadCloser) Close() error {
|
||||
c.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResponse(t *testing.T) {
|
||||
text := "hello, world"
|
||||
|
||||
logger := log.NewLogger(log.Level("trace"))
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
peekSize int
|
||||
expectedTruncation bool
|
||||
expectedPeek string
|
||||
}{
|
||||
{"without peek", 0, false, ""},
|
||||
{"with truncated peek", 4, true, "hell"},
|
||||
{"with full peek", 1024, false, text},
|
||||
} {
|
||||
for _, tr := range []struct {
|
||||
name string
|
||||
traceSize int
|
||||
}{
|
||||
{"with tracing", 256},
|
||||
{"without tracing", 0},
|
||||
} {
|
||||
for _, b := range []struct {
|
||||
name string
|
||||
withBody bool
|
||||
}{
|
||||
{"with a body", true},
|
||||
{"without a body", false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s %s %s %s", t.Name(), tt.name, tr.name, b.name), func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
var closer *testNopReadCloser
|
||||
var source io.ReadCloser
|
||||
if b.withBody {
|
||||
closer = &testNopReadCloser{closed: false, Reader: strings.NewReader(text)}
|
||||
source = closer
|
||||
} else {
|
||||
closer = nil
|
||||
source = http.NoBody
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Header: http.Header{},
|
||||
Body: source,
|
||||
}
|
||||
|
||||
client := HttpJmapClient{
|
||||
traceResponses: true,
|
||||
traceMaxResponseBodySize: int64(tr.traceSize),
|
||||
}
|
||||
body, peek, truncated, err := client.response(int64(tt.peekSize), context.TODO(), &logger, "username", "https://stalwart", time.Duration(1*time.Second), resp)
|
||||
require.NoError(err)
|
||||
|
||||
if b.withBody {
|
||||
require.Equal(tt.expectedPeek, string(peek))
|
||||
require.Equal(tt.expectedTruncation, truncated)
|
||||
buf := new(strings.Builder)
|
||||
_, err = io.Copy(buf, body)
|
||||
require.NoError(err)
|
||||
require.Equal(text, buf.String())
|
||||
} else {
|
||||
require.Empty(peek)
|
||||
require.False(truncated)
|
||||
}
|
||||
|
||||
body.Close()
|
||||
|
||||
if closer != nil {
|
||||
require.True(closer.closed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-8676
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,8 +0,0 @@
|
||||
//go:build groupware_examples
|
||||
|
||||
package jmap
|
||||
|
||||
func Example() {
|
||||
SerializeExamples(ExemplarInstance)
|
||||
//Output:
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/jscontact"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestObjectNames(t *testing.T) { //NOSONAR
|
||||
require := require.New(t)
|
||||
objectTypeNames, err := parseConsts("github.com/opencloud-eu/opencloud/pkg/jmap", "Name", "ObjectTypeName")
|
||||
require.NoError(err)
|
||||
for n, v := range objectTypeNames {
|
||||
require.True(strings.HasSuffix(n, "Name"))
|
||||
prefix := n[0 : len(n)-len("Name")]
|
||||
require.Equal(prefix, v)
|
||||
}
|
||||
}
|
||||
|
||||
func jsoneq[X any](t *testing.T, expected string, object X) {
|
||||
data, err := json.MarshalIndent(object, "", "")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, expected, string(data))
|
||||
|
||||
var rec X
|
||||
err = json.Unmarshal(data, &rec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, object, rec)
|
||||
}
|
||||
|
||||
func TestContactCard(t *testing.T) {
|
||||
created, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := time.Parse(time.RFC3339, "2025-09-26T09:58:01+02:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Card",
|
||||
"kind": "group",
|
||||
"id": "20fba820-2f8e-432d-94f1-5abbb59d3ed7",
|
||||
"addressBookIds": {
|
||||
"79047052-ae0e-4299-8860-5bff1a139f3d": true,
|
||||
"44eb6105-08c1-458b-895e-4ad1149dfabd": true
|
||||
},
|
||||
"version": "1.0",
|
||||
"created": "2025-09-25T18:26:14.094725532+02:00",
|
||||
"language": "fr-BE",
|
||||
"members": {
|
||||
"314815dd-81c8-4640-aace-6dc83121616d": true,
|
||||
"c528b277-d8cb-45f2-b7df-1aa3df817463": true,
|
||||
"81dea240-c0a4-4929-82e7-79e713a8bbe4": true
|
||||
},
|
||||
"prodId": "OpenCloud Groupware 1.0",
|
||||
"relatedTo": {
|
||||
"urn:uid:ca9d2a62-e068-43b6-a470-46506976d505": {
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"contact": true
|
||||
}
|
||||
},
|
||||
"urn:uid:72183ec2-b218-4983-9c89-ff117eeb7c5e": {
|
||||
"relation": {
|
||||
"emergency": true,
|
||||
"spouse": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"uid": "1091f2bb-6ae6-4074-bb64-df74071d7033",
|
||||
"updated": "2025-09-26T09:58:01+02:00",
|
||||
"name": {
|
||||
"@type": "Name",
|
||||
"components": [
|
||||
{"@type": "NameComponent", "value": "OpenCloud", "kind": "surname"},
|
||||
{"value": " ", "kind": "separator"},
|
||||
{"value": "Team", "kind": "surname2"}
|
||||
],
|
||||
"isOrdered": true,
|
||||
"defaultSeparator": ", ",
|
||||
"sortAs": {
|
||||
"surname": "OpenCloud Team"
|
||||
},
|
||||
"full": "OpenCloud Team"
|
||||
},
|
||||
"nicknames": {
|
||||
"a": {
|
||||
"@type": "Nickname",
|
||||
"name": "The Team",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1
|
||||
}
|
||||
},
|
||||
"organizations": {
|
||||
"o": {
|
||||
"@type": "Organization",
|
||||
"name": "OpenCloud GmbH",
|
||||
"units": [
|
||||
{"@type": "OrgUnit", "name": "Marketing", "sortAs": "marketing"},
|
||||
{"@type": "OrgUnit", "name": "Sales"},
|
||||
{"name": "Operations", "sortAs": "ops"}
|
||||
],
|
||||
"sortAs": "opencloud",
|
||||
"contexts": {
|
||||
"work": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"speakToAs": {
|
||||
"@type": "SpeakToAs",
|
||||
"grammaticalGender": "inanimate",
|
||||
"pronouns": {
|
||||
"p": {
|
||||
"@type": "Pronouns",
|
||||
"pronouns": "it",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"t": {
|
||||
"@type": "Title",
|
||||
"name": "The",
|
||||
"kind": "title",
|
||||
"organizationId": "o"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
"e": {
|
||||
"@type": "EmailAddress",
|
||||
"address": "info@opencloud.eu.example.com",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "work"
|
||||
}
|
||||
},
|
||||
"onlineServices": {
|
||||
"s": {
|
||||
"@type": "OnlineService",
|
||||
"service": "The Misinformation Game",
|
||||
"uri": "https://misinfogame.com/91886aa0-3586-4ade-b9bb-ec031464a251",
|
||||
"user": "opencloudeu",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "imaginary"
|
||||
}
|
||||
},
|
||||
"phones": {
|
||||
"p": {
|
||||
"@type": "Phone",
|
||||
"number": "+1-804-222-1111",
|
||||
"features": {
|
||||
"voice": true,
|
||||
"text": true
|
||||
},
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "imaginary"
|
||||
}
|
||||
},
|
||||
"preferredLanguages": {
|
||||
"wa": {
|
||||
"@type": "LanguagePref",
|
||||
"language": "wa-BE",
|
||||
"contexts": {
|
||||
"private": true
|
||||
},
|
||||
"pref": 1
|
||||
},
|
||||
"de": {
|
||||
"language": "de-DE",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 2
|
||||
}
|
||||
},
|
||||
"calendars": {
|
||||
"c": {
|
||||
"@type": "Calendar",
|
||||
"kind": "calendar",
|
||||
"uri": "https://opencloud.eu/calendars/521b032b-a2b3-4540-81b9-3f6bccacaab2",
|
||||
"mediaType": "application/jscontact+json",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "work"
|
||||
}
|
||||
},
|
||||
"schedulingAddresses": {
|
||||
"s": {
|
||||
"@type": "SchedulingAddress",
|
||||
"uri": "mailto:scheduling@opencloud.eu.example.com",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "work"
|
||||
}
|
||||
},
|
||||
"addresses": {
|
||||
"k26": {
|
||||
"@type": "Address",
|
||||
"components": [
|
||||
{"@type": "AddressComponent", "kind": "block", "value": "2-7"},
|
||||
{"kind": "separator", "value": "-"},
|
||||
{"kind": "number", "value": "2"},
|
||||
{"kind": "separator", "value": " "},
|
||||
{"kind": "district", "value": "Marunouchi"},
|
||||
{"kind": "locality", "value": "Chiyoda-ku"},
|
||||
{"kind": "region", "value": "Tokyo"},
|
||||
{"kind": "separator", "value": " "},
|
||||
{"kind": "postcode", "value": "100-8994"}
|
||||
],
|
||||
"isOrdered": true,
|
||||
"defaultSeparator": ", ",
|
||||
"full": "2-7-2 Marunouchi, Chiyoda-ku, Tokyo 100-8994",
|
||||
"countryCode": "JP",
|
||||
"coordinates": "geo:35.6796373,139.7616907",
|
||||
"timeZone": "JST",
|
||||
"contexts": {
|
||||
"delivery": true,
|
||||
"work": true
|
||||
},
|
||||
"pref": 2
|
||||
}
|
||||
},
|
||||
"cryptoKeys": {
|
||||
"k1": {
|
||||
"@type": "CryptoKey",
|
||||
"uri": "https://opencloud.eu.example.com/keys/d550f57c-582c-43cc-8d94-822bded9ab36",
|
||||
"mediaType": "application/pgp-keys",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "keys"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"d1": {
|
||||
"@type": "Directory",
|
||||
"kind": "entry",
|
||||
"uri": "https://opencloud.eu.example.com/addressbook/8c2f0363-af0a-4d16-a9d5-8a9cd885d722",
|
||||
"listAs": 1
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"r1": {
|
||||
"@type": "Link",
|
||||
"kind": "contact",
|
||||
"uri": "mailto:contact@opencloud.eu.example.com",
|
||||
"contexts": {
|
||||
"work": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"m": {
|
||||
"@type": "Media",
|
||||
"kind": "logo",
|
||||
"uri": "https://opencloud.eu.example.com/opencloud.svg",
|
||||
"mediaType": "image/svg+xml",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 123,
|
||||
"label": "svg",
|
||||
"blobId": "53feefbabeb146fcbe3e59e91462fa5f"
|
||||
}
|
||||
},
|
||||
"anniversaries": {
|
||||
"birth": {
|
||||
"@type": "Anniversary",
|
||||
"kind": "birth",
|
||||
"date": {
|
||||
"@type": "PartialDate",
|
||||
"year": 2025,
|
||||
"month": 9,
|
||||
"day": 26,
|
||||
"calendarScale": "iso8601"
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywords": {
|
||||
"imaginary": true,
|
||||
"test": true
|
||||
},
|
||||
"notes": {
|
||||
"n1": {
|
||||
"@type": "Note",
|
||||
"note": "This is a note.",
|
||||
"created": "2025-09-25T18:26:14.094725532+02:00",
|
||||
"author": {
|
||||
"@type": "Author",
|
||||
"name": "Test Data",
|
||||
"uri": "https://isbn.example.com/a461f292-6bf1-470e-b08d-f6b4b0223fe3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"personalInfo": {
|
||||
"p1": {
|
||||
"@type": "PersonalInfo",
|
||||
"kind": "expertise",
|
||||
"value": "Clouds",
|
||||
"level": "high",
|
||||
"listAs": 1,
|
||||
"label": "experts"
|
||||
}
|
||||
},
|
||||
"localizations": {
|
||||
"fr": {
|
||||
"personalInfo": {
|
||||
"value": "Nuages"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, ContactCard{
|
||||
Type: jscontact.ContactCardType,
|
||||
Kind: jscontact.ContactCardKindGroup,
|
||||
Id: "20fba820-2f8e-432d-94f1-5abbb59d3ed7",
|
||||
AddressBookIds: map[string]bool{
|
||||
"79047052-ae0e-4299-8860-5bff1a139f3d": true,
|
||||
"44eb6105-08c1-458b-895e-4ad1149dfabd": true,
|
||||
},
|
||||
Version: jscontact.JSContactVersion_1_0,
|
||||
Created: created,
|
||||
Language: "fr-BE",
|
||||
Members: map[string]bool{
|
||||
"314815dd-81c8-4640-aace-6dc83121616d": true,
|
||||
"c528b277-d8cb-45f2-b7df-1aa3df817463": true,
|
||||
"81dea240-c0a4-4929-82e7-79e713a8bbe4": true,
|
||||
},
|
||||
ProdId: "OpenCloud Groupware 1.0",
|
||||
RelatedTo: map[string]jscontact.Relation{
|
||||
"urn:uid:ca9d2a62-e068-43b6-a470-46506976d505": {
|
||||
Type: jscontact.RelationType,
|
||||
Relation: map[jscontact.Relationship]bool{
|
||||
jscontact.RelationContact: true,
|
||||
},
|
||||
},
|
||||
"urn:uid:72183ec2-b218-4983-9c89-ff117eeb7c5e": {
|
||||
Relation: map[jscontact.Relationship]bool{
|
||||
jscontact.RelationEmergency: true,
|
||||
jscontact.RelationSpouse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Uid: "1091f2bb-6ae6-4074-bb64-df74071d7033",
|
||||
Updated: updated,
|
||||
Name: &jscontact.Name{
|
||||
Type: jscontact.NameType,
|
||||
Components: []jscontact.NameComponent{
|
||||
{Type: jscontact.NameComponentType, Value: "OpenCloud", Kind: jscontact.NameComponentKindSurname},
|
||||
{Value: " ", Kind: jscontact.NameComponentKindSeparator},
|
||||
{Value: "Team", Kind: jscontact.NameComponentKindSurname2},
|
||||
},
|
||||
IsOrdered: true,
|
||||
DefaultSeparator: ", ",
|
||||
SortAs: map[string]string{
|
||||
string(jscontact.NameComponentKindSurname): "OpenCloud Team",
|
||||
},
|
||||
Full: "OpenCloud Team",
|
||||
},
|
||||
Nicknames: map[string]jscontact.Nickname{
|
||||
"a": {
|
||||
Type: jscontact.NicknameType,
|
||||
Name: "The Team",
|
||||
Contexts: map[jscontact.NicknameContext]bool{
|
||||
jscontact.NicknameContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
},
|
||||
},
|
||||
Organizations: map[string]jscontact.Organization{
|
||||
"o": {
|
||||
Type: jscontact.OrganizationType,
|
||||
Name: "OpenCloud GmbH",
|
||||
Units: []jscontact.OrgUnit{
|
||||
{Type: jscontact.OrgUnitType, Name: "Marketing", SortAs: "marketing"},
|
||||
{Type: jscontact.OrgUnitType, Name: "Sales"},
|
||||
{Name: "Operations", SortAs: "ops"},
|
||||
},
|
||||
SortAs: "opencloud",
|
||||
Contexts: map[jscontact.OrganizationContext]bool{
|
||||
jscontact.OrganizationContextWork: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
SpeakToAs: &jscontact.SpeakToAs{
|
||||
Type: jscontact.SpeakToAsType,
|
||||
GrammaticalGender: jscontact.GrammaticalGenderInanimate,
|
||||
Pronouns: map[string]jscontact.Pronouns{
|
||||
"p": {
|
||||
Type: jscontact.PronounsType,
|
||||
Pronouns: "it",
|
||||
Contexts: map[jscontact.PronounsContext]bool{
|
||||
jscontact.PronounsContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
Titles: map[string]jscontact.Title{
|
||||
"t": {
|
||||
Type: jscontact.TitleType,
|
||||
Name: "The",
|
||||
Kind: jscontact.TitleKindTitle,
|
||||
OrganizationId: "o",
|
||||
},
|
||||
},
|
||||
Emails: map[string]jscontact.EmailAddress{
|
||||
"e": {
|
||||
Type: jscontact.EmailAddressType,
|
||||
Address: "info@opencloud.eu.example.com",
|
||||
Contexts: map[jscontact.EmailAddressContext]bool{
|
||||
jscontact.EmailAddressContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "work",
|
||||
},
|
||||
},
|
||||
OnlineServices: map[string]jscontact.OnlineService{
|
||||
"s": {
|
||||
Type: jscontact.OnlineServiceType,
|
||||
Service: "The Misinformation Game",
|
||||
Uri: "https://misinfogame.com/91886aa0-3586-4ade-b9bb-ec031464a251",
|
||||
User: "opencloudeu",
|
||||
Contexts: map[jscontact.OnlineServiceContext]bool{
|
||||
jscontact.OnlineServiceContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "imaginary",
|
||||
},
|
||||
},
|
||||
Phones: map[string]jscontact.Phone{
|
||||
"p": {
|
||||
Type: jscontact.PhoneType,
|
||||
Number: "+1-804-222-1111",
|
||||
Features: map[jscontact.PhoneFeature]bool{
|
||||
jscontact.PhoneFeatureVoice: true,
|
||||
jscontact.PhoneFeatureText: true,
|
||||
},
|
||||
Contexts: map[jscontact.PhoneContext]bool{
|
||||
jscontact.PhoneContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "imaginary",
|
||||
},
|
||||
},
|
||||
PreferredLanguages: map[string]jscontact.LanguagePref{
|
||||
"wa": {
|
||||
Type: jscontact.LanguagePrefType,
|
||||
Language: "wa-BE",
|
||||
Contexts: map[jscontact.LanguagePrefContext]bool{
|
||||
jscontact.LanguagePrefContextPrivate: true,
|
||||
},
|
||||
Pref: 1,
|
||||
},
|
||||
"de": {
|
||||
Language: "de-DE",
|
||||
Contexts: map[jscontact.LanguagePrefContext]bool{
|
||||
jscontact.LanguagePrefContextWork: true,
|
||||
},
|
||||
Pref: 2,
|
||||
},
|
||||
},
|
||||
Calendars: map[string]jscontact.Calendar{
|
||||
"c": {
|
||||
Type: jscontact.CalendarType,
|
||||
Kind: jscontact.CalendarKindCalendar,
|
||||
Uri: "https://opencloud.eu/calendars/521b032b-a2b3-4540-81b9-3f6bccacaab2",
|
||||
MediaType: "application/jscontact+json",
|
||||
Contexts: map[jscontact.CalendarContext]bool{
|
||||
jscontact.CalendarContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "work",
|
||||
},
|
||||
},
|
||||
SchedulingAddresses: map[string]jscontact.SchedulingAddress{
|
||||
"s": {
|
||||
Type: jscontact.SchedulingAddressType,
|
||||
Uri: "mailto:scheduling@opencloud.eu.example.com",
|
||||
Contexts: map[jscontact.SchedulingAddressContext]bool{
|
||||
jscontact.SchedulingAddressContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "work",
|
||||
},
|
||||
},
|
||||
Addresses: map[string]jscontact.Address{
|
||||
"k26": {
|
||||
Type: jscontact.AddressType,
|
||||
Components: []jscontact.AddressComponent{
|
||||
{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindBlock, Value: "2-7"},
|
||||
{Kind: jscontact.AddressComponentKindSeparator, Value: "-"},
|
||||
{Kind: jscontact.AddressComponentKindNumber, Value: "2"},
|
||||
{Kind: jscontact.AddressComponentKindSeparator, Value: " "},
|
||||
{Kind: jscontact.AddressComponentKindDistrict, Value: "Marunouchi"},
|
||||
{Kind: jscontact.AddressComponentKindLocality, Value: "Chiyoda-ku"},
|
||||
{Kind: jscontact.AddressComponentKindRegion, Value: "Tokyo"},
|
||||
{Kind: jscontact.AddressComponentKindSeparator, Value: " "},
|
||||
{Kind: jscontact.AddressComponentKindPostcode, Value: "100-8994"},
|
||||
},
|
||||
IsOrdered: true,
|
||||
DefaultSeparator: ", ",
|
||||
Full: "2-7-2 Marunouchi, Chiyoda-ku, Tokyo 100-8994",
|
||||
CountryCode: "JP",
|
||||
Coordinates: "geo:35.6796373,139.7616907",
|
||||
TimeZone: "JST",
|
||||
Contexts: map[jscontact.AddressContext]bool{
|
||||
jscontact.AddressContextDelivery: true,
|
||||
jscontact.AddressContextWork: true,
|
||||
},
|
||||
Pref: 2,
|
||||
},
|
||||
},
|
||||
CryptoKeys: map[string]jscontact.CryptoKey{
|
||||
"k1": {
|
||||
Type: jscontact.CryptoKeyType,
|
||||
Uri: "https://opencloud.eu.example.com/keys/d550f57c-582c-43cc-8d94-822bded9ab36",
|
||||
MediaType: "application/pgp-keys",
|
||||
Contexts: map[jscontact.CryptoKeyContext]bool{
|
||||
jscontact.CryptoKeyContextWork: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "keys",
|
||||
},
|
||||
},
|
||||
Directories: map[string]jscontact.Directory{
|
||||
"d1": {
|
||||
Type: jscontact.DirectoryType,
|
||||
Kind: jscontact.DirectoryKindEntry,
|
||||
Uri: "https://opencloud.eu.example.com/addressbook/8c2f0363-af0a-4d16-a9d5-8a9cd885d722",
|
||||
ListAs: 1,
|
||||
},
|
||||
},
|
||||
Links: map[string]jscontact.Link{
|
||||
"r1": {
|
||||
Type: jscontact.LinkType,
|
||||
Kind: jscontact.LinkKindContact,
|
||||
Contexts: map[jscontact.LinkContext]bool{
|
||||
jscontact.LinkContextWork: true,
|
||||
},
|
||||
Uri: "mailto:contact@opencloud.eu.example.com",
|
||||
},
|
||||
},
|
||||
Media: map[string]jscontact.Media{
|
||||
"m": {
|
||||
Type: jscontact.MediaType,
|
||||
Kind: jscontact.MediaKindLogo,
|
||||
Uri: "https://opencloud.eu.example.com/opencloud.svg",
|
||||
MediaType: "image/svg+xml",
|
||||
Contexts: map[jscontact.MediaContext]bool{
|
||||
jscontact.MediaContextWork: true,
|
||||
},
|
||||
Pref: 123,
|
||||
Label: "svg",
|
||||
BlobId: "53feefbabeb146fcbe3e59e91462fa5f",
|
||||
},
|
||||
},
|
||||
Anniversaries: map[string]jscontact.Anniversary{
|
||||
"birth": {
|
||||
Type: jscontact.AnniversaryType,
|
||||
Kind: jscontact.AnniversaryKindBirth,
|
||||
Date: &jscontact.PartialDate{
|
||||
Type: jscontact.PartialDateType,
|
||||
Year: 2025,
|
||||
Month: 9,
|
||||
Day: 26,
|
||||
CalendarScale: "iso8601",
|
||||
},
|
||||
},
|
||||
},
|
||||
Keywords: map[string]bool{
|
||||
"imaginary": true,
|
||||
"test": true,
|
||||
},
|
||||
Notes: map[string]jscontact.Note{
|
||||
"n1": {
|
||||
Type: jscontact.NoteType,
|
||||
Note: "This is a note.",
|
||||
Created: created,
|
||||
Author: &jscontact.Author{
|
||||
Type: jscontact.AuthorType,
|
||||
Name: "Test Data",
|
||||
Uri: "https://isbn.example.com/a461f292-6bf1-470e-b08d-f6b4b0223fe3",
|
||||
},
|
||||
},
|
||||
},
|
||||
PersonalInfo: map[string]jscontact.PersonalInfo{
|
||||
"p1": {
|
||||
Type: jscontact.PersonalInfoType,
|
||||
Kind: jscontact.PersonalInfoKindExpertise,
|
||||
Value: "Clouds",
|
||||
Level: jscontact.PersonalInfoLevelHigh,
|
||||
ListAs: 1,
|
||||
Label: "experts",
|
||||
},
|
||||
},
|
||||
Localizations: map[string]jscontact.PatchObject{
|
||||
"fr": {
|
||||
"personalInfo": map[string]any{
|
||||
"value": "Nuages",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SessionEventListener interface {
|
||||
OnSessionOutdated(session *Session, newSessionState SessionState)
|
||||
}
|
||||
|
||||
// Cached user related information
|
||||
//
|
||||
// This information is typically retrieved once (or at least for a certain period of time) from the
|
||||
// JMAP well-known endpoint of Stalwart and then kept in cache to avoid the performance cost of
|
||||
// retrieving it over and over again.
|
||||
//
|
||||
// This is really only needed due to the Graph API limitations, since ideally, the account ID should
|
||||
// be passed as a request parameter by the UI, in order to support a user having multiple accounts.
|
||||
//
|
||||
// Keeping track of the JMAP URL might be useful though, in case of Stalwart sharding strategies making
|
||||
// use of that, by providing different URLs for JMAP on a per-user basis, and that is not something
|
||||
// we would want to query before every single JMAP request. On the other hand, that then also creates
|
||||
// a risk of going out-of-sync, e.g. if a node is down and the user is reassigned to a different node.
|
||||
// There might be webhooks to subscribe to in Stalwart to be notified of such situations, in which case
|
||||
// the Session needs to be removed from the cache.
|
||||
//
|
||||
// The Username is only here for convenience, it could just as well be passed as a separate parameter
|
||||
// instead of being part of the Session, since the username is always part of the request (typically in
|
||||
// the authentication token payload.)
|
||||
type Session struct {
|
||||
// The name of the user to use to authenticate against Stalwart
|
||||
Username string
|
||||
|
||||
// The base URL to use for JMAP operations towards Stalwart
|
||||
JmapUrl url.URL
|
||||
// An identifier of the JmapUrl to use in metrics and tracing
|
||||
JmapEndpoint string
|
||||
|
||||
// The upload URL template
|
||||
UploadUrlTemplate string
|
||||
// An identifier of the UploadUrlTemplate to use in metrics and tracing
|
||||
UploadEndpoint string
|
||||
|
||||
// The upload URL template
|
||||
DownloadUrlTemplate string
|
||||
// An identifier of the DownloadUrlTemplate to use in metrics and tracing
|
||||
DownloadEndpoint string
|
||||
|
||||
WebsocketUrl *url.URL
|
||||
SupportsWebsocketPush bool
|
||||
WebsocketEndpoint string
|
||||
|
||||
SessionResponse
|
||||
}
|
||||
|
||||
var _ ResultMetadata = Session{}
|
||||
|
||||
func (s Session) GetSessionState() SessionState { return s.State }
|
||||
func (s Session) GetState() State { return EmptyState }
|
||||
func (s Session) GetLanguage() Language { return NoLanguage }
|
||||
func (s Session) GetDurations() []time.Duration { return nil }
|
||||
|
||||
var (
|
||||
invalidSessionResponseErrorMissingUsername = jmapError(errors.New("JMAP session response does not provide a username"), JmapErrorInvalidSessionResponse)
|
||||
invalidSessionResponseErrorMissingApiUrl = jmapError(errors.New("JMAP session response does not provide an API URL"), JmapErrorInvalidSessionResponse)
|
||||
invalidSessionResponseErrorInvalidApiUrl = jmapError(errors.New("JMAP session response provides an invalid API URL"), JmapErrorInvalidSessionResponse)
|
||||
invalidSessionResponseErrorMissingUploadUrl = jmapError(errors.New("JMAP session response does not provide an upload URL"), JmapErrorInvalidSessionResponse)
|
||||
invalidSessionResponseErrorMissingDownloadUrl = jmapError(errors.New("JMAP session response does not provide a download URL"), JmapErrorInvalidSessionResponse)
|
||||
invalidSessionResponseErrorInvalidWebsocketUrl = jmapError(errors.New("JMAP session response provides an invalid Websocket URL"), JmapErrorInvalidSessionResponse)
|
||||
)
|
||||
|
||||
// Create a new Session from a SessionResponse.
|
||||
func newSession(sessionResponse SessionResponse) (Session, Error) {
|
||||
username := sessionResponse.Username
|
||||
if username == "" {
|
||||
return Session{}, invalidSessionResponseErrorMissingUsername
|
||||
}
|
||||
apiStr := sessionResponse.ApiUrl
|
||||
if apiStr == "" {
|
||||
return Session{}, invalidSessionResponseErrorMissingApiUrl
|
||||
}
|
||||
apiUrl, err := url.Parse(apiStr)
|
||||
if err != nil {
|
||||
return Session{}, invalidSessionResponseErrorInvalidApiUrl
|
||||
}
|
||||
apiEndpoint := endpointOf(apiUrl)
|
||||
|
||||
uploadUrl := sessionResponse.UploadUrl
|
||||
if uploadUrl == "" {
|
||||
return Session{}, invalidSessionResponseErrorMissingUploadUrl
|
||||
}
|
||||
uploadEndpoint := toEndpoint(uploadUrl)
|
||||
|
||||
downloadUrl := sessionResponse.DownloadUrl
|
||||
if downloadUrl == "" {
|
||||
return Session{}, invalidSessionResponseErrorMissingDownloadUrl
|
||||
}
|
||||
downloadEndpoint := toEndpoint(downloadUrl)
|
||||
|
||||
var websocketUrl *url.URL = nil
|
||||
websocketEndpoint := ""
|
||||
supportsWebsocketPush := false
|
||||
websocketUrlStr := sessionResponse.Capabilities.Websocket.Url
|
||||
if websocketUrlStr != "" {
|
||||
websocketUrl, err = url.Parse(websocketUrlStr)
|
||||
if err != nil {
|
||||
return Session{}, invalidSessionResponseErrorInvalidWebsocketUrl
|
||||
}
|
||||
supportsWebsocketPush = sessionResponse.Capabilities.Websocket.SupportsPush
|
||||
websocketEndpoint = endpointOf(websocketUrl)
|
||||
}
|
||||
|
||||
return Session{
|
||||
Username: username,
|
||||
JmapUrl: *apiUrl,
|
||||
JmapEndpoint: apiEndpoint,
|
||||
UploadUrlTemplate: uploadUrl,
|
||||
UploadEndpoint: uploadEndpoint,
|
||||
DownloadUrlTemplate: downloadUrl,
|
||||
DownloadEndpoint: downloadEndpoint,
|
||||
WebsocketUrl: websocketUrl,
|
||||
SupportsWebsocketPush: supportsWebsocketPush,
|
||||
WebsocketEndpoint: websocketEndpoint,
|
||||
SessionResponse: sessionResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func endpointOf(u *url.URL) string {
|
||||
if u != nil {
|
||||
return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func toEndpoint(str string) string {
|
||||
u, err := url.Parse(str)
|
||||
if err == nil {
|
||||
return endpointOf(u)
|
||||
} else {
|
||||
return str
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func get[T Foo, GETREQ GetCommand[T], GETRESP GetResponse[T], ID any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
getCommandFactory func(AccountId, []ID) GETREQ,
|
||||
_ GETRESP,
|
||||
mapper func(GETRESP) RESP,
|
||||
accountId AccountId, ids []ID, ctx Context) (Result[RESP], Error) {
|
||||
ctx = ctx.WithLogger(client.logger(name, ctx))
|
||||
|
||||
get := getCommandFactory(accountId, ids)
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocation(get, "0"))
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
var response GETRESP
|
||||
err = retrieveGet(ctx, body, get, "0", &response)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
return mapper(response), response.GetState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func getAN[T Foo, GETREQ GetCommand[T], GETRESP GetResponse[T], ID any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
getCommandFactory func(AccountId, []ID) GETREQ,
|
||||
resp GETRESP,
|
||||
respMapper func(map[AccountId][]T) RESP,
|
||||
accountIds []AccountId, ids []ID, ctx Context) (Result[RESP], Error) {
|
||||
return getN(client, name, objType, getCommandFactory, resp,
|
||||
func(r GETRESP) []T { return r.GetList() },
|
||||
respMapper,
|
||||
accountIds, ids,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func getN[T Foo, ITEM any, GETREQ GetCommand[T], GETRESP GetResponse[T], ID any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
getCommandFactory func(AccountId, []ID) GETREQ,
|
||||
_ GETRESP,
|
||||
itemMapper func(GETRESP) ITEM,
|
||||
respMapper func(map[AccountId]ITEM) RESP,
|
||||
accountIds []AccountId, ids []ID, ctx Context) (Result[RESP], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
|
||||
invocations := make([]Invocation, len(uniqueAccountIds))
|
||||
var c Command
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
get := getCommandFactory(accountId, ids)
|
||||
c = get.GetCommand()
|
||||
invocations[i] = invocation(get, mcid(accountId, "0"))
|
||||
}
|
||||
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
result := map[AccountId]ITEM{}
|
||||
responses := map[AccountId]GETRESP{}
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
var resp GETRESP
|
||||
err = retrieveResponseMatchParameters(ctx, body, c, mcid(accountId, "0"), &resp)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
responses[accountId] = resp
|
||||
result[accountId] = itemMapper(resp)
|
||||
}
|
||||
return respMapper(result), squashStateFunc(responses, func(r GETRESP) State { return r.GetState() }), nil
|
||||
})
|
||||
}
|
||||
|
||||
func create[T Foo, C any, SETREQ SetCommand[T], GETREQ GetCommand[T], SETRESP SetResponse[T], GETRESP GetResponse[T]]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
setCommandFactory func(AccountId, map[string]C) SETREQ,
|
||||
getCommandFactory func(AccountId, string) GETREQ,
|
||||
createdMapper func(SETRESP) map[string]*T,
|
||||
listMapper func(GETRESP) []T,
|
||||
accountId AccountId, create C,
|
||||
ctx Context) (Result[*T], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
createMap := map[string]C{"c": create}
|
||||
get := getCommandFactory(accountId, "#c")
|
||||
set := setCommandFactory(accountId, createMap)
|
||||
cmd, err := client.request(ctx, objType.Namespaces,
|
||||
invocation(set, "0"),
|
||||
invocation(get, "1"),
|
||||
)
|
||||
if err != nil {
|
||||
return ZeroResultV[*T](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (*T, State, Error) {
|
||||
var setResponse SETRESP
|
||||
err = retrieveSet(ctx, body, set, "0", &setResponse)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
notCreatedMap := setResponse.GetNotCreated()
|
||||
setErr, notok := notCreatedMap["c"]
|
||||
if notok {
|
||||
logger.Error().Msgf("%T.NotCreated returned an error %v", setResponse, setErr)
|
||||
return nil, "", setErrorError(setErr, set.GetObjectType())
|
||||
}
|
||||
|
||||
createdMap := createdMapper(setResponse)
|
||||
if created, ok := createdMap["c"]; !ok || created == nil {
|
||||
berr := fmt.Errorf("failed to find %s in %s response", set.GetObjectType(), set.GetCommand())
|
||||
logger.Error().Err(berr)
|
||||
return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
|
||||
var getResponse GETRESP
|
||||
err = retrieveGet(ctx, body, get, "1", &getResponse)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
list := listMapper(getResponse)
|
||||
|
||||
if len(list) < 1 {
|
||||
berr := fmt.Errorf("failed to find %s in %s response", get.GetObjectType(), get.GetCommand())
|
||||
logger.Error().Err(berr)
|
||||
return nil, "", jmapError(berr, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
|
||||
return &list[0], setResponse.GetNewState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func destroy[T Foo, REQ SetCommand[T], RESP SetResponse[T]](client *Client, name string, objType ObjectType, //NOSONAR
|
||||
setCommandFactory func(AccountId, []string) REQ, _ RESP,
|
||||
accountId AccountId, destroy []string, ctx Context) (Result[map[string]SetError], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
set := setCommandFactory(accountId, destroy)
|
||||
cmd, err := client.request(ctx, objType.Namespaces,
|
||||
invocation(set, "0"),
|
||||
)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[string]SetError](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (map[string]SetError, State, Error) {
|
||||
var setResponse RESP
|
||||
err = retrieveSet(ctx, body, set, "0", &setResponse)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return setResponse.GetNotDestroyed(), setResponse.GetNewState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func changesA[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESRESP ChangesResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
changesCommandFactory func() CHANGESREQ,
|
||||
changesResp CHANGESRESP,
|
||||
_ GETRESP,
|
||||
getCommandFactory func(string, string) GETREQ,
|
||||
respMapper func(State, State, bool, []T, []T, []string) RESP,
|
||||
ctx Context) (Result[RESP], Error) {
|
||||
return changes(client, name, objType, changesCommandFactory, changesResp, getCommandFactory,
|
||||
func(r GETRESP) []T { return r.GetList() },
|
||||
respMapper,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
func changes[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESRESP ChangesResponse[T], GETRESP GetResponse[T], ITEM any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
changesCommandFactory func() CHANGESREQ,
|
||||
_ CHANGESRESP,
|
||||
getCommandFactory func(string, string) GETREQ,
|
||||
getMapper func(GETRESP) []ITEM,
|
||||
respMapper func(State, State, bool, []ITEM, []ITEM, []string) RESP,
|
||||
ctx Context) (Result[RESP], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
|
||||
changes := changesCommandFactory()
|
||||
getCreated := getCommandFactory("/created", "0") //NOSONAR
|
||||
getUpdated := getCommandFactory("/updated", "0") //NOSONAR
|
||||
|
||||
cmd, err := client.request(ctx.WithLogger(logger), objType.Namespaces,
|
||||
invocation(changes, "0"),
|
||||
invocation(getCreated, "1"),
|
||||
invocation(getUpdated, "2"),
|
||||
)
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
var changesResponse CHANGESRESP
|
||||
err = retrieveChanges(ctx, body, changes, "0", &changesResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
var createdResponse GETRESP
|
||||
err = retrieveGet(ctx, body, getCreated, "1", &createdResponse)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Send()
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
var updatedResponse GETRESP
|
||||
err = retrieveGet(ctx, body, getUpdated, "2", &updatedResponse)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Send()
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
created := getMapper(createdResponse)
|
||||
updated := getMapper(updatedResponse)
|
||||
|
||||
result := respMapper(changesResponse.GetOldState(), changesResponse.GetNewState(), changesResponse.GetHasMoreChanges(), created, updated, changesResponse.GetDestroyed())
|
||||
|
||||
return result, changesResponse.GetNewState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func changesN[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESRESP ChangesResponse[T], GETRESP GetResponse[T], ITEM any, CHANGESITEM any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
accountIds []AccountId, sinceStateMap map[AccountId]State,
|
||||
changesCommandFactory func(AccountId, State) CHANGESREQ,
|
||||
_ CHANGESRESP,
|
||||
getCommandFactory func(AccountId, string, string) GETREQ,
|
||||
getMapper func(GETRESP) []ITEM,
|
||||
changesItemMapper func(State, State, bool, []ITEM, []ITEM, []string) CHANGESITEM,
|
||||
respMapper func(map[AccountId]CHANGESITEM) RESP,
|
||||
stateMapper func(GETRESP) State,
|
||||
ctx Context) (Result[RESP], Error) {
|
||||
logger := client.loggerParams(name, ctx, func(z zerolog.Context) zerolog.Context {
|
||||
sinceStateLogDict := zerolog.Dict()
|
||||
for k, v := range sinceStateMap {
|
||||
sinceStateLogDict.Str(log.SafeString(string(k)), log.SafeString(string(v)))
|
||||
}
|
||||
return z.Dict(logSinceState, sinceStateLogDict)
|
||||
})
|
||||
|
||||
uniqueAccountIds := structs.Uniq(accountIds)
|
||||
n := len(uniqueAccountIds)
|
||||
if n < 1 {
|
||||
return ZeroResultV[RESP](), nil
|
||||
}
|
||||
|
||||
invocations := make([]Invocation, n*3)
|
||||
var ch CHANGESREQ
|
||||
var gc GETREQ
|
||||
var gu GETREQ
|
||||
for i, accountId := range uniqueAccountIds {
|
||||
sinceState, ok := sinceStateMap[accountId]
|
||||
if !ok {
|
||||
sinceState = ""
|
||||
}
|
||||
changes := changesCommandFactory(accountId, sinceState)
|
||||
ref := mcid(accountId, "0")
|
||||
|
||||
getCreated := getCommandFactory(accountId, "/created", ref)
|
||||
getUpdated := getCommandFactory(accountId, "/updated", ref)
|
||||
|
||||
invocations[i*3+0] = invocation(changes, ref)
|
||||
invocations[i*3+1] = invocation(getCreated, mcid(accountId, "1"))
|
||||
invocations[i*3+2] = invocation(getUpdated, mcid(accountId, "2"))
|
||||
|
||||
ch = changes
|
||||
gc = getCreated
|
||||
gu = getUpdated
|
||||
}
|
||||
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
changesItemByAccount := make(map[AccountId]CHANGESITEM, n)
|
||||
stateByAccountId := make(map[AccountId]State, n)
|
||||
for _, accountId := range uniqueAccountIds {
|
||||
var changesResponse CHANGESRESP
|
||||
err = retrieveChanges(ctx, body, ch, mcid(accountId, "0"), &changesResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
var createdResponse GETRESP
|
||||
err = retrieveGet(ctx, body, gc, mcid(accountId, "1"), &createdResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
var updatedResponse GETRESP
|
||||
err = retrieveGet(ctx, body, gu, mcid(accountId, "2"), &updatedResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
created := getMapper(createdResponse)
|
||||
updated := getMapper(updatedResponse)
|
||||
changesItemByAccount[accountId] = changesItemMapper(changesResponse.GetOldState(), changesResponse.GetNewState(), changesResponse.GetHasMoreChanges(), created, updated, changesResponse.GetDestroyed())
|
||||
stateByAccountId[accountId] = stateMapper(createdResponse)
|
||||
}
|
||||
return respMapper(changesItemByAccount), squashState(stateByAccountId), nil
|
||||
})
|
||||
}
|
||||
|
||||
func updates[T Foo, CHANGESREQ ChangesCommand[T], GETREQ GetCommand[T], CHANGESRESP ChangesResponse[T], GETRESP GetResponse[T], ITEM any, RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
changesCommandFactory func() CHANGESREQ,
|
||||
_ CHANGESRESP,
|
||||
getCommandFactory func(string, string) GETREQ,
|
||||
getMapper func(GETRESP) []ITEM,
|
||||
respMapper func(State, State, bool, []ITEM) RESP,
|
||||
ctx Context) (Result[RESP], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
changes := changesCommandFactory()
|
||||
getUpdated := getCommandFactory("/updated", "0") //NOSONAR
|
||||
cmd, err := client.request(ctx, objType.Namespaces,
|
||||
invocation(changes, "0"),
|
||||
invocation(getUpdated, "1"),
|
||||
)
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
var changesResponse CHANGESRESP
|
||||
err = retrieveChanges(ctx, body, changes, "0", &changesResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
var updatedResponse GETRESP
|
||||
err = retrieveGet(ctx, body, getUpdated, "1", &updatedResponse)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Send()
|
||||
var zero RESP
|
||||
return zero, "", err
|
||||
}
|
||||
|
||||
updated := getMapper(updatedResponse)
|
||||
result := respMapper(changesResponse.GetOldState(), changesResponse.GetNewState(), changesResponse.GetHasMoreChanges(), updated)
|
||||
|
||||
return result, changesResponse.GetNewState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func update[T Foo, CHANGES Change[T], SET SetCommand[T], GET GetCommand[T], RESP any, SETRESP SetResponse[T], GETRESP GetResponse[T]]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
setCommandFactory func(map[string]PatchObject) SET,
|
||||
getCommandFactory func(string) GET,
|
||||
notUpdatedExtractor func(SETRESP) map[string]SetError,
|
||||
objExtractor func(GETRESP) RESP,
|
||||
id string, changes CHANGES,
|
||||
ctx Context) (Result[RESP], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
var update SET
|
||||
{
|
||||
patch, err := changes.AsPatch()
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), jmapError(err, JmapErrorPatchObjectSerialization)
|
||||
}
|
||||
update = setCommandFactory(map[string]PatchObject{id: patch})
|
||||
}
|
||||
get := getCommandFactory(id)
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocation(update, "0"), invocation(get, "1"))
|
||||
if err != nil {
|
||||
return ZeroResultV[RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (RESP, State, Error) {
|
||||
var setResponse SETRESP
|
||||
err = retrieveSet(ctx, body, update, "0", &setResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, setResponse.GetNewState(), err
|
||||
}
|
||||
nc := notUpdatedExtractor(setResponse)
|
||||
setErr, notok := nc[id]
|
||||
if notok {
|
||||
logger.Error().Msgf("%T.NotUpdated returned an error %v", setResponse, setErr)
|
||||
var zero RESP
|
||||
return zero, "", setErrorError(setErr, update.GetObjectType())
|
||||
}
|
||||
var getResponse GETRESP
|
||||
err = retrieveGet(ctx, body, get, "1", &getResponse)
|
||||
if err != nil {
|
||||
var zero RESP
|
||||
return zero, setResponse.GetNewState(), err
|
||||
}
|
||||
return objExtractor(getResponse), setResponse.GetNewState(), nil
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
func query[T Foo, FILTER any, SORT any, QUERY QueryCommand[T], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP SearchResults[T]]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
defaultSortBy []SORT,
|
||||
queryCommandFactory func(filter FILTER, sortBy []SORT) QUERY,
|
||||
getCommandFactory func(cmd Command, path string, rof string) GET,
|
||||
respMapper0 func(query QUERYRESP) *RESP,
|
||||
respMapper func(query QUERYRESP, get GETRESP) *RESP,
|
||||
filter FILTER, sortBy []SORT,
|
||||
ctx Context) (Result[*RESP], Error) {
|
||||
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
if sortBy == nil {
|
||||
sortBy = defaultSortBy
|
||||
}
|
||||
|
||||
query := queryCommandFactory(filter, sortBy)
|
||||
var get GET
|
||||
limit := query.GetLimit()
|
||||
if limit != nil && *limit == 0 {
|
||||
query.SetLimit(UintPtrOne)
|
||||
} else {
|
||||
get = getCommandFactory(query.GetCommand(), "/ids/*", "0")
|
||||
}
|
||||
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocation(query, "0"), invocation(get, "1"))
|
||||
if err != nil {
|
||||
return ZeroResult[*RESP](), err
|
||||
}
|
||||
|
||||
return command(client, ctx, cmd, func(body *Response) (*RESP, State, Error) {
|
||||
var queryResponse QUERYRESP
|
||||
err = retrieveQuery(ctx, body, query, "0", &queryResponse)
|
||||
if err != nil {
|
||||
return nil, EmptyState, err
|
||||
}
|
||||
if limit == nil && *limit == 0 {
|
||||
result := respMapper0(queryResponse)
|
||||
if query.GetAnchor() != "" && (*result).GetPosition() != nil && *(*result).GetPosition() == 0 {
|
||||
(*result).SetPosition(nil)
|
||||
}
|
||||
return result, queryResponse.GetQueryState(), nil
|
||||
} else {
|
||||
var getResponse GETRESP
|
||||
err = retrieveGet(ctx, body, get, "1", &getResponse)
|
||||
if err != nil {
|
||||
return nil, EmptyState, err
|
||||
}
|
||||
return respMapper(queryResponse, getResponse), queryResponse.GetQueryState(), nil
|
||||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO enable self-referencing generics parameter when upgrading to Go 1.26
|
||||
func queryN[T Foo, FILTER any, SORT any, QUERY QueryCommand[T /*, QUERY*/], GET GetCommand[T], QUERYRESP QueryResponse[T], GETRESP GetResponse[T], RESP any]( //NOSONAR
|
||||
client *Client, name string, objType ObjectType,
|
||||
defaultSortBy []SORT,
|
||||
queryCommandFactory func(accountId AccountId, queryParams QueryParams, limit *uint, filter FILTER, sortBy []SORT) QUERY,
|
||||
getCommandFactory func(accountId AccountId, cmd Command, path string, rof string) GET,
|
||||
respMapper0 func(query QUERYRESP, queryParams QueryParams, limit *uint) *RESP,
|
||||
respMapper func(query QUERYRESP, get GETRESP, queryParams QueryParams, limit *uint) *RESP,
|
||||
accountIds map[AccountId]QueryParams,
|
||||
limit *uint, filter FILTER, sortBy []SORT,
|
||||
ctx Context) (Result[map[AccountId]*RESP], Error) {
|
||||
logger := client.logger(name, ctx)
|
||||
ctx = ctx.WithLogger(logger)
|
||||
|
||||
if sortBy == nil {
|
||||
sortBy = defaultSortBy
|
||||
}
|
||||
|
||||
invocations := make([]Invocation, len(accountIds)*2)
|
||||
var g GET
|
||||
var q QueryCommand[T] // TODO change type to QUERY when upgrading to Go 1.26
|
||||
{
|
||||
i := 0
|
||||
for accountId, queryParams := range accountIds {
|
||||
var query QueryCommand[T] = queryCommandFactory(accountId, queryParams, limit, filter, sortBy) // TODO change type to QUERY when upgrading to Go 1.26
|
||||
q = query
|
||||
invocations[i*2+0] = invocation(query, mcid(accountId, "0"))
|
||||
if limit != nil && *limit == 0 {
|
||||
query = query.WithLimit(UintPtrOne)
|
||||
invocations[i*2+1] = skipInvocation()
|
||||
} else {
|
||||
get := getCommandFactory(accountId, query.GetCommand(), "/ids/*", mcid(accountId, "0"))
|
||||
invocations[i*2+1] = invocation(get, mcid(accountId, "1"))
|
||||
g = get
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
cmd, err := client.request(ctx, objType.Namespaces, invocations...)
|
||||
if err != nil {
|
||||
return ZeroResultV[map[AccountId]*RESP](), err
|
||||
}
|
||||
|
||||
return command(client, Operation(name), ctx, cmd, func(body *Response) (map[AccountId]*RESP, State, Error) {
|
||||
resp := map[AccountId]*RESP{}
|
||||
stateByAccountId := map[AccountId]State{}
|
||||
for accountId, queryParams := range accountIds {
|
||||
var queryResponse QUERYRESP
|
||||
err = retrieveQuery(ctx, body, q, mcid(accountId, "0"), &queryResponse)
|
||||
if err != nil {
|
||||
return nil, EmptyState, err
|
||||
}
|
||||
if limit != nil && *limit == 0 {
|
||||
resp[accountId] = respMapper0(queryResponse, queryParams, limit)
|
||||
stateByAccountId[accountId] = queryResponse.GetQueryState()
|
||||
} else {
|
||||
var getResponse GETRESP
|
||||
err = retrieveGet(ctx, body, g, mcid(accountId, "1"), &getResponse)
|
||||
if err != nil {
|
||||
return nil, EmptyState, err
|
||||
}
|
||||
if len(getResponse.GetNotFound()) > 0 {
|
||||
// TODO what to do when there are not-found calendarevents here? potentially nothing, they could have been deleted between query and get?
|
||||
}
|
||||
resp[accountId] = respMapper(queryResponse, getResponse, queryParams, limit)
|
||||
stateByAccountId[accountId] = getResponse.GetState()
|
||||
}
|
||||
}
|
||||
return resp, squashState(stateByAccountId), nil
|
||||
})
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
|
||||
)
|
||||
|
||||
func single[S any](s S) []S {
|
||||
return []S{s}
|
||||
}
|
||||
|
||||
var UintPtrOne *uint = uintPtr(1)
|
||||
var UintPtrZero *uint = uintPtr(0)
|
||||
|
||||
type eventListeners[T any] struct {
|
||||
listeners []T
|
||||
m sync.Mutex // TODO get rid of the mutex to avoid lock contention, use a lock-free alternative, or get rid of it altogether
|
||||
}
|
||||
|
||||
func (e *eventListeners[T]) add(listener T) {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
e.listeners = append(e.listeners, listener)
|
||||
}
|
||||
|
||||
func (e *eventListeners[T]) signal(signal func(T)) {
|
||||
e.m.Lock()
|
||||
defer e.m.Unlock()
|
||||
for _, listener := range e.listeners {
|
||||
signal(listener)
|
||||
}
|
||||
}
|
||||
|
||||
func newEventListeners[T any]() *eventListeners[T] {
|
||||
return &eventListeners[T]{
|
||||
listeners: []T{},
|
||||
}
|
||||
}
|
||||
|
||||
// Create an identifier to use as a method call ID, from the specified accountId and additional
|
||||
// tag, to make something unique within that API request.
|
||||
func mcid(accountId AccountId, tag string) string {
|
||||
// https://jmap.io/spec-core.html#the-invocation-data-type
|
||||
// May be any string of data:
|
||||
// An arbitrary string from the client to be echoed back with the responses emitted by that method
|
||||
// call (a method may return 1 or more responses, as it may make implicit calls to other methods;
|
||||
// all responses initiated by this method call get the same method call id in the response).
|
||||
return string(accountId) + ":" + tag
|
||||
}
|
||||
|
||||
type Cmdr interface {
|
||||
ApiSupplier
|
||||
Hooks
|
||||
}
|
||||
|
||||
type Operation string
|
||||
|
||||
func command[T any](client Cmdr, //NOSONAR
|
||||
operation Operation,
|
||||
ctx Context,
|
||||
request Request,
|
||||
mapper func(body *Response) (T, State, Error)) (Result[T], Error) {
|
||||
|
||||
logger := ctx.Logger
|
||||
|
||||
before := time.Now()
|
||||
responseBody, language, jmapErr := client.Api().Command(operation, request, ctx)
|
||||
duration := time.Since(before)
|
||||
if jmapErr != nil {
|
||||
return ZeroResult[T](single(duration)), jmapErr
|
||||
}
|
||||
if responseBody == nil {
|
||||
return ZeroResult[T](single(duration)), jmapError(fmt.Errorf("empty response body"), JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(responseBody).Decode(&response); err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to deserialize body JSON payload into a %T", response)
|
||||
if err := responseBody.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
|
||||
}
|
||||
return ZeroResult[T](single(duration)), jmapError(err, JmapErrorDecodingResponseBody)
|
||||
}
|
||||
|
||||
if err := responseBody.Close(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body") //NOSONAR
|
||||
}
|
||||
|
||||
if response.SessionState != ctx.Session.State {
|
||||
client.OnSessionOutdated(ctx.Session, response.SessionState)
|
||||
}
|
||||
|
||||
// search for an "error" response
|
||||
// https://jmap.io/spec-core.html#method-level-errors
|
||||
for _, mr := range response.MethodResponses {
|
||||
if mr.Command == ErrorCommand {
|
||||
if errorParameters, ok := mr.Parameters.(ErrorResponse); ok {
|
||||
// TODO deal with stateMismatch differently, as it's not an error per se, but rather "optimistic update"
|
||||
code := JmapErrorServerFail
|
||||
switch errorParameters.Type {
|
||||
case MethodLevelErrorServerUnavailable:
|
||||
code = JmapErrorServerUnavailable
|
||||
case MethodLevelErrorServerFail, MethodLevelErrorServerPartialFail:
|
||||
code = JmapErrorServerFail
|
||||
case MethodLevelErrorUnknownMethod:
|
||||
code = JmapErrorUnknownMethod
|
||||
case MethodLevelErrorInvalidArguments:
|
||||
code = JmapErrorInvalidArguments
|
||||
if strings.HasPrefix(errorParameters.Description, "invalid JMAP State") {
|
||||
code = JmapErrorInvalidObjectState
|
||||
}
|
||||
case MethodLevelErrorInvalidResultReference:
|
||||
code = JmapErrorInvalidResultReference
|
||||
case MethodLevelErrorForbidden:
|
||||
// there's a quirk here: when referencing an account that exists but that this
|
||||
// user has no access to, Stalwart returns the 'forbidden' error, but this might
|
||||
// leak the existence of an account to an attacker -- instead, we deem it safer to
|
||||
// return a "account does not exist" error instead
|
||||
if strings.HasPrefix(errorParameters.Description, "You do not have access to account") {
|
||||
code = JmapErrorAccountNotFound
|
||||
} else {
|
||||
code = JmapErrorForbidden
|
||||
}
|
||||
case MethodLevelErrorAccountNotFound:
|
||||
code = JmapErrorAccountNotFound
|
||||
case MethodLevelErrorAccountNotSupportedByMethod:
|
||||
code = JmapErrorAccountNotSupportedByMethod
|
||||
case MethodLevelErrorAccountReadOnly:
|
||||
code = JmapErrorAccountReadOnly
|
||||
}
|
||||
msg := fmt.Sprintf("found method level error in response '%v', type: '%v', description: '%v'", mr.Tag, errorParameters.Type, errorParameters.Description)
|
||||
err := errors.New(msg)
|
||||
logger.Warn().Int("code", code).Str("type", errorParameters.Type).Msg(msg)
|
||||
return newPartialResult[T](response.SessionState, language, single(duration)), jmapResponseError(code, err, errorParameters.Type, errorParameters.Description)
|
||||
} else {
|
||||
code := JmapErrorUnspecifiedType
|
||||
msg := fmt.Sprintf("found method level error in response '%v'", mr.Tag)
|
||||
err := errors.New(msg)
|
||||
logger.Warn().Int("code", code).Msg(msg)
|
||||
return newPartialResult[T](response.SessionState, language, single(duration)), jmapResponseError(code, err, errorParameters.Type, errorParameters.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, state, jerr := mapper(&response)
|
||||
sessionState := response.SessionState
|
||||
return NewResult(result, sessionState, state, language, single(duration)), jerr
|
||||
}
|
||||
|
||||
func mapstructStringToTimeHook() mapstructure.DecodeHookFunc {
|
||||
// mapstruct isn't able to properly map RFC3339 date strings into Time
|
||||
// objects, which is why we require this custom hook,
|
||||
// see https://github.com/mitchellh/mapstructure/issues/41
|
||||
wanted := reflect.TypeFor[time.Time]()
|
||||
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
|
||||
if to != wanted {
|
||||
return data, nil
|
||||
}
|
||||
switch from.Kind() {
|
||||
case reflect.String:
|
||||
return time.Parse(time.RFC3339, data.(string))
|
||||
case reflect.Float64:
|
||||
return time.Unix(0, int64(data.(float64))*int64(time.Millisecond)), nil
|
||||
case reflect.Int64:
|
||||
return time.Unix(0, data.(int64)*int64(time.Millisecond)), nil
|
||||
default:
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMap(input map[string]any, target any) error {
|
||||
// https://github.com/mitchellh/mapstructure/issues/41
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Metadata: nil,
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructStringToTimeHook(),
|
||||
jscalendar.MapstructTriggerHook(),
|
||||
),
|
||||
Result: &target,
|
||||
ErrorUnused: false,
|
||||
ErrorUnset: false,
|
||||
IgnoreUntaggedFields: false,
|
||||
Squash: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decoder.Decode(input)
|
||||
}
|
||||
|
||||
func decodeParameters(input any, target any) error {
|
||||
m, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("decodeParameters: parameters is not a map but a %T", input)
|
||||
}
|
||||
return decodeMap(m, target)
|
||||
}
|
||||
|
||||
func retrieveResponseMatch(data *Response, command Command, tag string) (Invocation, bool) {
|
||||
for _, inv := range data.MethodResponses {
|
||||
if command == inv.Command && tag == inv.Tag {
|
||||
return inv, true
|
||||
}
|
||||
}
|
||||
return Invocation{}, false
|
||||
}
|
||||
|
||||
func retrieveResponseMatchParameters[T any](ctx Context, data *Response, command Command, tag string, target *T) Error {
|
||||
match, ok := retrieveResponseMatch(data, command, tag)
|
||||
if !ok {
|
||||
err := fmt.Errorf("failed to find JMAP response invocation match for command '%v' and tag '%v'", command, tag) // NOSONAR
|
||||
ctx.Logger.Error().Msg(err.Error())
|
||||
return jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
params := match.Parameters
|
||||
typedParams, ok := params.(T)
|
||||
if !ok {
|
||||
err := fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %T does not match the expected %T", command, tag, params, *target) // NOSONAR
|
||||
ctx.Logger.Error().Msg(err.Error())
|
||||
return jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
*target = typedParams
|
||||
return nil
|
||||
}
|
||||
|
||||
func tryRetrieveResponseMatchParameters[T any](ctx Context, data *Response, command Command, tag string, target *T) (bool, Error) {
|
||||
match, ok := retrieveResponseMatch(data, command, tag)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
params := match.Parameters
|
||||
typedParams, ok := params.(T)
|
||||
if !ok {
|
||||
err := fmt.Errorf("JMAP response invocation matches command '%v' and tag '%v' but the type %T does not match the expected %T", command, tag, params, *target)
|
||||
ctx.Logger.Error().Msg(err.Error())
|
||||
return true, jmapError(err, JmapErrorInvalidJmapResponsePayload)
|
||||
}
|
||||
*target = typedParams
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func retrieveGet[T Foo, C GetCommand[T], R GetResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
func retrieveSet[T Foo, C SetCommand[T], R SetResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
// TODO enable self-referencing generics parameter when upgrading to Go 1.26
|
||||
func retrieveQuery[T Foo, C QueryCommand[T /*, C*/], R QueryResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
func retrieveChanges[T Foo, C ChangesCommand[T], R ChangesResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
func retrieveUpload[T Foo, C UploadCommand[T], R UploadResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
func retrieveParse[T Foo, C ParseCommand[T], R ParseResponse[T]](ctx Context, data *Response, command C, tag string, target *R) Error {
|
||||
return retrieveResponseMatchParameters(ctx, data, command.GetCommand(), tag, target)
|
||||
}
|
||||
|
||||
func (i *Invocation) MarshalJSON() ([]byte, error) {
|
||||
// JMAP requests have a slightly unusual structure since they are not a JSON object
|
||||
// but, instead, a three-element array composed of
|
||||
// 0: the command (e.g. "Email/query")
|
||||
// 1: the actual payload of the request (structure depends on the command)
|
||||
// 2: a tag that can be used to identify the matching response payload
|
||||
// That implementation aspect thus requires us to use a custom marshalling hook.
|
||||
arr := []any{string(i.Command), i.Parameters, i.Tag}
|
||||
return json.Marshal(arr)
|
||||
}
|
||||
|
||||
func (i *Invocation) UnmarshalJSON(bs []byte) error {
|
||||
// JMAP responses have a slightly unusual structure since they are not a JSON object
|
||||
// but, instead, a three-element array composed of
|
||||
// 0: the command (e.g. "Thread/get") this is a response to
|
||||
// 1: the actual payload of the response (structure depends on the command)
|
||||
// 2: the tag (same as in the request invocation)
|
||||
// That implementation aspect thus requires us to use a custom unmarshalling hook.
|
||||
arr := []any{}
|
||||
err := json.Unmarshal(bs, &arr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) != 3 {
|
||||
// JMAP response must really always be an array of three elements
|
||||
return fmt.Errorf("Invocation array length ought to be 3 but is %d", len(arr))
|
||||
}
|
||||
// The first element in the array is the command:
|
||||
i.Command = Command(arr[0].(string))
|
||||
// The third element in the array is the tag:
|
||||
i.Tag = arr[2].(string)
|
||||
|
||||
// Due to the dynamic nature of request and response types in JMAP, we
|
||||
// switch to using mapstruct here to deserialize the payload in the "parameters"
|
||||
// element of JMAP invocation response arrays, as their expected struct type
|
||||
// is directly inferred from the command (e.g. "Mailbox/get")
|
||||
payload := arr[1]
|
||||
|
||||
paramsFactory, ok := CommandResponseTypeMap[i.Command]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported JMAP operation cannot be unmarshalled: %v", i.Command)
|
||||
}
|
||||
params := paramsFactory()
|
||||
err = decodeParameters(payload, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Parameters = params
|
||||
return nil
|
||||
}
|
||||
|
||||
func squashState[K ~string](all map[K]State) State {
|
||||
return squashStateFunc(all, func(s State) State { return s })
|
||||
}
|
||||
|
||||
/*
|
||||
func squashStates(states ...State) State {
|
||||
return State(strings.Join(structs.Map(states, func(s State) string { return string(s) }), ","))
|
||||
}
|
||||
*/
|
||||
|
||||
func squashKeyedStates[K ~string](m map[K]State) State {
|
||||
return squashStateFunc(m, identity1)
|
||||
}
|
||||
|
||||
func squashStateFunc[K ~string, V any](all map[K]V, mapper func(V) State) State {
|
||||
n := len(all)
|
||||
if n == 0 {
|
||||
return State("")
|
||||
}
|
||||
if n == 1 {
|
||||
for _, v := range all {
|
||||
return mapper(v)
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, n)
|
||||
sortedKeys := make([]K, n)
|
||||
i := 0
|
||||
for k := range all {
|
||||
sortedKeys[i] = k
|
||||
i++
|
||||
}
|
||||
slices.Sort(sortedKeys)
|
||||
for i, k := range sortedKeys {
|
||||
if v, ok := all[k]; ok {
|
||||
parts[i] = string(k) + ":" + string(mapper(v))
|
||||
} else {
|
||||
parts[i] = string(k) + ":"
|
||||
}
|
||||
}
|
||||
return State(strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
func squashStateMaps(first map[AccountId]State, second map[AccountId]State) State {
|
||||
return squashStateFunc(mapPairs(first, second), func(p pair[State, State]) State {
|
||||
if p.left != nil {
|
||||
if p.right != nil {
|
||||
return *p.left + ";" + *p.right
|
||||
} else {
|
||||
return *p.left + ";"
|
||||
}
|
||||
} else if p.right != nil {
|
||||
return ";" + *p.right
|
||||
} else {
|
||||
return ";"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type pair[L any, R any] struct {
|
||||
left *L
|
||||
right *R
|
||||
}
|
||||
|
||||
func mapPairs[K comparable, L, R any](left map[K]L, right map[K]R) map[K]pair[L, R] {
|
||||
result := map[K]pair[L, R]{}
|
||||
for k, l := range left {
|
||||
if r, ok := right[k]; ok {
|
||||
result[k] = pair[L, R]{left: &l, right: &r}
|
||||
} else {
|
||||
result[k] = pair[L, R]{left: &l, right: nil}
|
||||
}
|
||||
}
|
||||
for k, r := range right {
|
||||
if _, ok := left[k]; !ok {
|
||||
result[k] = pair[L, R]{left: nil, right: &r}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var (
|
||||
truep = ptr(true)
|
||||
)
|
||||
|
||||
func identity1[T any](t T) T {
|
||||
return t
|
||||
}
|
||||
|
||||
func uintPtr[T int | uint](i T) *uint {
|
||||
return ptr(uint(i))
|
||||
}
|
||||
|
||||
func valueIf[T any | uint | int | bool | any](value *T, condition bool) *T {
|
||||
if condition {
|
||||
return value
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ptrIf[T any | uint | int | bool](value T, condition bool) *T {
|
||||
if condition {
|
||||
return &value
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ns(namespaces ...JmapNamespace) []JmapNamespace {
|
||||
result := make([]JmapNamespace, len(namespaces)+1)
|
||||
result[0] = JmapCore
|
||||
for i, n := range namespaces {
|
||||
result[i+1] = n
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TODO remove and replace with calls to new() when upgrading to Go 1.26
|
||||
func ptr[T any | int | uint | bool | string](t T) *T {
|
||||
return &t
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnmarshallingError(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
responseBody := `{"methodResponses":[["error",{"type":"forbidden","description":"You do not have access to account a"},"a:0"]],"sessionState":"3e25b2a0"}`
|
||||
var response Response
|
||||
err := json.Unmarshal([]byte(responseBody), &response)
|
||||
require.NoError(err)
|
||||
require.Len(response.MethodResponses, 1)
|
||||
require.Equal(ErrorCommand, response.MethodResponses[0].Command)
|
||||
require.Equal("a:0", response.MethodResponses[0].Tag)
|
||||
require.IsType(ErrorResponse{}, response.MethodResponses[0].Parameters)
|
||||
er, _ := response.MethodResponses[0].Parameters.(ErrorResponse)
|
||||
require.Equal("forbidden", er.Type)
|
||||
require.Equal("You do not have access to account a", er.Description)
|
||||
}
|
||||
|
||||
func TestSquashKeyedStates(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
result := squashKeyedStates(map[string]State{
|
||||
"a": "aaa",
|
||||
"b": "bbb",
|
||||
"c": "ccc",
|
||||
})
|
||||
require.Equal("a:aaa,b:bbb,c:ccc", string(result))
|
||||
}
|
||||
|
||||
func TestInvocationMarshalling(t *testing.T) {
|
||||
tag := strconv.Itoa(1000 + rand.Intn(1000))
|
||||
accountId := fmt.Sprintf("a%d", 100+rand.Intn(100))
|
||||
inv := invocation(IdentityGetCommand{AccountId: AccountId(accountId), Ids: []string{"x", "y", "z"}}, tag)
|
||||
b, err := json.Marshal(inv)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fmt.Sprintf(`{"Command":"Identity/get","Parameters":{"accountId":"%s","ids":["x","y","z"]},"Tag":"%s"}`, accountId, tag), string(b))
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
package jmaptest
|
||||
|
||||
import (
|
||||
golog "log"
|
||||
"maps"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
. "github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/opencloud-eu/opencloud/pkg/jscontact"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
const (
|
||||
// currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431
|
||||
EnableMediaWithBlobId = false
|
||||
)
|
||||
|
||||
type AddressBookBoxes struct {
|
||||
sharedReadOnly bool
|
||||
sharedReadWrite bool
|
||||
sharedDelete bool
|
||||
sortOrdered bool
|
||||
}
|
||||
|
||||
func TestAddressBooks(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
containerTest(t,
|
||||
func(session *Session) AccountId { return session.PrimaryAccounts.Contacts },
|
||||
list,
|
||||
getid,
|
||||
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
|
||||
return s.Client.GetAddressbooks(accountId, ids, ctx)
|
||||
},
|
||||
func(s *StalwartTest, accountId AccountId, id string, change AddressBookChange, ctx Context) (Result[AddressBook], error) { //NOSONAR
|
||||
return s.Client.UpdateAddressBook(accountId, id, change, ctx)
|
||||
},
|
||||
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
|
||||
return s.Client.DeleteAddressBook(accountId, ids, ctx)
|
||||
},
|
||||
func(s *StalwartTest, t *testing.T, accountId AccountId, count uint, ctx Context, user User, principalIds []PrincipalId) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
|
||||
return s.fillAddressBook(t, accountId, count, ctx, user, principalIds)
|
||||
},
|
||||
func(orig AddressBook) AddressBookChange {
|
||||
return AddressBookChange{
|
||||
Description: ptr(orig.Description + " (changed)"),
|
||||
IsSubscribed: ptr(!orig.IsSubscribed),
|
||||
}
|
||||
},
|
||||
func(t *testing.T, orig AddressBook, _ AddressBookChange, changed AddressBook) {
|
||||
require.Equal(t, orig.Name, changed.Name)
|
||||
require.Equal(t, orig.Description+" (changed)", changed.Description)
|
||||
require.Equal(t, !orig.IsSubscribed, changed.IsSubscribed)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestContacts(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
count := uint(20 + rand.Intn(30))
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
s, err := NewStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
|
||||
user := pickUser()
|
||||
session := s.Session(user.Email)
|
||||
ctx := s.Context(session)
|
||||
|
||||
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, ctx, user)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(accountId)
|
||||
require.NotEmpty(addressbookId)
|
||||
|
||||
filter := ContactCardFilterCondition{
|
||||
InAddressBook: addressbookId,
|
||||
}
|
||||
sortBy := []ContactCardComparator{
|
||||
{Property: ContactCardPropertyCreated, IsAscending: true},
|
||||
}
|
||||
|
||||
var results *ContactCardSearchResults
|
||||
ss := EmptySessionState
|
||||
os := EmptyState
|
||||
{
|
||||
result, err := s.Client.QueryContactCards(ToNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results = result.Payload[accountId]
|
||||
require.Len(results.Results, int(count))
|
||||
require.Nil(results.Limit)
|
||||
require.NotNil(results.Position)
|
||||
require.Equal(uint(0), *results.Position)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
|
||||
ss = result.GetSessionState()
|
||||
require.NotEmpty(ss)
|
||||
os = result.GetState()
|
||||
require.NotEmpty(os)
|
||||
}
|
||||
|
||||
for _, actual := range results.Results {
|
||||
expected, ok := expectedContactCardsById[actual.Id]
|
||||
require.True(ok, "failed to find created contact by its id")
|
||||
matchContact(t, actual, expected)
|
||||
}
|
||||
|
||||
// retrieve all objects at once
|
||||
{
|
||||
ids := structs.Map(results.Results, func(c ContactCard) string { return c.Id })
|
||||
result, err := s.Client.GetContactCards(accountId, ids, ctx)
|
||||
require.NoError(err)
|
||||
require.Empty(result.Payload.NotFound)
|
||||
require.Len(result.Payload.List, len(ids))
|
||||
byId := structs.Index(result.Payload.List, func(r ContactCard) string { return r.Id })
|
||||
for _, actual := range results.Results {
|
||||
expected, ok := byId[actual.Id]
|
||||
require.True(ok, "failed to find created contact by its id")
|
||||
matchContact(t, actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve each object one by one
|
||||
for _, actual := range results.Results {
|
||||
result, err := s.Client.GetContactCards(accountId, []string{actual.Id}, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload.List, 1)
|
||||
matchContact(t, result.Payload.List[0], actual)
|
||||
}
|
||||
|
||||
{
|
||||
limit := uint(10)
|
||||
slices := count / limit
|
||||
remainder := count
|
||||
require.Greater(slices, uint(1), "we need to have more than 10 objects in order to test the pagination of search results")
|
||||
for i := range slices {
|
||||
position := int(i * limit)
|
||||
page := min(remainder, limit)
|
||||
result, err := s.Client.QueryContactCards(map[AccountId]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results := result.Payload[accountId]
|
||||
require.Equal(len(results.Results), int(page))
|
||||
require.NotNil(results.Limit)
|
||||
require.Equal(limit, *results.Limit)
|
||||
require.NotNil(results.Position)
|
||||
require.Equal(uint(position), *results.Position)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
remainder -= uint(len(results.Results))
|
||||
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
chunkSize := 3
|
||||
anchor := results.Results[0].Id
|
||||
offset := 0
|
||||
i := 0
|
||||
for chunk := range slices.Chunk(results.Results, chunkSize) {
|
||||
result, err := s.Client.QueryContactCards(map[AccountId]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results := result.Payload[accountId]
|
||||
l := len(results.Results)
|
||||
require.LessOrEqual(l, chunkSize)
|
||||
require.NotZero(l)
|
||||
require.NotNil(results.Limit)
|
||||
require.Equal(uint(chunkSize), *results.Limit)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
for i := range l {
|
||||
require.Equal(chunk[i].Id, results.Results[i].Id)
|
||||
}
|
||||
anchor = chunk[len(chunk)-1].Id
|
||||
offset = 1
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
now := time.Now().Truncate(time.Duration(1) * time.Second).UTC()
|
||||
for _, event := range expectedContactCardsById {
|
||||
change := ContactCardChange{
|
||||
Language: ptr("xyz"),
|
||||
Updated: ptr(now),
|
||||
}
|
||||
result, err := s.Client.UpdateContactCard(accountId, event.Id, change, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal("xyz", result.Payload.Language)
|
||||
require.Equal(now, result.Payload.Updated)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NotEqual(os, result.GetState())
|
||||
os = result.GetState()
|
||||
}
|
||||
}
|
||||
{
|
||||
ids := structs.Map(slices.Collect(maps.Values(expectedContactCardsById)), func(e ContactCard) string { return e.Id })
|
||||
result, err := s.Client.DeleteContactCard(accountId, ids, ctx)
|
||||
require.NoError(err)
|
||||
require.Empty(result.Payload)
|
||||
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NotEqual(os, result.GetState())
|
||||
os = result.GetState()
|
||||
}
|
||||
{
|
||||
result, err := s.Client.QueryContactCards(ToNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Contains(result.Payload, accountId)
|
||||
resp := result.Payload[accountId]
|
||||
require.Empty(resp.Results)
|
||||
require.NotNil(resp.Total)
|
||||
require.Equal(uint(0), *resp.Total)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.Equal(os, result.GetState())
|
||||
}
|
||||
|
||||
exceptions := []string{}
|
||||
if !EnableMediaWithBlobId {
|
||||
exceptions = append(exceptions, "mediaWithBlobId")
|
||||
}
|
||||
allBoxesAreTicked(t, boxes, exceptions...)
|
||||
}
|
||||
|
||||
func matchContact(t *testing.T, actual ContactCard, expected ContactCard) {
|
||||
// require.Equal(t, expected, actual)
|
||||
deepEqual(t, expected, actual)
|
||||
}
|
||||
|
||||
type ContactsBoxes struct {
|
||||
nicknames bool
|
||||
secondaryEmails bool
|
||||
secondaryAddress bool
|
||||
phones bool
|
||||
onlineService bool
|
||||
preferredLanguage bool
|
||||
mediaWithBlobId bool
|
||||
mediaWithDataUri bool
|
||||
mediaWithExternalUri bool
|
||||
organization bool
|
||||
cryptoKey bool
|
||||
link bool
|
||||
}
|
||||
|
||||
var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`)
|
||||
|
||||
func (s *StalwartTest) fillAddressBook( //NOSONAR
|
||||
t *testing.T,
|
||||
accountId AccountId,
|
||||
count uint,
|
||||
ctx Context,
|
||||
_ User,
|
||||
principalIds []PrincipalId,
|
||||
) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
|
||||
require := require.New(t)
|
||||
|
||||
boxes := AddressBookBoxes{}
|
||||
created := []AddressBook{}
|
||||
ss := EmptySessionState
|
||||
as := EmptyState
|
||||
|
||||
printer := func(s string) { golog.Println(s) }
|
||||
|
||||
for i := range count {
|
||||
name := gofakeit.Company()
|
||||
description := gofakeit.SentenceSimple()
|
||||
subscribed := gofakeit.Bool()
|
||||
abook := AddressBookChange{
|
||||
Name: &name,
|
||||
Description: &description,
|
||||
IsSubscribed: &subscribed,
|
||||
}
|
||||
if i%2 == 0 {
|
||||
abook.SortOrder = uintPtr(gofakeit.Uint())
|
||||
boxes.sortOrdered = true
|
||||
}
|
||||
var sharing *AddressBookRights = nil
|
||||
switch i % 4 {
|
||||
default:
|
||||
// no sharing
|
||||
case 1:
|
||||
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: false}
|
||||
boxes.sharedReadWrite = true
|
||||
case 2:
|
||||
sharing = &AddressBookRights{MayRead: true, MayWrite: false, MayAdmin: false, MayDelete: false}
|
||||
boxes.sharedReadOnly = true
|
||||
case 3:
|
||||
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: true}
|
||||
boxes.sharedDelete = true
|
||||
}
|
||||
if sharing != nil {
|
||||
numPrincipals := 1 + rand.Intn(len(principalIds)-1)
|
||||
m := make(map[PrincipalId]AddressBookRights, numPrincipals)
|
||||
for _, p := range pickRandomN(numPrincipals, principalIds...) {
|
||||
m[p] = *sharing
|
||||
}
|
||||
abook.ShareWith = m
|
||||
}
|
||||
|
||||
result, err := s.Client.CreateAddressBook(accountId, abook, ctx)
|
||||
if err != nil {
|
||||
return boxes, created, ss, as, err
|
||||
}
|
||||
require.NotEmpty(result.GetSessionState())
|
||||
require.NotEmpty(result.GetState())
|
||||
if ss != EmptySessionState {
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
}
|
||||
if as != EmptyState {
|
||||
require.NotEqual(as, result.GetState())
|
||||
}
|
||||
require.NotNil(result.Payload)
|
||||
created = append(created, *result.Payload)
|
||||
ss = result.GetSessionState()
|
||||
as = result.GetState()
|
||||
|
||||
printer(fmt.Sprintf("📔 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, result.Payload.Id))
|
||||
}
|
||||
return boxes, created, ss, as, nil
|
||||
}
|
||||
|
||||
func (s *StalwartTest) fillContacts( //NOSONAR
|
||||
t *testing.T,
|
||||
count uint,
|
||||
session *Session,
|
||||
ctx Context,
|
||||
user User,
|
||||
) (AccountId, string, map[string]ContactCard, ContactsBoxes, error) {
|
||||
require := require.New(t)
|
||||
c, err := NewTestJmapClient(session, user.Email, user.Password, true, true)
|
||||
require.NoError(err)
|
||||
defer c.Close()
|
||||
|
||||
boxes := ContactsBoxes{}
|
||||
|
||||
printer := func(s string) { golog.Println(s) }
|
||||
|
||||
accountId := c.session.PrimaryAccounts.Contacts
|
||||
require.NotEmpty(accountId, "no primary account for contacts in session")
|
||||
|
||||
addressbookId := ""
|
||||
{
|
||||
addressBooksById, err := c.objectsById(accountId, AddressBookType)
|
||||
require.NoError(err)
|
||||
|
||||
for id, addressbook := range addressBooksById {
|
||||
if isDefault, ok := addressbook["isDefault"]; ok {
|
||||
if isDefault.(bool) {
|
||||
addressbookId = id
|
||||
break
|
||||
}
|
||||
} else {
|
||||
printer(fmt.Sprintf("abook without isDefault: %v", addressbook))
|
||||
}
|
||||
}
|
||||
if addressbookId == "" {
|
||||
ids := structs.Keys(addressBooksById)
|
||||
slices.Sort(ids)
|
||||
addressbookId = ids[0]
|
||||
}
|
||||
}
|
||||
require.NotEmpty(addressbookId)
|
||||
|
||||
filled := map[string]ContactCard{}
|
||||
for i := range count {
|
||||
person := gofakeit.Person()
|
||||
nameObj := createName(person)
|
||||
language := pickLanguage()
|
||||
|
||||
card := ContactCardChange{
|
||||
Type: jscontact.ContactCardType,
|
||||
Version: ptr(jscontact.JSContactVersion_1_0),
|
||||
AddressBookIds: toBoolPtrMap([]string{addressbookId}),
|
||||
ProdId: &productName,
|
||||
Language: &language,
|
||||
Kind: ptr(jscontact.ContactCardKindIndividual),
|
||||
Name: &nameObj,
|
||||
}
|
||||
|
||||
if i%3 == 0 {
|
||||
nicknameObj := createNickName(person)
|
||||
id := id()
|
||||
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
|
||||
boxes.nicknames = true
|
||||
}
|
||||
|
||||
{
|
||||
emailObjs := map[string]jscontact.EmailAddress{}
|
||||
emailId := id()
|
||||
emailObj := createEmail(person, 10)
|
||||
emailObjs[emailId] = emailObj
|
||||
|
||||
for i := range rand.Intn(3) {
|
||||
id := id()
|
||||
o := createSecondaryEmail(gofakeit.Email(), i*100)
|
||||
emailObjs[id] = o
|
||||
boxes.secondaryEmails = true
|
||||
}
|
||||
if len(emailObjs) > 0 {
|
||||
card.Emails = emailObjs
|
||||
}
|
||||
}
|
||||
if err := propmap(i%2 == 0, 1, 2, &card.Phones, func(i int, id string) (jscontact.Phone, error) {
|
||||
boxes.phones = true
|
||||
num := person.Contact.Phone
|
||||
if i > 0 {
|
||||
num = gofakeit.Phone()
|
||||
}
|
||||
var features map[jscontact.PhoneFeature]bool = nil
|
||||
if rand.Intn(3) < 2 {
|
||||
features = toBoolMapS(jscontact.PhoneFeatureMobile, jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureVideo, jscontact.PhoneFeatureText)
|
||||
} else {
|
||||
features = toBoolMapS(jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureMainNumber)
|
||||
}
|
||||
|
||||
contexts := map[jscontact.PhoneContext]bool{jscontact.PhoneContextWork: true}
|
||||
if rand.Intn(2) < 1 {
|
||||
contexts[jscontact.PhoneContextPrivate] = true
|
||||
}
|
||||
tel := "tel:" + "+1" + num
|
||||
return jscontact.Phone{
|
||||
Type: jscontact.PhoneType,
|
||||
Number: tel,
|
||||
Features: features,
|
||||
Contexts: contexts,
|
||||
}, nil
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
if err := propmap(i%5 < 4, 1, 2, &card.Addresses, func(i int, id string) (jscontact.Address, error) {
|
||||
var source *gofakeit.AddressInfo
|
||||
if i == 0 {
|
||||
source = person.Address
|
||||
} else {
|
||||
source = gofakeit.Address()
|
||||
boxes.secondaryAddress = true
|
||||
}
|
||||
components := []jscontact.AddressComponent{}
|
||||
m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1)
|
||||
if m != nil {
|
||||
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: m[0][2]})
|
||||
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]})
|
||||
} else {
|
||||
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: source.Street})
|
||||
}
|
||||
components = append(components,
|
||||
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindLocality, Value: source.City},
|
||||
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindCountry, Value: source.Country},
|
||||
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindRegion, Value: source.State},
|
||||
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
|
||||
)
|
||||
tz := pickRandom(timezones...)
|
||||
return jscontact.Address{
|
||||
Type: jscontact.AddressType,
|
||||
Components: components,
|
||||
DefaultSeparator: ", ",
|
||||
IsOrdered: true,
|
||||
TimeZone: tz,
|
||||
}, nil
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
if err := propmap(i%2 == 0, 1, 2, &card.OnlineServices, func(i int, id string) (jscontact.OnlineService, error) {
|
||||
boxes.onlineService = true
|
||||
switch rand.Intn(3) {
|
||||
case 0:
|
||||
return jscontact.OnlineService{
|
||||
Type: jscontact.OnlineServiceType,
|
||||
Service: "Mastodon",
|
||||
User: "@" + person.Contact.Email,
|
||||
Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
|
||||
}, nil
|
||||
case 1:
|
||||
return jscontact.OnlineService{
|
||||
Type: jscontact.OnlineServiceType,
|
||||
Uri: "xmpp:" + person.Contact.Email,
|
||||
}, nil
|
||||
default:
|
||||
return jscontact.OnlineService{
|
||||
Type: jscontact.OnlineServiceType,
|
||||
Service: "Discord",
|
||||
User: person.Contact.Email,
|
||||
Uri: "https://discord.example.com/user/" + person.Contact.Email,
|
||||
}, nil
|
||||
}
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
|
||||
if err := propmap(i%3 == 0, 1, 2, &card.PreferredLanguages, func(i int, id string) (jscontact.LanguagePref, error) {
|
||||
boxes.preferredLanguage = true
|
||||
lang := pickRandom("en", "fr", "de", "es", "it")
|
||||
contexts := pickRandoms1("work", "private")
|
||||
return jscontact.LanguagePref{
|
||||
Type: jscontact.LanguagePrefType,
|
||||
Language: lang,
|
||||
Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })),
|
||||
Pref: uint(i + 1),
|
||||
}, nil
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
|
||||
if i%2 == 0 {
|
||||
organizationObjs := map[string]jscontact.Organization{}
|
||||
titleObjs := map[string]jscontact.Title{}
|
||||
for range 1 + rand.Intn(2) {
|
||||
boxes.organization = true
|
||||
orgId := id()
|
||||
titleId := id()
|
||||
organizationObjs[orgId] = jscontact.Organization{
|
||||
Type: jscontact.OrganizationType,
|
||||
Name: person.Job.Company,
|
||||
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
|
||||
}
|
||||
|
||||
titleObjs[titleId] = jscontact.Title{
|
||||
Type: jscontact.TitleType,
|
||||
Kind: jscontact.TitleKindTitle,
|
||||
Name: person.Job.Title,
|
||||
OrganizationId: orgId,
|
||||
}
|
||||
}
|
||||
card.Organizations = organizationObjs
|
||||
card.Titles = titleObjs
|
||||
}
|
||||
|
||||
if err := propmap(i%2 == 0, 1, 1, &card.CryptoKeys, func(i int, id string) (jscontact.CryptoKey, error) {
|
||||
boxes.cryptoKey = true
|
||||
entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil)
|
||||
if err != nil {
|
||||
return jscontact.CryptoKey{}, err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
err = entity.PrimaryKey.Serialize(&b)
|
||||
if err != nil {
|
||||
return jscontact.CryptoKey{}, err
|
||||
}
|
||||
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
|
||||
return jscontact.CryptoKey{
|
||||
Type: jscontact.CryptoKeyType,
|
||||
Uri: "data:application/pgp-keys;base64," + encoded,
|
||||
MediaType: "application/pgp-keys",
|
||||
}, nil
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
|
||||
if err := propmap(i%2 == 0, 1, 2, &card.Media, func(i int, id string) (jscontact.Media, error) {
|
||||
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
|
||||
|
||||
r := 0
|
||||
if EnableMediaWithBlobId {
|
||||
r = rand.Intn(3)
|
||||
} else {
|
||||
r = rand.Intn(2)
|
||||
}
|
||||
|
||||
switch r {
|
||||
case 0:
|
||||
boxes.mediaWithDataUri = true
|
||||
// use data uri
|
||||
//size := 16 + rand.Intn(512-16+1) // <- let's not do that right now, makes debugging errors very difficult due to the ASCII wall noise
|
||||
size := pickRandom(16, 24, 32, 48, 64)
|
||||
img := gofakeit.ImagePng(size, size)
|
||||
mime := "image/png"
|
||||
uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
|
||||
contexts := toBoolMapS(jscontact.MediaContextPrivate)
|
||||
return jscontact.Media{
|
||||
Type: jscontact.MediaType,
|
||||
Kind: jscontact.MediaKindPhoto,
|
||||
Uri: uri,
|
||||
MediaType: mime,
|
||||
Contexts: contexts,
|
||||
Label: label,
|
||||
}, nil
|
||||
|
||||
case 1:
|
||||
boxes.mediaWithExternalUri = true
|
||||
// use external uri
|
||||
uri := externalImageUri()
|
||||
contexts := toBoolMapS(jscontact.MediaContextWork)
|
||||
return jscontact.Media{
|
||||
Type: jscontact.MediaType,
|
||||
Kind: jscontact.MediaKindPhoto,
|
||||
Uri: uri,
|
||||
Contexts: contexts,
|
||||
Label: label,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
boxes.mediaWithBlobId = true
|
||||
size := pickRandom(16, 24, 32, 48, 64)
|
||||
img := gofakeit.ImageJpeg(size, size)
|
||||
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
|
||||
if err != nil {
|
||||
return jscontact.Media{}, err
|
||||
}
|
||||
contexts := toBoolMapS(jscontact.MediaContextPrivate)
|
||||
return jscontact.Media{
|
||||
Type: jscontact.MediaType,
|
||||
Kind: jscontact.MediaKindPhoto,
|
||||
BlobId: blob.BlobId,
|
||||
MediaType: blob.Type,
|
||||
Contexts: contexts,
|
||||
Label: label,
|
||||
}, nil
|
||||
|
||||
}
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
if err := propmap(i%2 == 0, 1, 1, &card.Links, func(i int, id string) (jscontact.Link, error) {
|
||||
boxes.link = true
|
||||
return jscontact.Link{
|
||||
Type: jscontact.LinkType,
|
||||
Kind: jscontact.LinkKindContact,
|
||||
Uri: "mailto:" + person.Contact.Email,
|
||||
Pref: uint((i + 1) * 10),
|
||||
}, nil
|
||||
}); err != nil {
|
||||
return "", "", nil, boxes, err
|
||||
}
|
||||
|
||||
result, err := s.Client.CreateContactCard(accountId, card, ctx)
|
||||
if err != nil {
|
||||
return accountId, addressbookId, filled, boxes, err
|
||||
}
|
||||
require.NotNil(result.Payload)
|
||||
filled[result.Payload.Id] = *result.Payload
|
||||
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, result.Payload.Id))
|
||||
}
|
||||
return accountId, addressbookId, filled, boxes, nil
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
package jmaptest
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
golog "log"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
. "github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
)
|
||||
|
||||
// fields that are currently unsupported in Stalwart
|
||||
const (
|
||||
EnableEventMayInviteFields = false
|
||||
EnableEventParticipantDescriptionFields = false
|
||||
)
|
||||
|
||||
func TestCalendars(t *testing.T) { //NOSONAR
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
containerTest(t,
|
||||
func(session *Session) AccountId { return session.PrimaryAccounts.Calendars },
|
||||
func(resp CalendarGetResponse) []Calendar { return resp.List },
|
||||
func(obj Calendar) string { return obj.Id },
|
||||
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[CalendarGetResponse], error) {
|
||||
return s.Client.GetCalendars(accountId, ids, ctx)
|
||||
},
|
||||
func(s *StalwartTest, accountId AccountId, id string, change CalendarChange, ctx Context) (Result[Calendar], error) { //NOSONAR
|
||||
return s.Client.UpdateCalendar(accountId, id, change, ctx)
|
||||
},
|
||||
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
|
||||
return s.Client.DeleteCalendar(accountId, ids, ctx)
|
||||
},
|
||||
func(s *StalwartTest, t *testing.T, accountId AccountId, count uint, ctx Context, user User, principalIds []PrincipalId) (CalendarBoxes, []Calendar, SessionState, State, error) {
|
||||
return s.fillCalendar(t, accountId, count, ctx, user, principalIds)
|
||||
},
|
||||
func(orig Calendar) CalendarChange {
|
||||
return CalendarChange{
|
||||
Description: ptr(orig.Description + " (changed)"),
|
||||
IsSubscribed: ptr(!orig.IsSubscribed),
|
||||
}
|
||||
},
|
||||
func(t *testing.T, orig Calendar, _ CalendarChange, changed Calendar) {
|
||||
require.Equal(t, orig.Name, changed.Name)
|
||||
require.Equal(t, orig.Description+" (changed)", changed.Description)
|
||||
require.Equal(t, !orig.IsSubscribed, changed.IsSubscribed)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestEvents(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
count := uint(20 + rand.Intn(30))
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
s, err := NewStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
|
||||
user := pickUser()
|
||||
session := s.Session(user.Email)
|
||||
ctx := s.Context(session)
|
||||
|
||||
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, ctx, user)
|
||||
require.NoError(err)
|
||||
require.NotEmpty(accountId)
|
||||
require.NotEmpty(calendarId)
|
||||
|
||||
filter := CalendarEventFilterCondition{
|
||||
InCalendar: calendarId,
|
||||
}
|
||||
sortBy := []CalendarEventComparator{
|
||||
{Property: CalendarEventPropertyStart, IsAscending: true},
|
||||
}
|
||||
|
||||
ss := EmptySessionState
|
||||
os := EmptyState
|
||||
var results *CalendarEventSearchResults
|
||||
{
|
||||
result, err := s.Client.QueryCalendarEvents(ToNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results = result.Payload[accountId]
|
||||
require.NotNil(results)
|
||||
require.Len(results.Results, int(count))
|
||||
require.Nil(results.Limit)
|
||||
require.NotNil(results.Position)
|
||||
require.Equal(uint(0), *results.Position)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
|
||||
for _, actual := range results.Results {
|
||||
expected, ok := expectedEventsById[actual.Id]
|
||||
require.True(ok, "failed to find created contact by its id")
|
||||
matchEvent(t, actual, expected)
|
||||
}
|
||||
|
||||
ss = result.GetSessionState()
|
||||
os = result.GetState()
|
||||
}
|
||||
|
||||
{
|
||||
limit := uint(10)
|
||||
slices := count / limit
|
||||
remainder := count
|
||||
require.Greater(slices, uint(1), "we need to have more than 10 objects in order to test the pagination of search results")
|
||||
for i := range slices {
|
||||
position := int(i * limit)
|
||||
page := min(remainder, limit)
|
||||
result, err := s.Client.QueryCalendarEvents(map[AccountId]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results := result.Payload[accountId]
|
||||
require.Equal(len(results.Results), int(page))
|
||||
require.NotNil(results.Limit)
|
||||
require.Equal(limit, *results.Limit)
|
||||
require.NotNil(results.Position)
|
||||
require.Equal(uint(position), *results.Position)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
remainder -= uint(len(results.Results))
|
||||
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
chunkSize := 3
|
||||
anchor := results.Results[0].Id
|
||||
offset := 0
|
||||
i := 0
|
||||
for chunk := range slices.Chunk(results.Results, chunkSize) {
|
||||
result, err := s.Client.QueryCalendarEvents(map[AccountId]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
results := result.Payload[accountId]
|
||||
l := len(results.Results)
|
||||
require.LessOrEqual(l, chunkSize)
|
||||
require.NotZero(l)
|
||||
require.NotNil(results.Limit)
|
||||
require.Equal(uint(chunkSize), *results.Limit)
|
||||
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
|
||||
require.NotNil(results.Total)
|
||||
require.Equal(count, *results.Total)
|
||||
for i := range l {
|
||||
require.Equal(chunk[i].Id, results.Results[i].Id)
|
||||
}
|
||||
anchor = chunk[len(chunk)-1].Id
|
||||
offset = 1
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
for _, event := range expectedEventsById {
|
||||
change := CalendarEventChange{
|
||||
EventChange: jscalendar.EventChange{
|
||||
Status: ptr(jscalendar.StatusCancelled),
|
||||
ObjectChange: jscalendar.ObjectChange{
|
||||
Sequence: uintPtr(99),
|
||||
ShowWithoutTime: truep,
|
||||
},
|
||||
},
|
||||
}
|
||||
result, err := s.Client.UpdateCalendarEvent(accountId, event.Id, change, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(jscalendar.StatusCancelled, result.Payload.Status)
|
||||
require.Equal(uint(99), result.Payload.Sequence)
|
||||
require.Equal(true, result.Payload.ShowWithoutTime)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NotEqual(os, result.GetState())
|
||||
os = result.GetState()
|
||||
}
|
||||
|
||||
{
|
||||
ids := structs.Map(slices.Collect(maps.Values(expectedEventsById)), func(e CalendarEvent) string { return e.Id })
|
||||
result, err := s.Client.DeleteCalendarEvent(accountId, ids, ctx)
|
||||
require.NoError(err)
|
||||
require.Empty(result.Payload)
|
||||
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.NotEqual(os, result.GetState())
|
||||
os = result.GetState()
|
||||
}
|
||||
|
||||
{
|
||||
result, err := s.Client.QueryCalendarEvents(ToNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Contains(result.Payload, accountId)
|
||||
resp := result.Payload[accountId]
|
||||
require.Empty(resp.Results)
|
||||
require.NotNil(resp.Total)
|
||||
require.Equal(uint(0), *resp.Total)
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
require.Equal(os, result.GetState())
|
||||
}
|
||||
|
||||
exceptions := []string{}
|
||||
if !EnableEventMayInviteFields {
|
||||
exceptions = append(exceptions, "mayInvite")
|
||||
}
|
||||
allBoxesAreTicked(t, boxes, exceptions...)
|
||||
}
|
||||
|
||||
func matchEvent(t *testing.T, actual CalendarEvent, expected CalendarEvent) {
|
||||
//require.Equal(t, expected, actual)
|
||||
deepEqual(t, expected, actual)
|
||||
}
|
||||
|
||||
type CalendarBoxes struct {
|
||||
sharedReadOnly bool
|
||||
sharedReadWrite bool
|
||||
sharedDelete bool
|
||||
sortOrdered bool
|
||||
}
|
||||
|
||||
func (s *StalwartTest) fillCalendar( //NOSONAR
|
||||
t *testing.T,
|
||||
accountId AccountId,
|
||||
count uint,
|
||||
ctx Context,
|
||||
_ User,
|
||||
principalIds []PrincipalId,
|
||||
) (CalendarBoxes, []Calendar, SessionState, State, error) {
|
||||
require := require.New(t)
|
||||
|
||||
boxes := CalendarBoxes{}
|
||||
created := []Calendar{}
|
||||
ss := EmptySessionState
|
||||
as := EmptyState
|
||||
|
||||
printer := func(s string) { golog.Println(s) }
|
||||
|
||||
for i := range count {
|
||||
name := gofakeit.Company()
|
||||
description := gofakeit.SentenceSimple()
|
||||
subscribed := gofakeit.Bool()
|
||||
visible := gofakeit.Bool()
|
||||
color := gofakeit.HexColor()
|
||||
include := pickRandom(IncludeInAvailabilities...)
|
||||
dawtId := gofakeit.UUID()
|
||||
daotId := gofakeit.UUID()
|
||||
cal := CalendarChange{
|
||||
Name: &name,
|
||||
Description: &description,
|
||||
IsSubscribed: &subscribed,
|
||||
Color: &color,
|
||||
IsVisible: &visible,
|
||||
IncludeInAvailability: &include,
|
||||
DefaultAlertsWithTime: map[string]jscalendar.Alert{
|
||||
dawtId: {
|
||||
Type: jscalendar.AlertType,
|
||||
Trigger: jscalendar.OffsetTrigger{
|
||||
Type: jscalendar.OffsetTriggerType,
|
||||
Offset: "-PT5M",
|
||||
RelativeTo: jscalendar.RelativeToStart,
|
||||
},
|
||||
Action: jscalendar.AlertActionDisplay,
|
||||
},
|
||||
},
|
||||
DefaultAlertsWithoutTime: map[string]jscalendar.Alert{
|
||||
daotId: {
|
||||
Type: jscalendar.AlertType,
|
||||
Trigger: jscalendar.OffsetTrigger{
|
||||
Type: jscalendar.OffsetTriggerType,
|
||||
Offset: "-PT24H",
|
||||
RelativeTo: jscalendar.RelativeToStart,
|
||||
},
|
||||
Action: jscalendar.AlertActionDisplay,
|
||||
},
|
||||
},
|
||||
}
|
||||
if i%2 == 0 {
|
||||
cal.SortOrder = uintPtr(gofakeit.Uint())
|
||||
boxes.sortOrdered = true
|
||||
}
|
||||
var sharing *CalendarRights = nil
|
||||
switch i % 4 {
|
||||
default:
|
||||
// no sharing
|
||||
case 1:
|
||||
sharing = &CalendarRights{
|
||||
MayReadFreeBusy: true,
|
||||
MayReadItems: true,
|
||||
MayRSVP: true,
|
||||
MayAdmin: false,
|
||||
MayDelete: false,
|
||||
MayWriteAll: false,
|
||||
MayWriteOwn: false,
|
||||
MayUpdatePrivate: false,
|
||||
}
|
||||
boxes.sharedReadWrite = true
|
||||
case 2:
|
||||
sharing = &CalendarRights{
|
||||
MayReadFreeBusy: true,
|
||||
MayReadItems: true,
|
||||
MayRSVP: true,
|
||||
MayAdmin: false,
|
||||
MayDelete: false,
|
||||
MayWriteAll: false,
|
||||
MayWriteOwn: true,
|
||||
MayUpdatePrivate: true,
|
||||
}
|
||||
boxes.sharedReadOnly = true
|
||||
case 3:
|
||||
sharing = &CalendarRights{
|
||||
MayReadFreeBusy: true,
|
||||
MayReadItems: true,
|
||||
MayRSVP: true,
|
||||
MayAdmin: false,
|
||||
MayDelete: true,
|
||||
MayWriteAll: true,
|
||||
MayWriteOwn: true,
|
||||
MayUpdatePrivate: true,
|
||||
}
|
||||
boxes.sharedDelete = true
|
||||
}
|
||||
if sharing != nil {
|
||||
numPrincipals := 1 + rand.Intn(len(principalIds)-1)
|
||||
m := make(map[PrincipalId]CalendarRights, numPrincipals)
|
||||
for _, p := range pickRandomN(numPrincipals, principalIds...) {
|
||||
m[p] = *sharing
|
||||
}
|
||||
cal.ShareWith = m
|
||||
}
|
||||
|
||||
result, err := s.Client.CreateCalendar(accountId, cal, ctx)
|
||||
if err != nil {
|
||||
return boxes, created, ss, as, err
|
||||
}
|
||||
require.NotEmpty(result.GetSessionState())
|
||||
require.NotEmpty(result.GetState())
|
||||
if ss != EmptySessionState {
|
||||
require.Equal(ss, result.GetSessionState())
|
||||
}
|
||||
if as != EmptyState {
|
||||
require.NotEqual(as, result.GetState())
|
||||
}
|
||||
require.NotNil(result.Payload)
|
||||
created = append(created, *result.Payload)
|
||||
ss = result.GetSessionState()
|
||||
as = result.GetState()
|
||||
|
||||
printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, result.Payload.Id))
|
||||
}
|
||||
return boxes, created, ss, as, nil
|
||||
}
|
||||
|
||||
type EventsBoxes struct {
|
||||
categories bool
|
||||
keywords bool
|
||||
mayInvite bool
|
||||
}
|
||||
|
||||
func (s *StalwartTest) fillEvents( //NOSONAR
|
||||
t *testing.T,
|
||||
count uint,
|
||||
ctx Context,
|
||||
user User,
|
||||
) (AccountId, string, map[string]CalendarEvent, EventsBoxes, error) {
|
||||
require := require.New(t)
|
||||
c, err := NewTestJmapClient(ctx.Session, user.Email, user.Password, true, true)
|
||||
require.NoError(err)
|
||||
defer c.Close()
|
||||
|
||||
boxes := EventsBoxes{}
|
||||
|
||||
printer := func(s string) { golog.Println(s) }
|
||||
|
||||
accountId := c.session.PrimaryAccounts.Calendars
|
||||
require.NotEmpty(accountId, "no primary account for calendars in session")
|
||||
|
||||
calendarId := ""
|
||||
{
|
||||
calendarsById, err := c.objectsById(accountId, CalendarType)
|
||||
require.NoError(err)
|
||||
|
||||
for id, calendar := range calendarsById {
|
||||
if isDefault, ok := calendar["isDefault"]; ok {
|
||||
if isDefault.(bool) {
|
||||
calendarId = id
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NotEmpty(calendarId)
|
||||
|
||||
filled := map[string]CalendarEvent{}
|
||||
for i := range count {
|
||||
uid := gofakeit.UUID()
|
||||
|
||||
isDraft := false
|
||||
mainLocationId := ""
|
||||
locationIds := []string{}
|
||||
locationObjs := map[string]jscalendar.Location{}
|
||||
{
|
||||
n := 1
|
||||
if i%4 == 0 {
|
||||
n++
|
||||
}
|
||||
for range n {
|
||||
locationId, locationObj := pickLocation()
|
||||
locationObjs[locationId] = locationObj
|
||||
locationIds = append(locationIds, locationId)
|
||||
if n > 0 && mainLocationId == "" {
|
||||
mainLocationId = locationId
|
||||
}
|
||||
}
|
||||
}
|
||||
virtualLocationId, virtualLocationObj := pickVirtualLocation()
|
||||
participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
|
||||
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
|
||||
tz := pickRandom(timezones...)
|
||||
daysDiff := rand.Intn(31) - 15
|
||||
t := time.Now().Add(time.Duration(daysDiff) * time.Hour * 24)
|
||||
h := pickRandom(9, 10, 11, 14, 15, 16, 18)
|
||||
m := pickRandom(0, 30)
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), h, m, 0, 0, t.Location())
|
||||
start := strings.ReplaceAll(t.Format(time.DateTime), " ", "T")
|
||||
title := gofakeit.Sentence(1)
|
||||
description := gofakeit.Paragraph(1+rand.Intn(3), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
|
||||
|
||||
descriptionFormat := pickRandom("text/plain", "text/html") //NOSONAR
|
||||
if descriptionFormat == "text/html" {
|
||||
description = toHtml(description)
|
||||
}
|
||||
status := pickRandom(jscalendar.Statuses...)
|
||||
freeBusy := pickRandom(jscalendar.FreeBusyStatuses...)
|
||||
privacy := pickRandom(jscalendar.Privacies...)
|
||||
color := pickRandom(basicColors...)
|
||||
locale := pickLocale()
|
||||
keywords := pickKeywords()
|
||||
categories := pickCategories()
|
||||
|
||||
sequence := uint(0)
|
||||
|
||||
alertId := id()
|
||||
alertOffset := pickRandom("-PT5M", "-PT10M", "-PT15M")
|
||||
|
||||
obj := CalendarEventChange{
|
||||
CalendarIds: toBoolMapS(calendarId),
|
||||
IsDraft: &isDraft,
|
||||
EventChange: jscalendar.EventChange{
|
||||
Type: jscalendar.EventType,
|
||||
Start: jscalendar.LocalDateTime(start),
|
||||
Duration: ptr(jscalendar.Duration(duration)),
|
||||
Status: &status,
|
||||
ObjectChange: jscalendar.ObjectChange{
|
||||
CommonObjectChange: jscalendar.CommonObjectChange{
|
||||
Uid: &uid,
|
||||
ProdId: &productName,
|
||||
Title: &title,
|
||||
Description: &description,
|
||||
DescriptionContentType: &descriptionFormat,
|
||||
Locale: &locale,
|
||||
Color: &color,
|
||||
},
|
||||
Sequence: uintPtr(sequence),
|
||||
ShowWithoutTime: falsep,
|
||||
FreeBusyStatus: &freeBusy,
|
||||
Privacy: &privacy,
|
||||
SentBy: organizerEmail,
|
||||
Participants: participantObjs,
|
||||
TimeZone: &tz,
|
||||
HideAttendees: falsep,
|
||||
ReplyTo: map[jscalendar.ReplyMethod]string{
|
||||
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, //NOSONAR
|
||||
},
|
||||
Locations: locationObjs,
|
||||
VirtualLocations: map[string]jscalendar.VirtualLocation{
|
||||
virtualLocationId: virtualLocationObj,
|
||||
},
|
||||
Alerts: map[string]jscalendar.Alert{
|
||||
alertId: {
|
||||
Type: jscalendar.AlertType,
|
||||
Trigger: jscalendar.OffsetTrigger{
|
||||
Type: jscalendar.OffsetTriggerType,
|
||||
Offset: jscalendar.SignedDuration(alertOffset),
|
||||
RelativeTo: jscalendar.RelativeToStart,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if EnableEventMayInviteFields {
|
||||
obj.MayInviteSelf = truep
|
||||
obj.MayInviteOthers = truep
|
||||
boxes.mayInvite = true
|
||||
}
|
||||
|
||||
if len(keywords) > 0 {
|
||||
obj.Keywords = keywords
|
||||
boxes.keywords = true
|
||||
}
|
||||
|
||||
if len(categories) > 0 {
|
||||
obj.Categories = categories
|
||||
boxes.categories = true
|
||||
}
|
||||
|
||||
if mainLocationId != "" {
|
||||
obj.MainLocationId = &mainLocationId
|
||||
}
|
||||
|
||||
err = propmap(i%2 == 0, 1, 1, &obj.Links, func(int, string) (jscalendar.Link, error) {
|
||||
mime := ""
|
||||
uri := ""
|
||||
rel := jscalendar.RelAbout
|
||||
switch rand.Intn(2) {
|
||||
case 0:
|
||||
size := pickRandom(16, 24, 32, 48, 64)
|
||||
img := gofakeit.ImagePng(size, size)
|
||||
mime = "image/png"
|
||||
uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
|
||||
default:
|
||||
mime = "image/jpeg" //NOSONAR
|
||||
uri = externalImageUri()
|
||||
}
|
||||
return jscalendar.Link{
|
||||
Type: jscalendar.LinkType,
|
||||
Href: uri,
|
||||
ContentType: mime,
|
||||
Rel: rel,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if rand.Intn(10) > 7 {
|
||||
frequency := pickRandom(jscalendar.FrequencyWeekly, jscalendar.FrequencyDaily)
|
||||
interval := pickRandom(1, 2)
|
||||
count := 1
|
||||
if frequency == jscalendar.FrequencyWeekly {
|
||||
count = 1 + rand.Intn(8)
|
||||
} else {
|
||||
count = 1 + rand.Intn(4)
|
||||
}
|
||||
rr := jscalendar.RecurrenceRule{
|
||||
Type: jscalendar.RecurrenceRuleType,
|
||||
Frequency: frequency,
|
||||
Interval: uint(interval),
|
||||
Rscale: jscalendar.RscaleIso8601,
|
||||
Skip: jscalendar.SkipOmit,
|
||||
FirstDayOfWeek: jscalendar.DayOfWeekMonday,
|
||||
Count: uint(count),
|
||||
}
|
||||
obj.RecurrenceRule = &rr
|
||||
}
|
||||
|
||||
result, err := s.Client.CreateCalendarEvent(accountId, obj, ctx)
|
||||
if err != nil {
|
||||
return accountId, calendarId, nil, boxes, err
|
||||
}
|
||||
|
||||
filled[result.Payload.Id] = *result.Payload
|
||||
|
||||
printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid))
|
||||
}
|
||||
return accountId, calendarId, filled, boxes, nil
|
||||
}
|
||||
|
||||
var rooms = []jscalendar.Location{
|
||||
{
|
||||
Type: jscalendar.LocationType,
|
||||
Name: "Office meeting room upstairs",
|
||||
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
|
||||
Coordinates: "geo:52.5335389,13.4103296",
|
||||
Links: map[string]jscalendar.Link{
|
||||
"l1": {Href: "https://www.heinlein-support.de/"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: jscalendar.LocationType,
|
||||
Name: "office-nue",
|
||||
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
|
||||
Coordinates: "geo:49.4723337,11.1042282",
|
||||
Links: map[string]jscalendar.Link{
|
||||
"l2": {Href: "https://www.workandpepper.de/"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: jscalendar.LocationType,
|
||||
Name: "Meetingraum Prenzlauer Berg",
|
||||
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionPublic),
|
||||
Coordinates: "geo:52.554222,13.4142387",
|
||||
Links: map[string]jscalendar.Link{
|
||||
"l3": {Href: "https://www.spacebase.com/en/venue/meeting-room-prenzlauer-be-11499/"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: jscalendar.LocationType,
|
||||
Name: "Meetingraum LIANE 1",
|
||||
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionLibrary),
|
||||
Coordinates: "geo:52.4854301,13.4224763",
|
||||
Links: map[string]jscalendar.Link{
|
||||
"l4": {Href: "https://www.spacebase.com/en/venue/rent-a-jungle-8372/"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: jscalendar.LocationType,
|
||||
Name: "Dark Horse",
|
||||
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
|
||||
Coordinates: "geo:52.4942254,13.4346015",
|
||||
Links: map[string]jscalendar.Link{
|
||||
"l5": {Href: "https://www.spacebase.com/en/event-venue/workshop-white-space-2667/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var virtualRooms = []jscalendar.VirtualLocation{
|
||||
{
|
||||
Type: jscalendar.VirtualLocationType,
|
||||
Name: "opentalk",
|
||||
Uri: "https://meet.opentalk.eu/fake/room/06fb8f7d-42eb-4212-8112-769fac2cb111",
|
||||
Features: toBoolMapS(
|
||||
jscalendar.VirtualLocationFeatureAudio,
|
||||
jscalendar.VirtualLocationFeatureChat,
|
||||
jscalendar.VirtualLocationFeatureVideo,
|
||||
jscalendar.VirtualLocationFeatureScreen,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
func pickLocation() (string, jscalendar.Location) {
|
||||
locationId := id()
|
||||
room := rooms[rand.Intn(len(rooms))]
|
||||
return locationId, room
|
||||
}
|
||||
|
||||
func pickVirtualLocation() (string, jscalendar.VirtualLocation) {
|
||||
locationId := id()
|
||||
vroom := virtualRooms[rand.Intn(len(virtualRooms))]
|
||||
return locationId, vroom
|
||||
}
|
||||
|
||||
var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner)
|
||||
var RegularRoles = toBoolMapS(jscalendar.RoleOptional)
|
||||
|
||||
func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]jscalendar.Participant, string) {
|
||||
options := structs.Concat(locationIds, virtualLocationIds)
|
||||
n := 1 + rand.Intn(4)
|
||||
objs := map[string]jscalendar.Participant{}
|
||||
organizerId, organizerEmail, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "")
|
||||
objs[organizerId] = organizerObj
|
||||
for i := 1; i < n; i++ {
|
||||
id, _, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
|
||||
objs[id] = participantObj
|
||||
}
|
||||
return objs, organizerEmail
|
||||
}
|
||||
|
||||
func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, jscalendar.Participant) {
|
||||
participantId := id()
|
||||
person := gofakeit.Person()
|
||||
roles := RegularRoles
|
||||
if i == 0 {
|
||||
roles = ChairRoles
|
||||
}
|
||||
status := jscalendar.ParticipationStatusAccepted
|
||||
if i != 0 {
|
||||
status = pickRandom(
|
||||
jscalendar.ParticipationStatusNeedsAction,
|
||||
jscalendar.ParticipationStatusAccepted,
|
||||
jscalendar.ParticipationStatusDeclined,
|
||||
jscalendar.ParticipationStatusTentative,
|
||||
)
|
||||
//, delegated + set "delegatedTo"
|
||||
}
|
||||
statusComment := ""
|
||||
if rand.Intn(5) >= 3 {
|
||||
statusComment = gofakeit.HipsterSentence(1 + rand.Intn(5))
|
||||
}
|
||||
if i == 0 {
|
||||
organizerEmail = person.Contact.Email
|
||||
organizerId = participantId
|
||||
}
|
||||
|
||||
name := person.FirstName + " " + person.LastName
|
||||
email := person.Contact.Email
|
||||
description := gofakeit.SentenceSimple()
|
||||
descriptionContentType := pickRandom("text/html", "text/plain")
|
||||
if descriptionContentType == "text/html" {
|
||||
description = toHtml(description)
|
||||
}
|
||||
language := pickLanguage()
|
||||
updated := "2025-10-01T01:59:12Z"
|
||||
updatedTime, err := time.Parse(time.RFC3339, updated)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var calendarAddress string
|
||||
{
|
||||
pos := strings.LastIndex(email, "@")
|
||||
if pos < 0 {
|
||||
calendarAddress = email
|
||||
} else {
|
||||
local := email[0:pos]
|
||||
domain := email[pos+1:]
|
||||
calendarAddress = local + "+itip+" + uid + "@" + "itip." + domain
|
||||
}
|
||||
}
|
||||
|
||||
o := jscalendar.Participant{
|
||||
Type: jscalendar.ParticipantType,
|
||||
Name: name,
|
||||
Email: email,
|
||||
Kind: jscalendar.ParticipantKindIndividual,
|
||||
CalendarAddress: calendarAddress,
|
||||
Roles: roles,
|
||||
LocationId: locationId,
|
||||
Language: language,
|
||||
ParticipationStatus: status,
|
||||
ParticipationComment: statusComment,
|
||||
ExpectReply: true,
|
||||
ScheduleAgent: jscalendar.ScheduleAgentServer,
|
||||
ScheduleSequence: uint(1),
|
||||
ScheduleStatus: []string{"1.0"},
|
||||
ScheduleUpdated: updatedTime,
|
||||
SentBy: organizerEmail,
|
||||
InvitedBy: organizerId,
|
||||
ScheduleId: "mailto:" + email,
|
||||
}
|
||||
|
||||
if EnableEventParticipantDescriptionFields {
|
||||
o.Description = description
|
||||
o.DescriptionContentType = descriptionContentType
|
||||
}
|
||||
|
||||
err = propmap(i%2 == 0, 1, 2, &o.Links, func(int, string) (jscalendar.Link, error) {
|
||||
href := externalImageUri()
|
||||
title := person.FirstName + "'s Cake Day pick"
|
||||
return jscalendar.Link{
|
||||
Type: jscalendar.LinkType,
|
||||
Href: href,
|
||||
ContentType: "image/jpeg",
|
||||
Rel: jscalendar.RelIcon,
|
||||
Display: jscalendar.DisplayBadge,
|
||||
Title: title,
|
||||
}, nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return participantId, person.Contact.Email, o
|
||||
}
|
||||
|
||||
var Keywords = []string{
|
||||
"office",
|
||||
"important",
|
||||
"sales",
|
||||
"coordination",
|
||||
"decision",
|
||||
}
|
||||
|
||||
func pickKeywords() map[string]bool {
|
||||
return toBoolMap(pickRandoms(Keywords...))
|
||||
}
|
||||
|
||||
var Categories = []string{
|
||||
"http://opencloud.eu/categories/secret",
|
||||
"http://opencloud.eu/categories/internal",
|
||||
}
|
||||
|
||||
func pickCategories() map[string]bool {
|
||||
return toBoolMap(pickRandoms(Categories...))
|
||||
}
|
||||
@@ -1,784 +0,0 @@
|
||||
package jmaptest
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
. "github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEmails(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
count := 15 + rand.Intn(20)
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
s, err := NewStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
|
||||
user := pickUser()
|
||||
session := s.Session(user.Email)
|
||||
ctx := s.Context(session)
|
||||
|
||||
accountId := session.PrimaryAccounts.Mail
|
||||
|
||||
inboxId, inboxFolder := s.findInbox(t, accountId, ctx)
|
||||
|
||||
var threads int = 0
|
||||
var mails []filledMail = nil
|
||||
{
|
||||
mails, threads, err = s.fillEmailsWithImap(inboxFolder, count, false, user)
|
||||
require.NoError(err)
|
||||
}
|
||||
mailsByMessageId := structs.Index(mails, func(mail filledMail) string { return mail.messageId })
|
||||
|
||||
{
|
||||
{
|
||||
result, err := s.Client.GetIdentities(accountId, []string{}, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.Len(result.Payload.List, 2)
|
||||
emailMatches := structs.Filter(result.Payload.List, func(i Identity) bool { return i.Email == user.Email })
|
||||
require.Len(emailMatches, 1)
|
||||
aliasMatches := structs.Filter(result.Payload.List, func(i Identity) bool { return i.Email == user.Alias })
|
||||
require.Len(aliasMatches, 1)
|
||||
}
|
||||
|
||||
{
|
||||
result, err := s.Client.GetAllMailboxes([]AccountId{accountId}, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
resp := result.Payload[accountId]
|
||||
mailboxesUnreadByRole := map[string]int{}
|
||||
for _, m := range resp {
|
||||
if m.Role != "" {
|
||||
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
|
||||
}
|
||||
}
|
||||
require.LessOrEqual(mailboxesUnreadByRole["inbox"], count)
|
||||
}
|
||||
|
||||
{
|
||||
result, err := s.Client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, true, false, 0, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
|
||||
require.Equalf(threads, len(result.Payload.Results), "the number of collapsed emails in the inbox is expected to be %v, but is actually %v", threads, len(result.Payload.Results))
|
||||
for _, e := range result.Payload.Results {
|
||||
require.Len(e.MessageId, 1)
|
||||
expectation, ok := mailsByMessageId[e.MessageId[0]]
|
||||
require.True(ok)
|
||||
matchEmail(t, e, expectation, false)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
result, err := s.Client.GetAllEmailsInMailbox(accountId, inboxId, NullQueryParams, nil, false, false, 0, true, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
|
||||
require.Equalf(count, len(result.Payload.Results), "the number of emails in the inbox is expected to be %v, but is actually %v", count, len(result.Payload.Results))
|
||||
for _, e := range result.Payload.Results {
|
||||
require.Len(e.MessageId, 1)
|
||||
expectation, ok := mailsByMessageId[e.MessageId[0]]
|
||||
require.True(ok)
|
||||
matchEmail(t, e, expectation, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendingEmails(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
s, err := NewStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
|
||||
from := pickUser()
|
||||
session := s.Session(from.Email)
|
||||
ctx := s.Context(session)
|
||||
accountId := session.PrimaryAccounts.Mail
|
||||
|
||||
var to User
|
||||
{
|
||||
others := structs.Filter(users[:], func(u User) bool { return u.Name != from.Name })
|
||||
to = others[rand.Intn(len(others))]
|
||||
}
|
||||
toSession := s.Session(to.Email)
|
||||
toAccountId := toSession.PrimaryAccounts.Mail
|
||||
|
||||
var cc User
|
||||
{
|
||||
others := structs.Filter(users[:], func(u User) bool { return u.Name != from.Name && u.Name != to.Name })
|
||||
cc = others[rand.Intn(len(others))]
|
||||
}
|
||||
ccSession := s.Session(cc.Email)
|
||||
ccAccountId := ccSession.PrimaryAccounts.Mail
|
||||
|
||||
var mailboxPerRole map[string]Mailbox
|
||||
{
|
||||
result, err := s.Client.GetAllMailboxes([]AccountId{accountId}, ctx)
|
||||
require.NoError(err)
|
||||
mailboxPerRole = structs.Index(result.Payload[accountId], func(m Mailbox) string { return m.Role })
|
||||
require.Contains(mailboxPerRole, JmapMailboxRoleInbox)
|
||||
require.Contains(mailboxPerRole, JmapMailboxRoleDrafts)
|
||||
require.Contains(mailboxPerRole, JmapMailboxRoleSent)
|
||||
require.Contains(mailboxPerRole, JmapMailboxRoleTrash)
|
||||
}
|
||||
{
|
||||
roles := []string{JmapMailboxRoleDrafts, JmapMailboxRoleSent, JmapMailboxRoleInbox}
|
||||
result, err := s.Client.SearchMailboxIdsPerRole([]AccountId{accountId}, roles, ctx)
|
||||
require.NoError(err)
|
||||
require.Contains(result.Payload, accountId)
|
||||
a := result.Payload[accountId]
|
||||
for _, role := range roles {
|
||||
require.Contains(a, role)
|
||||
}
|
||||
}
|
||||
|
||||
// let's ensure that the recipients have zero emails in their mailboxes before we send them any
|
||||
for _, u := range []struct {
|
||||
accountId AccountId
|
||||
session *Session
|
||||
}{{toAccountId, toSession}, {ccAccountId, ccSession}} {
|
||||
uctx := Context{
|
||||
Session: u.session,
|
||||
Context: ctx.Context,
|
||||
Logger: ctx.Logger,
|
||||
AcceptLanguage: ctx.AcceptLanguage,
|
||||
}
|
||||
result, err := s.Client.GetAllMailboxes([]AccountId{u.accountId}, uctx)
|
||||
require.NoError(err)
|
||||
for _, mailbox := range result.Payload[u.accountId] {
|
||||
require.Equal(0, mailbox.TotalEmails)
|
||||
}
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("Test Subject %d", 10000+rand.Intn(90000))
|
||||
fromName := fmt.Sprintf("%s (test %d)", from.Name, 1000+rand.Intn(9000))
|
||||
sender := EmailAddress{Email: from.Email, Name: from.Description}
|
||||
|
||||
{
|
||||
var identity Identity
|
||||
{
|
||||
result, err := s.Client.GetIdentities(accountId, []string{}, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload.List, 2)
|
||||
matchesAlias := structs.Filter(result.Payload.List, func(i Identity) bool { return i.Email == from.Alias })
|
||||
require.Len(matchesAlias, 1)
|
||||
identity = matchesAlias[0]
|
||||
}
|
||||
|
||||
create := EmailChange{
|
||||
Keywords: toBoolMapS("test"),
|
||||
Subject: subject,
|
||||
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
|
||||
}
|
||||
var created *Email
|
||||
{
|
||||
result, err := s.Client.CreateEmail(accountId, create, "", ctx)
|
||||
require.NoError(err)
|
||||
created = result.Payload
|
||||
require.NotEmpty(created.Id)
|
||||
}
|
||||
|
||||
{
|
||||
result, err := s.Client.GetEmails(accountId, []string{created.Id}, true, 0, false, false, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload.List, 1)
|
||||
require.Empty(result.Payload.NotFound)
|
||||
email := result.Payload.List[0]
|
||||
require.Equal(created.Id, email.Id)
|
||||
require.Len(email.MailboxIds, 1)
|
||||
require.Contains(email.MailboxIds, mailboxPerRole[JmapMailboxRoleDrafts].Id)
|
||||
}
|
||||
|
||||
update := EmailChange{
|
||||
From: []EmailAddress{{Name: fromName, Email: from.Email}},
|
||||
To: []EmailAddress{{Name: to.Description, Email: to.Email}},
|
||||
Cc: []EmailAddress{{Name: cc.Description, Email: cc.Email}},
|
||||
Sender: []EmailAddress{sender},
|
||||
Keywords: toBoolMapS("test"),
|
||||
Subject: subject,
|
||||
MailboxIds: toBoolMapS(mailboxPerRole[JmapMailboxRoleDrafts].Id),
|
||||
}
|
||||
var updated *Email
|
||||
{
|
||||
result, err := s.Client.CreateEmail(accountId, update, created.Id, ctx)
|
||||
require.NoError(err)
|
||||
updated = result.Payload
|
||||
require.NotNil(updated)
|
||||
require.NotEmpty(updated.Id)
|
||||
require.NotEqual(created.Id, updated.Id)
|
||||
}
|
||||
|
||||
var updatedMailboxId string
|
||||
{
|
||||
result, err := s.Client.GetEmails(accountId, []string{created.Id, updated.Id}, true, 0, false, false, ctx)
|
||||
require.NoError(err)
|
||||
require.Len(result.Payload.List, 1)
|
||||
require.Len(result.Payload.NotFound, 1)
|
||||
email := result.Payload.List[0]
|
||||
require.Equal(updated.Id, email.Id)
|
||||
require.Len(email.MailboxIds, 1)
|
||||
require.Contains(email.MailboxIds, mailboxPerRole[JmapMailboxRoleDrafts].Id)
|
||||
require.Equal(result.Payload.NotFound[0], created.Id)
|
||||
var ok bool
|
||||
updatedMailboxId, ok = firstKey(email.MailboxIds)
|
||||
require.True(ok)
|
||||
}
|
||||
|
||||
move := MoveMail{
|
||||
FromMailboxId: updatedMailboxId,
|
||||
ToMailboxId: mailboxPerRole[JmapMailboxRoleSent].Id,
|
||||
}
|
||||
|
||||
var sub EmailSubmission
|
||||
{
|
||||
result, err := s.Client.SubmitEmail(accountId, identity.Id, updated.Id, &move, ctx)
|
||||
require.NoError(err)
|
||||
sub = result.Payload
|
||||
require.NotEmpty(sub.Id)
|
||||
require.NotEmpty(sub.ThreadId)
|
||||
require.Equal(updated.Id, sub.EmailId)
|
||||
require.Equal(identity.Id, sub.IdentityId)
|
||||
require.Equal(sub.UndoStatus, UndoStatusPending) // this *might* be fragile: if the server is fast enough, would we get "final" here?
|
||||
require.Empty(sub.DsnBlobIds)
|
||||
require.Empty(sub.MdnBlobIds)
|
||||
require.Equal(from.Alias, sub.Envelope.MailFrom.Email)
|
||||
require.Nil(sub.Envelope.MailFrom.Parameters)
|
||||
require.Len(sub.Envelope.RcptTo, 2)
|
||||
require.Contains(sub.Envelope.RcptTo, Address{Email: to.Email})
|
||||
require.Contains(sub.Envelope.RcptTo, Address{Email: cc.Email})
|
||||
require.NotZero(sub.SendAt)
|
||||
require.Len(sub.DeliveryStatus, 2)
|
||||
require.Contains(sub.DeliveryStatus, to.Email)
|
||||
require.Contains(sub.DeliveryStatus, cc.Email)
|
||||
}
|
||||
|
||||
a := 0
|
||||
maxAttempts := 3
|
||||
delivery := sub.DeliveryStatus[to.Email].Delivered
|
||||
|
||||
for delivery != DeliveredYes {
|
||||
require.NotEqual(DeliveredNo, delivery)
|
||||
a++
|
||||
if a >= maxAttempts {
|
||||
break
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
result, err := s.Client.GetEmailSubmissionStatus(accountId, []string{sub.Id}, ctx)
|
||||
require.NoError(err)
|
||||
require.Empty(result.Payload.NotFound)
|
||||
submittedIds := structs.Map(result.Payload.List, func(s EmailSubmission) string { return s.Id })
|
||||
require.Contains(submittedIds, sub.Id)
|
||||
subs := structs.Index(result.Payload.List, func(s EmailSubmission) string { return s.Id })
|
||||
delivery = subs[sub.Id].DeliveryStatus[to.Email].Delivered
|
||||
}
|
||||
|
||||
require.Contains([]DeliveryStatusDelivered{DeliveredYes, DeliveredUnknown}, delivery)
|
||||
|
||||
for _, r := range []struct {
|
||||
user User
|
||||
accountId AccountId
|
||||
session *Session
|
||||
}{{to, toAccountId, toSession}, {cc, ccAccountId, ccSession}} {
|
||||
rctx := Context{
|
||||
Session: r.session,
|
||||
Context: ctx.Context,
|
||||
Logger: ctx.Logger,
|
||||
AcceptLanguage: ctx.AcceptLanguage,
|
||||
}
|
||||
inboxId := ""
|
||||
{
|
||||
result, err := s.Client.GetAllMailboxes([]AccountId{r.accountId}, rctx)
|
||||
require.NoError(err)
|
||||
for _, mailbox := range result.Payload[r.accountId] {
|
||||
if mailbox.Role == JmapMailboxRoleInbox {
|
||||
inboxId = mailbox.Id
|
||||
require.Equal(1, mailbox.TotalEmails)
|
||||
}
|
||||
}
|
||||
require.NotEmpty(inboxId, "failed to find the Mailbox with the 'inbox' role for %v", r.user.Email)
|
||||
}
|
||||
|
||||
result, err := s.Client.QueryEmails([]AccountId{r.accountId}, EmailFilterCondition{InMailbox: inboxId}, 0, 0, true, 0, rctx)
|
||||
require.NoError(err)
|
||||
require.Contains(result.Payload, r.accountId)
|
||||
require.Len(result.Payload[r.accountId].Results, 1)
|
||||
received := result.Payload[r.accountId].Results[0]
|
||||
require.Len(received.From, 1)
|
||||
require.Equal(from.Email, received.From[0].Email)
|
||||
require.Equal(fromName, received.From[0].Name)
|
||||
require.Len(received.Sender, 1)
|
||||
require.Equal(from.Email, received.Sender[0].Email)
|
||||
require.Equal(from.Description, received.Sender[0].Name)
|
||||
require.Len(received.To, 1)
|
||||
require.Equal(to.Email, received.To[0].Email)
|
||||
require.Equal(to.Description, received.To[0].Name)
|
||||
require.Len(received.Cc, 1)
|
||||
require.Equal(cc.Email, received.Cc[0].Email)
|
||||
require.Equal(cc.Description, received.Cc[0].Name)
|
||||
require.Equal(subject, received.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func matchEmail(t *testing.T, actual Email, expected filledMail, hasBodies bool) {
|
||||
require := require.New(t)
|
||||
require.Len(actual.MessageId, 1)
|
||||
require.Equal(expected.messageId, actual.MessageId[0])
|
||||
require.Equal(expected.subject, actual.Subject)
|
||||
require.NotEmpty(actual.Preview)
|
||||
if hasBodies {
|
||||
require.Len(actual.TextBody, 1)
|
||||
textBody := actual.TextBody[0]
|
||||
partId := textBody.PartId
|
||||
require.Contains(actual.BodyValues, partId)
|
||||
content := actual.BodyValues[partId].Value
|
||||
require.True(strings.Contains(content, actual.Preview), "text body contains preview")
|
||||
} else {
|
||||
require.Empty(actual.BodyValues)
|
||||
}
|
||||
require.ElementsMatch(slices.Collect(maps.Keys(actual.Keywords)), expected.keywords)
|
||||
|
||||
{
|
||||
list := make([]filledAttachment, len(actual.Attachments))
|
||||
for i, a := range actual.Attachments {
|
||||
list[i] = filledAttachment{
|
||||
name: a.Name,
|
||||
size: a.Size,
|
||||
mimeType: a.Type,
|
||||
disposition: a.Disposition,
|
||||
}
|
||||
require.NotEmpty(a.BlobId)
|
||||
require.NotEmpty(a.PartId)
|
||||
}
|
||||
|
||||
require.ElementsMatch(list, expected.attachments)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StalwartTest) findInbox(t *testing.T, accountId AccountId, ctx Context) (string, string) {
|
||||
require := require.New(t)
|
||||
result, err := s.Client.GetAllMailboxes([]AccountId{accountId}, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(ctx.Session.State, result.GetSessionState())
|
||||
require.Len(result.Payload, 1)
|
||||
require.Contains(result.Payload, accountId)
|
||||
resp := result.Payload[accountId]
|
||||
|
||||
mailboxesNameByRole := map[string]string{}
|
||||
mailboxesUnreadByRole := map[string]int{}
|
||||
for _, m := range resp {
|
||||
if m.Role != "" {
|
||||
mailboxesNameByRole[m.Role] = m.Name
|
||||
mailboxesUnreadByRole[m.Role] = m.UnreadEmails
|
||||
}
|
||||
}
|
||||
require.Contains(mailboxesNameByRole, "inbox")
|
||||
require.Contains(mailboxesUnreadByRole, "inbox")
|
||||
require.Zero(mailboxesUnreadByRole["inbox"])
|
||||
|
||||
inboxId := mailboxId("inbox", resp)
|
||||
require.NotEmpty(inboxId)
|
||||
inboxFolder := mailboxesNameByRole["inbox"]
|
||||
require.NotEmpty(inboxFolder)
|
||||
return inboxId, inboxFolder
|
||||
}
|
||||
|
||||
var emailSplitter = regexp.MustCompile("(.+)@(.+)$")
|
||||
|
||||
func htmlFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
|
||||
return msg.HTML([]byte(toHtml(body)))
|
||||
}
|
||||
|
||||
func textFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
|
||||
return msg.Text([]byte(body))
|
||||
}
|
||||
|
||||
func bothFormat(body string, msg enmime.MailBuilder) enmime.MailBuilder {
|
||||
msg = htmlFormat(body, msg)
|
||||
msg = textFormat(body, msg)
|
||||
return msg
|
||||
}
|
||||
|
||||
var formats = []func(string, enmime.MailBuilder) enmime.MailBuilder{
|
||||
htmlFormat,
|
||||
textFormat,
|
||||
bothFormat,
|
||||
}
|
||||
|
||||
type sender struct {
|
||||
first string
|
||||
last string
|
||||
from string
|
||||
sender string
|
||||
}
|
||||
|
||||
func (s sender) inject(b enmime.MailBuilder) enmime.MailBuilder {
|
||||
return b.From(s.first+" "+s.last, s.from).Header("Sender", s.sender)
|
||||
}
|
||||
|
||||
type senderGenerator struct {
|
||||
senders []sender
|
||||
}
|
||||
|
||||
func newSenderGenerator(numSenders int) senderGenerator {
|
||||
senders := make([]sender, numSenders)
|
||||
for i := range numSenders {
|
||||
person := gofakeit.Person()
|
||||
senders[i] = sender{
|
||||
first: person.FirstName,
|
||||
last: person.LastName,
|
||||
from: person.Contact.Email,
|
||||
sender: person.FirstName + " " + person.LastName + "<" + person.Contact.Email + ">",
|
||||
}
|
||||
}
|
||||
return senderGenerator{
|
||||
senders: senders,
|
||||
}
|
||||
}
|
||||
|
||||
func (s senderGenerator) nextSender() *sender {
|
||||
if len(s.senders) < 1 {
|
||||
panic("failed to determine a sender to use")
|
||||
} else {
|
||||
return &s.senders[rand.Intn(len(s.senders))]
|
||||
}
|
||||
}
|
||||
|
||||
func fakeFilename(extension string) string {
|
||||
return strings.ReplaceAll(gofakeit.Product().Name, " ", "_") + extension
|
||||
}
|
||||
|
||||
func mailboxId(role string, mailboxes []Mailbox) string {
|
||||
for _, m := range mailboxes {
|
||||
if m.Role == role {
|
||||
return m.Id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type filledAttachment struct {
|
||||
name string
|
||||
size int
|
||||
mimeType string
|
||||
disposition string
|
||||
}
|
||||
|
||||
type filledMail struct {
|
||||
uid int
|
||||
attachments []filledAttachment
|
||||
subject string
|
||||
testId string
|
||||
messageId string
|
||||
keywords []string
|
||||
}
|
||||
|
||||
var allKeywords = map[string]imap.Flag{
|
||||
JmapKeywordAnswered: imap.FlagAnswered,
|
||||
JmapKeywordDraft: imap.FlagDraft,
|
||||
JmapKeywordFlagged: imap.FlagFlagged,
|
||||
JmapKeywordForwarded: imap.FlagForwarded,
|
||||
JmapKeywordJunk: imap.FlagJunk,
|
||||
JmapKeywordMdnSent: imap.FlagMDNSent,
|
||||
JmapKeywordNotJunk: imap.FlagNotJunk,
|
||||
JmapKeywordPhishing: imap.FlagPhishing,
|
||||
JmapKeywordSeen: imap.FlagSeen,
|
||||
}
|
||||
|
||||
func (s *StalwartTest) fillEmailsWithImap(folder string, count int, empty bool, user User) ([]filledMail, int, error) { //NOSONAR
|
||||
to := fmt.Sprintf("%s <%s>", user.Description, user.Email)
|
||||
ccEvery := 2
|
||||
bccEvery := 3
|
||||
attachmentEvery := 2
|
||||
senders := max(count/4, 1)
|
||||
maxThreadSize := 6
|
||||
maxAttachments := 4
|
||||
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: true}
|
||||
|
||||
c, err := imapclient.DialTLS(net.JoinHostPort(s.ip, strconv.FormatUint(uint64(s.imapPort), 10)), &imapclient.Options{TLSConfig: tlsConfig})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
defer func(imap *imapclient.Client) {
|
||||
err := imap.Close()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(c)
|
||||
|
||||
if err = c.Login(user.Email, user.Password).Wait(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if _, err = c.Select(folder, &imap.SelectOptions{ReadOnly: false}).Wait(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if empty {
|
||||
if ids, err := c.Search(&imap.SearchCriteria{}, nil).Wait(); err != nil {
|
||||
return nil, 0, err
|
||||
} else {
|
||||
if len(ids.AllSeqNums()) > 0 {
|
||||
storeFlags := imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Flags: []imap.Flag{imap.FlagDeleted},
|
||||
Silent: true,
|
||||
}
|
||||
if err = c.Store(ids.All, &storeFlags, nil).Close(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err = c.Expunge().Close(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
log.Printf("🗑️ deleted %d messages in %s", len(ids.AllSeqNums()), folder)
|
||||
} else {
|
||||
log.Printf("ℹ️ did not delete any messages, %s is empty", folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
address, err := mail.ParseAddress(to)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
displayName := address.Name
|
||||
|
||||
addressParts := emailSplitter.FindAllStringSubmatch(address.Address, 3)
|
||||
if len(addressParts) != 1 {
|
||||
return nil, 0, fmt.Errorf("address does not have one part: '%v' -> %v", address.Address, addressParts)
|
||||
}
|
||||
if len(addressParts[0]) != 3 {
|
||||
return nil, 0, fmt.Errorf("first address part does not have a size of 3: '%v'", addressParts[0])
|
||||
}
|
||||
|
||||
domain := addressParts[0][2]
|
||||
|
||||
toName := displayName
|
||||
toAddress := fmt.Sprintf("%s@%s", user.Email, domain)
|
||||
ccName1 := "Team Lead"
|
||||
ccAddress1 := fmt.Sprintf("lead@%s", domain)
|
||||
ccName2 := "Coworker"
|
||||
ccAddress2 := fmt.Sprintf("coworker@%s", domain)
|
||||
bccName := "HR"
|
||||
bccAddress := fmt.Sprintf("corporate@%s", domain)
|
||||
|
||||
sg := newSenderGenerator(senders)
|
||||
thread := 0
|
||||
mails := make([]filledMail, count)
|
||||
for i := 0; i < count; thread++ {
|
||||
threadMessageId := fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
|
||||
threadSubject := strings.Trim(gofakeit.SentenceSimple(), ".") // remove the . at the end, looks weird
|
||||
threadSize := 1 + rand.Intn(maxThreadSize)
|
||||
lastMessageId := ""
|
||||
lastSubject := ""
|
||||
for t := 0; i < count && t < threadSize; t++ {
|
||||
sender := sg.nextSender()
|
||||
|
||||
format := formats[i%len(formats)]
|
||||
text := gofakeit.Paragraph(2+rand.Intn(9), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
|
||||
|
||||
msg := sender.inject(enmime.Builder().To(toName, toAddress))
|
||||
|
||||
messageId := ""
|
||||
if lastMessageId == "" {
|
||||
// start a new thread
|
||||
msg = msg.Header("Message-ID", threadMessageId).Subject(threadSubject)
|
||||
lastMessageId = threadMessageId
|
||||
lastSubject = threadSubject
|
||||
messageId = threadMessageId
|
||||
} else {
|
||||
// we're continuing a thread
|
||||
messageId = fmt.Sprintf("%d.%d@%s", time.Now().Unix(), 1000000+rand.Intn(8999999), domain)
|
||||
inReplyTo := ""
|
||||
subject := ""
|
||||
switch rand.Intn(2) {
|
||||
case 0:
|
||||
// reply to first post in thread
|
||||
subject = "Re: " + threadSubject
|
||||
inReplyTo = threadMessageId
|
||||
default:
|
||||
// reply to last addition to thread
|
||||
subject = "Re: " + lastSubject
|
||||
inReplyTo = lastMessageId
|
||||
}
|
||||
msg = msg.Header("Message-ID", messageId).Header("In-Reply-To", inReplyTo).Subject(subject)
|
||||
lastMessageId = messageId
|
||||
lastSubject = subject
|
||||
}
|
||||
|
||||
if i%ccEvery == 0 {
|
||||
msg = msg.CCAddrs([]mail.Address{{Name: ccName1, Address: ccAddress1}, {Name: ccName2, Address: ccAddress2}})
|
||||
}
|
||||
if i%bccEvery == 0 {
|
||||
msg = msg.BCC(bccName, bccAddress)
|
||||
}
|
||||
|
||||
numAttachments := 0
|
||||
attachments := []filledAttachment{}
|
||||
if maxAttachments > 0 && i%attachmentEvery == 0 {
|
||||
numAttachments = rand.Intn(maxAttachments)
|
||||
for a := range numAttachments {
|
||||
switch rand.Intn(2) {
|
||||
case 0:
|
||||
filename := fakeFilename(".txt")
|
||||
attachment := gofakeit.Paragraph(2+rand.Intn(4), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
|
||||
data := []byte(attachment)
|
||||
msg = msg.AddAttachment(data, "text/plain", filename)
|
||||
attachments = append(attachments, filledAttachment{
|
||||
name: filename,
|
||||
size: len(data),
|
||||
mimeType: "text/plain",
|
||||
disposition: "attachment",
|
||||
})
|
||||
default:
|
||||
filename := ""
|
||||
mimetype := ""
|
||||
var image []byte = nil
|
||||
switch rand.Intn(2) {
|
||||
case 0:
|
||||
filename = fakeFilename(".png")
|
||||
mimetype = "image/png"
|
||||
image = gofakeit.ImagePng(512, 512)
|
||||
default:
|
||||
filename = fakeFilename(".jpg")
|
||||
mimetype = "image/jpeg"
|
||||
image = gofakeit.ImageJpeg(400, 200)
|
||||
}
|
||||
disposition := ""
|
||||
switch rand.Intn(2) {
|
||||
case 0:
|
||||
msg = msg.AddAttachment(image, mimetype, filename)
|
||||
disposition = "attachment"
|
||||
default:
|
||||
msg = msg.AddInline(image, mimetype, filename, "c"+strconv.Itoa(a))
|
||||
disposition = "inline"
|
||||
}
|
||||
attachments = append(attachments, filledAttachment{
|
||||
name: filename,
|
||||
size: len(image),
|
||||
mimeType: mimetype,
|
||||
disposition: disposition,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
msg = format(text, msg)
|
||||
|
||||
flags := []imap.Flag{}
|
||||
keywords := pickRandomlyFromMap(allKeywords, 0, len(allKeywords))
|
||||
for _, f := range keywords {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
part, _ := msg.Build()
|
||||
part.Encode(buf)
|
||||
mail := buf.String()
|
||||
|
||||
var options *imap.AppendOptions = nil
|
||||
if len(flags) > 0 {
|
||||
options = &imap.AppendOptions{Flags: flags}
|
||||
}
|
||||
|
||||
size := int64(len(mail))
|
||||
appendCmd := c.Append(folder, size, options)
|
||||
if _, err := appendCmd.Write([]byte(mail)); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := appendCmd.Close(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if appendData, err := appendCmd.Wait(); err != nil {
|
||||
return nil, 0, err
|
||||
} else {
|
||||
attachmentStr := ""
|
||||
if numAttachments > 0 {
|
||||
attachmentStr = " " + strings.Repeat("📎", numAttachments)
|
||||
}
|
||||
log.Printf("➕ appended %v/%v [in thread %v] uid=%v%s", i+1, count, thread+1, appendData.UID, attachmentStr)
|
||||
|
||||
mails[i] = filledMail{
|
||||
uid: int(appendData.UID),
|
||||
attachments: attachments,
|
||||
subject: msg.GetSubject(),
|
||||
messageId: messageId,
|
||||
keywords: slices.Collect(maps.Keys(keywords)),
|
||||
}
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
listCmd := c.List("", "%", &imap.ListOptions{
|
||||
ReturnStatus: &imap.StatusOptions{
|
||||
NumMessages: true,
|
||||
NumUnseen: true,
|
||||
},
|
||||
})
|
||||
countMap := map[string]int{}
|
||||
for {
|
||||
mbox := listCmd.Next()
|
||||
if mbox == nil {
|
||||
break
|
||||
}
|
||||
countMap[mbox.Mailbox] = int(*mbox.Status.NumMessages)
|
||||
}
|
||||
|
||||
inboxCount := -1
|
||||
for f, i := range countMap {
|
||||
if strings.Compare(strings.ToLower(f), strings.ToLower(folder)) == 0 {
|
||||
inboxCount = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if err = listCmd.Close(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if inboxCount == -1 {
|
||||
return nil, 0, fmt.Errorf("failed to find folder '%v' via IMAP", folder)
|
||||
}
|
||||
if empty && count != inboxCount {
|
||||
return nil, 0, fmt.Errorf("wrong number of emails in the inbox after filling, expecting %v, has %v", count, inboxCount)
|
||||
}
|
||||
|
||||
return mails, thread, nil
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package jmaptest
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/structs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testWsPushListener struct {
|
||||
t *testing.T
|
||||
logger *log.Logger
|
||||
username string
|
||||
mailAccountId AccountId
|
||||
calls atomic.Uint32
|
||||
m sync.Mutex
|
||||
emailStates []string
|
||||
threadStates []string
|
||||
mailboxStates []string
|
||||
}
|
||||
|
||||
func (l *testWsPushListener) OnNotification(username string, pushState StateChange) {
|
||||
assert.Equal(l.t, l.username, username)
|
||||
l.calls.Add(1)
|
||||
// pushState is currently not supported by Stalwart, let's use the object states instead
|
||||
l.logger.Debug().Msgf("received %T: %v", pushState, pushState)
|
||||
if changed, ok := pushState.Changed[l.mailAccountId]; ok {
|
||||
l.m.Lock()
|
||||
if st, ok := changed[EmailName]; ok {
|
||||
l.emailStates = append(l.emailStates, st)
|
||||
}
|
||||
if st, ok := changed[ThreadName]; ok {
|
||||
l.threadStates = append(l.threadStates, st)
|
||||
}
|
||||
if st, ok := changed[MailboxName]; ok {
|
||||
l.mailboxStates = append(l.mailboxStates, st)
|
||||
}
|
||||
l.m.Unlock()
|
||||
|
||||
unsupportedKeys := structs.Filter(structs.Keys(changed), func(o ObjectTypeName) bool { return o != EmailName && o != ThreadName && o != MailboxName })
|
||||
assert.Empty(l.t, unsupportedKeys)
|
||||
}
|
||||
unsupportedAccounts := structs.Filter(structs.Keys(pushState.Changed), func(s AccountId) bool { return s != l.mailAccountId })
|
||||
assert.Empty(l.t, unsupportedAccounts)
|
||||
}
|
||||
|
||||
var _ WsPushListener = &testWsPushListener{}
|
||||
|
||||
func TestWs(t *testing.T) {
|
||||
if Skip(t) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, nil)
|
||||
|
||||
require := require.New(t)
|
||||
|
||||
cotx := t.Context()
|
||||
|
||||
s, err := NewStalwartTest(t)
|
||||
require.NoError(err)
|
||||
defer s.Close()
|
||||
|
||||
user := pickUser()
|
||||
session := s.Session(user.Email)
|
||||
ctx := s.Context(session)
|
||||
|
||||
mailAccountId := session.PrimaryAccounts.Mail
|
||||
inboxFolder := ""
|
||||
{
|
||||
_, inboxFolder = s.findInbox(t, mailAccountId, ctx)
|
||||
}
|
||||
|
||||
l := &testWsPushListener{t: t, username: user.Email, logger: s.logger, mailAccountId: mailAccountId}
|
||||
s.Client.AddWsPushListener(l)
|
||||
|
||||
require.Equal(uint32(0), l.calls.Load())
|
||||
{
|
||||
l.m.Lock()
|
||||
require.Len(l.emailStates, 0)
|
||||
require.Len(l.mailboxStates, 0)
|
||||
require.Len(l.threadStates, 0)
|
||||
l.m.Unlock()
|
||||
}
|
||||
|
||||
var initialState State
|
||||
{
|
||||
result, err := s.Client.GetEmailChanges(mailAccountId, EmptyState, true, 0, 0, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.NotEmpty(result.GetState())
|
||||
//fmt.Printf("\x1b[45;1;4mChanges [%s]:\x1b[0m\n", state)
|
||||
//for _, c := range changes.Created { fmt.Printf("%s %s\n", c.Id, c.Subject) }
|
||||
initialState = result.GetState()
|
||||
require.Empty(result.Payload.Created)
|
||||
require.Empty(result.Payload.Destroyed)
|
||||
require.Empty(result.Payload.Updated)
|
||||
}
|
||||
require.NotEmpty(initialState)
|
||||
|
||||
{
|
||||
result, err := s.Client.GetEmailChanges(mailAccountId, initialState, true, 0, 0, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.Equal(initialState, result.GetState())
|
||||
require.Equal(initialState, result.Payload.NewState)
|
||||
require.Empty(result.Payload.Created)
|
||||
require.Empty(result.Payload.Destroyed)
|
||||
require.Empty(result.Payload.Updated)
|
||||
}
|
||||
|
||||
wsc, err := s.Client.EnablePushNotifications(cotx, initialState, func() (*Session, error) { return session, nil })
|
||||
require.NoError(err)
|
||||
defer wsc.Close()
|
||||
|
||||
require.Equal(uint32(0), l.calls.Load())
|
||||
{
|
||||
l.m.Lock()
|
||||
require.Len(l.emailStates, 0)
|
||||
require.Len(l.mailboxStates, 0)
|
||||
require.Len(l.threadStates, 0)
|
||||
l.m.Unlock()
|
||||
}
|
||||
|
||||
emailIds := []string{}
|
||||
|
||||
{
|
||||
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
|
||||
require.NoError(err)
|
||||
require.Equal(1, n)
|
||||
}
|
||||
|
||||
require.Eventually(func() bool {
|
||||
return l.calls.Load() == uint32(1)
|
||||
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after first email state change")
|
||||
{
|
||||
l.m.Lock()
|
||||
require.Len(l.emailStates, 1)
|
||||
require.Len(l.mailboxStates, 1)
|
||||
require.Len(l.threadStates, 1)
|
||||
l.m.Unlock()
|
||||
}
|
||||
var lastState State
|
||||
{
|
||||
result, err := s.Client.GetEmailChanges(mailAccountId, initialState, true, 0, 0, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.NotEqual(initialState, result.GetState())
|
||||
require.NotEqual(initialState, result.Payload.NewState)
|
||||
require.Equal(result.GetState(), result.Payload.NewState)
|
||||
require.Len(result.Payload.Created, 1)
|
||||
require.Empty(result.Payload.Destroyed)
|
||||
require.Empty(result.Payload.Updated)
|
||||
lastState = result.GetState()
|
||||
|
||||
emailIds = append(emailIds, structs.Map(result.Payload.Created, func(e Email) string { return e.Id })...)
|
||||
}
|
||||
|
||||
{
|
||||
_, n, err := s.fillEmailsWithImap(inboxFolder, 1, false, user)
|
||||
require.NoError(err)
|
||||
require.Equal(1, n)
|
||||
}
|
||||
|
||||
require.Eventually(func() bool {
|
||||
return l.calls.Load() == uint32(2)
|
||||
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after second email state change")
|
||||
{
|
||||
l.m.Lock()
|
||||
require.Len(l.emailStates, 2)
|
||||
require.Len(l.mailboxStates, 2)
|
||||
require.Len(l.threadStates, 2)
|
||||
assert.NotEqual(t, l.emailStates[0], l.emailStates[1])
|
||||
assert.NotEqual(t, l.mailboxStates[0], l.mailboxStates[1])
|
||||
assert.NotEqual(t, l.threadStates[0], l.threadStates[1])
|
||||
l.m.Unlock()
|
||||
}
|
||||
{
|
||||
result, err := s.Client.GetEmailChanges(mailAccountId, lastState, true, 0, 0, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.NotEqual(lastState, result.GetState())
|
||||
require.NotEqual(lastState, result.Payload.NewState)
|
||||
require.Equal(result.GetState(), result.Payload.NewState)
|
||||
require.Len(result.Payload.Created, 1)
|
||||
require.Empty(result.Payload.Destroyed)
|
||||
require.Empty(result.Payload.Updated)
|
||||
lastState = result.GetState()
|
||||
|
||||
emailIds = append(emailIds, structs.Map(result.Payload.Created, func(e Email) string { return e.Id })...)
|
||||
}
|
||||
|
||||
{
|
||||
_, n, err := s.fillEmailsWithImap(inboxFolder, 0, true, user)
|
||||
require.NoError(err)
|
||||
require.Equal(0, n)
|
||||
}
|
||||
|
||||
require.Eventually(func() bool {
|
||||
return l.calls.Load() == uint32(3)
|
||||
}, 3*time.Second, 200*time.Millisecond, "WS push listener was not called after third email state change")
|
||||
{
|
||||
l.m.Lock()
|
||||
require.Len(l.emailStates, 3)
|
||||
require.Len(l.mailboxStates, 3)
|
||||
require.Len(l.threadStates, 3)
|
||||
assert.NotEqual(t, l.emailStates[1], l.emailStates[2])
|
||||
assert.NotEqual(t, l.mailboxStates[1], l.mailboxStates[2])
|
||||
assert.NotEqual(t, l.threadStates[1], l.threadStates[2])
|
||||
l.m.Unlock()
|
||||
}
|
||||
{
|
||||
result, err := s.Client.GetEmailChanges(mailAccountId, lastState, true, 0, 0, ctx)
|
||||
require.NoError(err)
|
||||
require.Equal(session.State, result.GetSessionState())
|
||||
require.NotEqual(lastState, result.GetState())
|
||||
require.NotEqual(lastState, result.Payload.NewState)
|
||||
require.Equal(result.GetState(), result.Payload.NewState)
|
||||
require.Empty(result.Payload.Created)
|
||||
require.Len(result.Payload.Destroyed, 2)
|
||||
{
|
||||
a := make([]string, len(emailIds))
|
||||
copy(a, emailIds)
|
||||
slices.Sort(emailIds)
|
||||
b := make([]string, len(result.Payload.Destroyed))
|
||||
copy(b, result.Payload.Destroyed)
|
||||
slices.Sort(b)
|
||||
require.EqualValues(a, b)
|
||||
}
|
||||
require.Empty(result.Payload.Updated)
|
||||
lastState = result.GetState()
|
||||
}
|
||||
|
||||
err = wsc.DisableNotifications()
|
||||
require.NoError(err)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long.
@@ -1,52 +0,0 @@
|
||||
package jmaptest
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/jmap"
|
||||
)
|
||||
|
||||
var (
|
||||
truep = ptr(true)
|
||||
falsep = ptr(false)
|
||||
)
|
||||
|
||||
// TODO remove and replace with calls to new() when upgrading to Go 1.26
|
||||
func ptr[T any | int | uint | bool | string](t T) *T {
|
||||
return &t
|
||||
}
|
||||
|
||||
func list[T jmap.Foo, GETRESP jmap.GetResponse[T]](r GETRESP) []T { return r.GetList() }
|
||||
func getid[T jmap.Idable](r T) string { return r.GetId() }
|
||||
|
||||
func uintPtr[T int | uint](i T) *uint {
|
||||
return ptr(uint(i))
|
||||
}
|
||||
|
||||
func firstKey[K comparable, V any](m map[K]V) (K, bool) {
|
||||
for k := range m {
|
||||
return k, true
|
||||
}
|
||||
var zero K
|
||||
return zero, false
|
||||
}
|
||||
|
||||
var freeLocalhostPortSync = sync.Mutex{}
|
||||
|
||||
func FreeLocalhostPort() (int, error) {
|
||||
addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
freeLocalhostPortSync.Lock()
|
||||
defer freeLocalhostPortSync.Unlock()
|
||||
l, err := net.ListenTCP("tcp", addr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/apidoc-examples.json
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,746 +0,0 @@
|
||||
package jscalendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jsoneq[X any](t *testing.T, expected string, object X) {
|
||||
data, err := json.MarshalIndent(object, "", "")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, expected, string(data))
|
||||
|
||||
var rec X
|
||||
err = json.Unmarshal(data, &rec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, object, rec)
|
||||
}
|
||||
|
||||
/*
|
||||
func TestLocalDateTime(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
ldt := &LocalDateTime{ts}
|
||||
|
||||
str, err := json.MarshalIndent(ldt, "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "\"2025-09-25T16:26:14\"", string(str))
|
||||
}
|
||||
|
||||
func TestLocalDateTimeUnmarshalling(t *testing.T) {
|
||||
ts, err := time.Parse(RFC3339Local, "2025-09-25T18:26:14")
|
||||
require.NoError(t, err)
|
||||
u := ts.UTC()
|
||||
|
||||
var result LocalDateTime
|
||||
err = json.Unmarshal([]byte("\"2025-09-25T18:26:14Z\""), &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, result, LocalDateTime{u})
|
||||
}
|
||||
*/
|
||||
|
||||
func TestRelation(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"first": true,
|
||||
"parent": true
|
||||
}
|
||||
}`, Relation{
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationshipFirst: true,
|
||||
RelationshipParent: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestLink(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Link",
|
||||
"href": "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png",
|
||||
"contentType": "image/png",
|
||||
"size": 128912,
|
||||
"rel": "icon",
|
||||
"display": "thumbnail",
|
||||
"title": "the logo"
|
||||
}`, Link{
|
||||
Type: LinkType,
|
||||
Href: "https://opencloud.eu.example.com/f72ae875-40be-48a4-84ff-aea9aed3e085.png",
|
||||
ContentType: "image/png", //NOSONAR
|
||||
Size: 128912,
|
||||
Rel: RelIcon,
|
||||
Display: DisplayThumbnail,
|
||||
Title: "the logo",
|
||||
})
|
||||
}
|
||||
|
||||
func TestLocation(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Location",
|
||||
"name": "The Eiffel Tower",
|
||||
"locationTypes": {
|
||||
"landmark-address": true,
|
||||
"industrial": true
|
||||
},
|
||||
"coordinates": "geo:48.8559324,2.2932441",
|
||||
"links": {
|
||||
"l1": {
|
||||
"@type": "Link",
|
||||
"href": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG",
|
||||
"contentType": "image/png",
|
||||
"size": 12345,
|
||||
"rel": "icon",
|
||||
"display": "A blue Eiffel tower",
|
||||
"title": "Blue Eiffel Tower"
|
||||
}
|
||||
}
|
||||
}`, Location{
|
||||
Type: LocationType,
|
||||
Name: "The Eiffel Tower",
|
||||
LocationTypes: map[LocationTypeOption]bool{
|
||||
LocationTypeOptionLandmarkAddress: true,
|
||||
LocationTypeOptionIndustrial: true,
|
||||
},
|
||||
Coordinates: "geo:48.8559324,2.2932441",
|
||||
Links: map[string]Link{
|
||||
"l1": {
|
||||
Type: LinkType,
|
||||
Href: "https://upload.wikimedia.org/wikipedia/commons/f/fd/Eiffel_blue.PNG",
|
||||
ContentType: "image/png",
|
||||
Size: 12345,
|
||||
Rel: RelIcon,
|
||||
Display: "A blue Eiffel tower",
|
||||
Title: "Blue Eiffel Tower",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestVirtualLocation(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "VirtualLocation",
|
||||
"name": "OpenTalk",
|
||||
"uri": "https://opentalk.eu",
|
||||
"features": {
|
||||
"video": true,
|
||||
"screen": true,
|
||||
"audio": true
|
||||
}
|
||||
}`, VirtualLocation{
|
||||
Type: VirtualLocationType,
|
||||
Name: "OpenTalk",
|
||||
Uri: "https://opentalk.eu",
|
||||
Features: map[VirtualLocationFeature]bool{
|
||||
VirtualLocationFeatureVideo: true,
|
||||
VirtualLocationFeatureScreen: true,
|
||||
VirtualLocationFeatureAudio: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNDay(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "NDay",
|
||||
"day": "fr",
|
||||
"nthOfPeriod": -1
|
||||
}`, NDay{
|
||||
Type: NDayType,
|
||||
Day: DayOfWeekFriday,
|
||||
NthOfPeriod: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecurrenceRule(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00") //NOSONAR
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
l := LocalDateTime("2025-09-25T16:26:14") //NOSONAR
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "RecurrenceRule",
|
||||
"frequency": "daily",
|
||||
"interval": 1,
|
||||
"rscale": "iso8601",
|
||||
"skip": "forward",
|
||||
"firstDayOfWeek": "mo",
|
||||
"byDay": [
|
||||
{"@type": "NDay", "day": "mo", "nthOfPeriod": -1},
|
||||
{"@type": "NDay", "day": "tu"},
|
||||
{"day": "we"}
|
||||
],
|
||||
"byMonthDay": [1, 10, 31],
|
||||
"byMonth": ["1", "31L"],
|
||||
"byYearDay": [-1, 366],
|
||||
"byWeekNo": [-53, 53],
|
||||
"byHour": [0, 23],
|
||||
"byMinute": [0, 59],
|
||||
"bySecond": [0, 39],
|
||||
"bySetPosition": [-3, 3],
|
||||
"count": 2,
|
||||
"until": "2025-09-25T16:26:14"
|
||||
}`, RecurrenceRule{
|
||||
Type: RecurrenceRuleType,
|
||||
Frequency: FrequencyDaily,
|
||||
Interval: 1,
|
||||
Rscale: RscaleIso8601,
|
||||
Skip: SkipForward,
|
||||
FirstDayOfWeek: DayOfWeekMonday,
|
||||
ByDay: []NDay{
|
||||
{
|
||||
Type: NDayType,
|
||||
Day: DayOfWeekMonday,
|
||||
NthOfPeriod: -1,
|
||||
},
|
||||
{
|
||||
Type: NDayType,
|
||||
Day: DayOfWeekTuesday,
|
||||
},
|
||||
{
|
||||
Day: DayOfWeekWednesday,
|
||||
NthOfPeriod: 0,
|
||||
},
|
||||
},
|
||||
ByMonthDay: []int{1, 10, 31},
|
||||
ByMonth: []string{"1", "31L"},
|
||||
ByYearDay: []int{-1, 366},
|
||||
ByWeekNo: []int{-53, 53},
|
||||
ByHour: []uint{0, 23},
|
||||
ByMinute: []uint{0, 59},
|
||||
BySecond: []uint{0, 39},
|
||||
BySetPosition: []int{-3, 3},
|
||||
Count: 2,
|
||||
Until: &l,
|
||||
})
|
||||
}
|
||||
|
||||
func TestParticipant(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
|
||||
ts2, err := time.Parse(time.RFC3339, "2025-09-29T14:32:19+02:00")
|
||||
require.NoError(t, err)
|
||||
ts2 = ts2.UTC()
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Participant",
|
||||
"name": "Camina Drummer",
|
||||
"email": "camina@opa.org",
|
||||
"description": "Camina Drummer is a Belter serving as the current President of the Transport Union.",
|
||||
"calendarAddress": "cdrummer@itip.opa.org",
|
||||
"kind": "individual",
|
||||
"roles": {
|
||||
"owner": true,
|
||||
"chair": true
|
||||
},
|
||||
"locationId": "98faaa01-b6db-4ddb-9574-e28ab83104e6",
|
||||
"language": "en-JM",
|
||||
"participationStatus": "accepted",
|
||||
"participationComment": "always there",
|
||||
"expectReply": true,
|
||||
"scheduleAgent": "server",
|
||||
"scheduleForceSend": true,
|
||||
"scheduleSequence": 3,
|
||||
"scheduleStatus": [
|
||||
"3.1",
|
||||
"2.0"
|
||||
],
|
||||
"scheduleUpdated": "2025-09-25T16:26:14Z",
|
||||
"sentBy": "adawes@opa.org",
|
||||
"invitedBy": "346be402-c340-4f3f-ac51-e4aa9955af4f",
|
||||
"delegatedTo": {
|
||||
"93230b90-70c6-4027-b2c1-3629877bfea5": true,
|
||||
"f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true
|
||||
},
|
||||
"delegatedFrom": {
|
||||
"a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true
|
||||
},
|
||||
"memberOf": {
|
||||
"0f41473b-0edd-494d-b346-8d039009a2a5": true
|
||||
},
|
||||
"links":{
|
||||
"l1": {
|
||||
"@type": "Link",
|
||||
"href": "https://opa.org/opa.png",
|
||||
"contentType": "image/png",
|
||||
"size": 182912,
|
||||
"rel": "icon",
|
||||
"display": "Logo",
|
||||
"title": "OPA"
|
||||
}
|
||||
},
|
||||
"progress": "in-process",
|
||||
"progressUpdated": "2025-09-29T12:32:19Z",
|
||||
"percentComplete": 42
|
||||
}`, Participant{
|
||||
Type: ParticipantType,
|
||||
Name: "Camina Drummer",
|
||||
Email: "camina@opa.org",
|
||||
Description: "Camina Drummer is a Belter serving as the current President of the Transport Union.",
|
||||
CalendarAddress: "cdrummer@itip.opa.org",
|
||||
Kind: ParticipantKindIndividual,
|
||||
Roles: map[Role]bool{
|
||||
RoleOwner: true,
|
||||
RoleChair: true,
|
||||
},
|
||||
LocationId: "98faaa01-b6db-4ddb-9574-e28ab83104e6",
|
||||
Language: "en-JM",
|
||||
ParticipationStatus: ParticipationStatusAccepted,
|
||||
ParticipationComment: "always there",
|
||||
ExpectReply: true,
|
||||
ScheduleAgent: ScheduleAgentServer,
|
||||
ScheduleForceSend: true,
|
||||
ScheduleSequence: 3,
|
||||
ScheduleStatus: []string{
|
||||
"3.1",
|
||||
"2.0",
|
||||
},
|
||||
ScheduleUpdated: ts,
|
||||
SentBy: "adawes@opa.org",
|
||||
InvitedBy: "346be402-c340-4f3f-ac51-e4aa9955af4f",
|
||||
DelegatedTo: map[string]bool{
|
||||
"93230b90-70c6-4027-b2c1-3629877bfea5": true,
|
||||
"f5fae398-cfa3-4873-bbc7-0ca9d51de5b0": true,
|
||||
},
|
||||
DelegatedFrom: map[string]bool{
|
||||
"a9c1c1a1-fecf-4214-a803-1ee209e2dbec": true,
|
||||
},
|
||||
MemberOf: map[string]bool{
|
||||
"0f41473b-0edd-494d-b346-8d039009a2a5": true,
|
||||
},
|
||||
Links: map[string]Link{
|
||||
"l1": {
|
||||
Type: LinkType,
|
||||
Href: "https://opa.org/opa.png",
|
||||
ContentType: "image/png",
|
||||
Size: 182912,
|
||||
Rel: RelIcon,
|
||||
Display: "Logo",
|
||||
Title: "OPA",
|
||||
},
|
||||
},
|
||||
Progress: ProgressInProcess,
|
||||
ProgressUpdated: ts2,
|
||||
PercentComplete: 42,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertWithAbsoluteTrigger(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Alert",
|
||||
"trigger": {
|
||||
"@type": "AbsoluteTrigger",
|
||||
"when": "2025-09-25T16:26:14Z"
|
||||
},
|
||||
"acknowledged": "2025-09-25T16:26:14Z",
|
||||
"relatedTo": {
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"first": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"action": "email"
|
||||
}`, Alert{
|
||||
Type: AlertType,
|
||||
Trigger: &AbsoluteTrigger{
|
||||
Type: AbsoluteTriggerType,
|
||||
When: ts,
|
||||
},
|
||||
Acknowledged: ts,
|
||||
RelatedTo: map[string]Relation{
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": { //NOSONAR
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationshipFirst: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Action: AlertActionEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertWithOffsetTrigger(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Alert",
|
||||
"trigger": {
|
||||
"@type": "OffsetTrigger",
|
||||
"offset": "-PT5M",
|
||||
"relativeTo": "end"
|
||||
},
|
||||
"acknowledged": "2025-09-25T16:26:14Z",
|
||||
"relatedTo": {
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"first": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"action": "email"
|
||||
}`, Alert{
|
||||
Type: AlertType,
|
||||
Trigger: &OffsetTrigger{
|
||||
Type: OffsetTriggerType,
|
||||
Offset: "-PT5M",
|
||||
RelativeTo: RelativeToEnd,
|
||||
},
|
||||
Acknowledged: ts,
|
||||
RelatedTo: map[string]Relation{
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationshipFirst: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Action: AlertActionEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertWithUnknownTrigger(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Alert",
|
||||
"trigger": {
|
||||
"@type": "XYZTRIGGER",
|
||||
"abc": 123,
|
||||
"xyz": "zzz"
|
||||
},
|
||||
"acknowledged": "2025-09-25T16:26:14Z",
|
||||
"relatedTo": {
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"first": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"action": "email"
|
||||
}`, Alert{
|
||||
Type: AlertType,
|
||||
Trigger: &UnknownTrigger{
|
||||
"@type": "XYZTRIGGER",
|
||||
"abc": 123.0,
|
||||
"xyz": "zzz",
|
||||
},
|
||||
Acknowledged: ts,
|
||||
RelatedTo: map[string]Relation{
|
||||
"a2e729eb-7d9c-4ea7-8514-93d2590ef0a2": {
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationshipFirst: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Action: AlertActionEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeZoneRule(t *testing.T) {
|
||||
l1 := LocalDateTime("2025-09-25T16:26:14")
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "TimeZoneRule",
|
||||
"start": "2025-09-25T16:26:14",
|
||||
"offsetFrom": "-0200",
|
||||
"offsetTo": "+0200",
|
||||
"recurrenceRules": [
|
||||
{
|
||||
"@type": "RecurrenceRule",
|
||||
"frequency": "weekly",
|
||||
"interval": 2,
|
||||
"rscale": "iso8601",
|
||||
"skip": "omit",
|
||||
"firstDayOfWeek": "mo",
|
||||
"byDay": [
|
||||
{
|
||||
"@type": "NDay",
|
||||
"day": "fr"
|
||||
}
|
||||
],
|
||||
"byHour": [14],
|
||||
"byMinute": [0],
|
||||
"count": 4
|
||||
}
|
||||
],
|
||||
"recurrenceOverrides": {
|
||||
"2025-09-25T16:26:14": {}
|
||||
},
|
||||
"names": {
|
||||
"CEST": true
|
||||
},
|
||||
"comments": ["this is a comment"]
|
||||
}`, TimeZoneRule{
|
||||
Type: TimeZoneRuleType,
|
||||
Start: l1,
|
||||
OffsetFrom: "-0200",
|
||||
OffsetTo: "+0200",
|
||||
RecurrenceRules: []RecurrenceRule{
|
||||
{
|
||||
Type: RecurrenceRuleType,
|
||||
Frequency: FrequencyWeekly,
|
||||
Interval: 2,
|
||||
Rscale: RscaleIso8601,
|
||||
Skip: SkipOmit,
|
||||
FirstDayOfWeek: DayOfWeekMonday,
|
||||
ByDay: []NDay{
|
||||
{
|
||||
Type: NDayType,
|
||||
Day: DayOfWeekFriday,
|
||||
},
|
||||
},
|
||||
ByHour: []uint{
|
||||
14,
|
||||
},
|
||||
ByMinute: []uint{
|
||||
0,
|
||||
},
|
||||
Count: 4,
|
||||
},
|
||||
},
|
||||
RecurrenceOverrides: map[LocalDateTime]PatchObject{
|
||||
l1: {},
|
||||
},
|
||||
Names: map[string]bool{
|
||||
"CEST": true,
|
||||
},
|
||||
Comments: []string{
|
||||
"this is a comment",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeZone(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T16:26:14+02:00")
|
||||
require.NoError(t, err)
|
||||
ts = ts.UTC()
|
||||
l := LocalDateTime("2025-09-25T16:26:14")
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "TimeZone",
|
||||
"tzId": "cest",
|
||||
"updated": "2025-09-25T14:26:14Z",
|
||||
"url": "https://timezones.net/cest",
|
||||
"validUntil": "2025-09-25T14:26:14Z",
|
||||
"aliases": {
|
||||
"cet": true
|
||||
},
|
||||
"standard": [{
|
||||
"@type": "TimeZoneRule",
|
||||
"start": "2025-09-25T16:26:14",
|
||||
"offsetFrom": "-0200",
|
||||
"offsetTo": "+1245"
|
||||
}],
|
||||
"daylight": [{
|
||||
"@type": "TimeZoneRule",
|
||||
"start": "2025-09-25T16:26:14",
|
||||
"offsetFrom": "-0200",
|
||||
"offsetTo": "+1245"
|
||||
}]
|
||||
}`, TimeZone{
|
||||
Type: TimeZoneType,
|
||||
TzId: "cest",
|
||||
Updated: ts,
|
||||
Url: "https://timezones.net/cest",
|
||||
ValidUntil: ts,
|
||||
Aliases: map[string]bool{
|
||||
"cet": true,
|
||||
},
|
||||
Standard: []TimeZoneRule{
|
||||
{
|
||||
Type: TimeZoneRuleType,
|
||||
Start: l,
|
||||
OffsetFrom: "-0200",
|
||||
OffsetTo: "+1245",
|
||||
},
|
||||
},
|
||||
Daylight: []TimeZoneRule{
|
||||
{
|
||||
Type: TimeZoneRuleType,
|
||||
Start: l,
|
||||
OffsetFrom: "-0200",
|
||||
OffsetTo: "+1245",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvent(t *testing.T) {
|
||||
local1 := "2025-09-25T16:26:14"
|
||||
ts1, err := time.Parse(time.RFC3339, local1+"+02:00")
|
||||
require.NoError(t, err)
|
||||
ts1 = ts1.UTC()
|
||||
|
||||
local2 := "2025-09-29T13:53:01"
|
||||
ts2, err := time.Parse(time.RFC3339, local2+"+02:00")
|
||||
require.NoError(t, err)
|
||||
ts2 = ts2.UTC()
|
||||
|
||||
l := LocalDateTime("2025-09-25T16:26:14")
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Event",
|
||||
"start": "2025-09-25T16:26:14",
|
||||
"duration": "PT10M",
|
||||
"status": "confirmed",
|
||||
"uid": "b422cfec-f7b4-4e04-8ec6-b794007f63f1",
|
||||
"prodId": "OpenCloud 1.0",
|
||||
"created": "2025-09-25T16:26:14",
|
||||
"updated": "2025-09-29T13:53:01",
|
||||
"title": "End of year party",
|
||||
"description": "It's the party at the end of the year.",
|
||||
"descriptionContentType": "text/plain",
|
||||
"links": {
|
||||
"l1": {
|
||||
"@type": "Link",
|
||||
"href": "https://opencloud.eu/eoy-party/2025",
|
||||
"contentType": "text/html",
|
||||
"rel": "about"
|
||||
}
|
||||
},
|
||||
"locale": "en-GB",
|
||||
"keywords": {
|
||||
"k1": true
|
||||
},
|
||||
"categories": {
|
||||
"cat": true
|
||||
},
|
||||
"color": "oil",
|
||||
"relatedTo": {
|
||||
"a": {
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"next": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sequence": 3,
|
||||
"showWithoutTime": true,
|
||||
"locations": {
|
||||
"loc1": {
|
||||
"@type": "Location",
|
||||
"name": "Steel Cactus Mexican Grill",
|
||||
"locationTypes": {
|
||||
"bar": true
|
||||
},
|
||||
"coordinates": "geo:16.7685657,-4.8629852",
|
||||
"links": {
|
||||
"l1": {
|
||||
"@type": "Link",
|
||||
"href": "https://mars.gov/bars/steelcactus",
|
||||
"rel": "about"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, Event{
|
||||
Type: EventType,
|
||||
Start: l,
|
||||
Duration: "PT10M",
|
||||
Status: "confirmed",
|
||||
Object: Object{
|
||||
CommonObject: CommonObject{
|
||||
Uid: "b422cfec-f7b4-4e04-8ec6-b794007f63f1",
|
||||
ProdId: "OpenCloud 1.0",
|
||||
Created: UTCDateTime(local1),
|
||||
Updated: UTCDateTime(local2),
|
||||
Title: "End of year party",
|
||||
Description: "It's the party at the end of the year.",
|
||||
DescriptionContentType: "text/plain",
|
||||
Links: map[string]Link{
|
||||
"l1": {
|
||||
Type: LinkType,
|
||||
Href: "https://opencloud.eu/eoy-party/2025",
|
||||
ContentType: "text/html",
|
||||
Rel: RelAbout,
|
||||
},
|
||||
},
|
||||
Locale: "en-GB",
|
||||
Keywords: map[string]bool{
|
||||
"k1": true,
|
||||
},
|
||||
Categories: map[string]bool{
|
||||
"cat": true,
|
||||
},
|
||||
Color: "oil",
|
||||
},
|
||||
RelatedTo: map[string]Relation{
|
||||
"a": {
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationshipNext: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Sequence: 3,
|
||||
ShowWithoutTime: true,
|
||||
Locations: map[string]Location{
|
||||
"loc1": {
|
||||
Type: LocationType,
|
||||
Name: "Steel Cactus Mexican Grill",
|
||||
LocationTypes: map[LocationTypeOption]bool{
|
||||
LocationTypeOptionBar: true,
|
||||
},
|
||||
Coordinates: "geo:16.7685657,-4.8629852",
|
||||
Links: map[string]Link{
|
||||
"l1": {
|
||||
Type: LinkType,
|
||||
Href: "https://mars.gov/bars/steelcactus",
|
||||
Rel: RelAbout,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPatch(t *testing.T) {
|
||||
require := require.New(t)
|
||||
for _, tt := range []struct {
|
||||
change ObjectChange
|
||||
expected PatchObject
|
||||
}{
|
||||
{ObjectChange{}, PatchObject{}},
|
||||
{ObjectChange{
|
||||
CommonObjectChange: CommonObjectChange{
|
||||
Uid: strPtr("e9787e0b-e824-4284-964e-6b5d77af4bc9"),
|
||||
},
|
||||
}, PatchObject{
|
||||
"uid": "e9787e0b-e824-4284-964e-6b5d77af4bc9",
|
||||
}},
|
||||
} {
|
||||
b, err := json.Marshal(tt.expected)
|
||||
require.NoError(err)
|
||||
title := string(b)
|
||||
t.Run(title, func(t *testing.T) {
|
||||
patch, err := tt.change.AsPatch()
|
||||
require.NoError(err)
|
||||
require.Equal(tt.expected, patch)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/apidoc-examples.json
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,660 +0,0 @@
|
||||
package jscontact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jsoneq[X any](t *testing.T, expected string, object X) {
|
||||
data, err := json.MarshalIndent(object, "", "")
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, expected, string(data))
|
||||
|
||||
var rec X
|
||||
err = json.Unmarshal(data, &rec)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, object, rec)
|
||||
}
|
||||
|
||||
func TestCalendar(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Calendar",
|
||||
"kind": "calendar",
|
||||
"uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025",
|
||||
"mediaType": "application/jscontact+json",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"label": "test"
|
||||
}`, Calendar{
|
||||
Type: CalendarType,
|
||||
Kind: CalendarKindCalendar,
|
||||
Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025", //NOSONAR
|
||||
MediaType: "application/jscontact+json", //NOSONAR
|
||||
Contexts: map[CalendarContext]bool{
|
||||
CalendarContextWork: true,
|
||||
},
|
||||
Pref: 0,
|
||||
Label: "test",
|
||||
})
|
||||
}
|
||||
|
||||
func TestLink(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Link",
|
||||
"kind": "contact",
|
||||
"uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025",
|
||||
"mediaType": "application/jscontact+json",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"label": "test"
|
||||
}`, Link{
|
||||
Type: LinkType,
|
||||
Kind: LinkKindContact,
|
||||
Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025",
|
||||
MediaType: "application/jscontact+json",
|
||||
Contexts: map[LinkContext]bool{
|
||||
LinkContextWork: true,
|
||||
},
|
||||
Pref: 0,
|
||||
Label: "test",
|
||||
})
|
||||
}
|
||||
|
||||
func TestCryptoKey(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "CryptoKey",
|
||||
"uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025.pgp",
|
||||
"mediaType": "application/pgp-keys",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"label": "test"
|
||||
}`, CryptoKey{
|
||||
Type: CryptoKeyType,
|
||||
Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025.pgp",
|
||||
MediaType: "application/pgp-keys",
|
||||
Contexts: map[CryptoKeyContext]bool{
|
||||
CryptoKeyContextWork: true,
|
||||
},
|
||||
Pref: 0,
|
||||
Label: "test",
|
||||
})
|
||||
}
|
||||
|
||||
func TestDirectory(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Directory",
|
||||
"kind": "entry",
|
||||
"uri": "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025",
|
||||
"mediaType": "application/jscontact+json",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"label": "test",
|
||||
"listAs": 3
|
||||
}`, Directory{
|
||||
Type: DirectoryType,
|
||||
Kind: DirectoryKindEntry,
|
||||
Uri: "https://opencloud.eu/calendar/d05779b6-9638-4694-9869-008a61df6025",
|
||||
MediaType: "application/jscontact+json",
|
||||
Contexts: map[DirectoryContext]bool{
|
||||
DirectoryContextWork: true,
|
||||
},
|
||||
Pref: 0,
|
||||
Label: "test",
|
||||
ListAs: 3,
|
||||
})
|
||||
}
|
||||
|
||||
func TestMedia(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Media",
|
||||
"kind": "logo",
|
||||
"uri": "https://opencloud.eu/opencloud.svg",
|
||||
"mediaType": "image/svg+xml",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"label": "test",
|
||||
"blobId": "1d92cf97e32b42ceb5538f0804a41891"
|
||||
}`, Media{
|
||||
Type: MediaType,
|
||||
Kind: MediaKindLogo,
|
||||
Uri: "https://opencloud.eu/opencloud.svg",
|
||||
MediaType: "image/svg+xml",
|
||||
Contexts: map[MediaContext]bool{
|
||||
MediaContextWork: true,
|
||||
},
|
||||
Pref: 0,
|
||||
Label: "test",
|
||||
BlobId: "1d92cf97e32b42ceb5538f0804a41891",
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelation(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Relation",
|
||||
"relation": {
|
||||
"co-worker": true,
|
||||
"friend": true
|
||||
}
|
||||
}`, Relation{
|
||||
Type: RelationType,
|
||||
Relation: map[Relationship]bool{
|
||||
RelationCoWorker: true,
|
||||
RelationFriend: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNameComponent(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "NameComponent",
|
||||
"value": "Robert",
|
||||
"kind": "given",
|
||||
"phonetic": "Bob"
|
||||
}`, NameComponent{
|
||||
Type: NameComponentType,
|
||||
Value: "Robert",
|
||||
Kind: NameComponentKindGiven,
|
||||
Phonetic: "Bob",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNickname(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Nickname",
|
||||
"name": "Bob",
|
||||
"contexts": {
|
||||
"private": true
|
||||
},
|
||||
"pref": 3
|
||||
}`, Nickname{
|
||||
Type: NicknameType,
|
||||
Name: "Bob",
|
||||
Contexts: map[NicknameContext]bool{
|
||||
NicknameContextPrivate: true,
|
||||
},
|
||||
Pref: 3,
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrgUnit(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "OrgUnit",
|
||||
"name": "Skynet",
|
||||
"sortAs": "SKY"
|
||||
}`, OrgUnit{
|
||||
Type: OrgUnitType,
|
||||
Name: "Skynet",
|
||||
SortAs: "SKY",
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrganization(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Organization",
|
||||
"name": "Cyberdyne",
|
||||
"sortAs": "CYBER",
|
||||
"units": [{
|
||||
"@type": "OrgUnit",
|
||||
"name": "Skynet",
|
||||
"sortAs": "SKY"
|
||||
}, {
|
||||
"@type": "OrgUnit",
|
||||
"name": "Cybernics"
|
||||
}
|
||||
],
|
||||
"contexts": {
|
||||
"work": true
|
||||
}
|
||||
}`, Organization{
|
||||
Type: OrganizationType,
|
||||
Name: "Cyberdyne",
|
||||
SortAs: "CYBER",
|
||||
Units: []OrgUnit{
|
||||
{
|
||||
Type: OrgUnitType,
|
||||
Name: "Skynet",
|
||||
SortAs: "SKY",
|
||||
},
|
||||
{
|
||||
Type: OrgUnitType,
|
||||
Name: "Cybernics",
|
||||
},
|
||||
},
|
||||
Contexts: map[OrganizationContext]bool{
|
||||
OrganizationContextWork: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPronouns(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Pronouns",
|
||||
"pronouns": "they/them",
|
||||
"contexts": {
|
||||
"work": true,
|
||||
"private": true
|
||||
},
|
||||
"pref": 1
|
||||
}`, Pronouns{
|
||||
Type: PronounsType,
|
||||
Pronouns: "they/them",
|
||||
Contexts: map[PronounsContext]bool{
|
||||
PronounsContextWork: true,
|
||||
PronounsContextPrivate: true,
|
||||
},
|
||||
Pref: 1,
|
||||
})
|
||||
}
|
||||
|
||||
func TestTitle(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Title",
|
||||
"name": "Doctor",
|
||||
"kind": "title",
|
||||
"organizationId": "407e1992-9a2b-4e4f-a11b-85a509a4b5ae"
|
||||
}`, Title{
|
||||
Type: TitleType,
|
||||
Name: "Doctor",
|
||||
Kind: TitleKindTitle,
|
||||
OrganizationId: "407e1992-9a2b-4e4f-a11b-85a509a4b5ae",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpeakToAs(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "SpeakToAs",
|
||||
"grammaticalGender": "neuter",
|
||||
"pronouns": {
|
||||
"a": {
|
||||
"@type": "Pronouns",
|
||||
"pronouns": "they/them",
|
||||
"contexts": {
|
||||
"private": true
|
||||
},
|
||||
"pref": 1
|
||||
},
|
||||
"b": {
|
||||
"@type": "Pronouns",
|
||||
"pronouns": "he/him",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 99
|
||||
}
|
||||
}
|
||||
}`, SpeakToAs{
|
||||
Type: SpeakToAsType,
|
||||
GrammaticalGender: GrammaticalGenderNeuter,
|
||||
Pronouns: map[string]Pronouns{
|
||||
"a": {
|
||||
Type: PronounsType,
|
||||
Pronouns: "they/them",
|
||||
Contexts: map[PronounsContext]bool{
|
||||
PronounsContextPrivate: true,
|
||||
},
|
||||
Pref: 1,
|
||||
},
|
||||
"b": {
|
||||
Type: PronounsType,
|
||||
Pronouns: "he/him",
|
||||
Contexts: map[PronounsContext]bool{
|
||||
PronounsContextWork: true,
|
||||
},
|
||||
Pref: 99,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Name",
|
||||
"components": [
|
||||
{ "@type": "NameComponent", "kind": "given", "value": "Diego", "phonetic": "/di\u02C8e\u026A\u0261əʊ/" },
|
||||
{ "kind": "surname", "value": "Rivera" },
|
||||
{ "kind": "surname2", "value": "Barrientos" }
|
||||
],
|
||||
"isOrdered": true,
|
||||
"defaultSeparator": " ",
|
||||
"full": "Diego Rivera Barrientos",
|
||||
"sortAs": {
|
||||
"surname": "Rivera Barrientos",
|
||||
"given": "Diego"
|
||||
}
|
||||
}`, Name{
|
||||
Type: NameType,
|
||||
Components: []NameComponent{
|
||||
{
|
||||
Type: NameComponentType,
|
||||
Value: "Diego",
|
||||
Kind: NameComponentKindGiven,
|
||||
Phonetic: "/diˈeɪɡəʊ/",
|
||||
},
|
||||
{
|
||||
Value: "Rivera",
|
||||
Kind: NameComponentKindSurname,
|
||||
},
|
||||
{
|
||||
Value: "Barrientos",
|
||||
Kind: NameComponentKindSurname2,
|
||||
},
|
||||
},
|
||||
IsOrdered: true,
|
||||
DefaultSeparator: " ",
|
||||
Full: "Diego Rivera Barrientos",
|
||||
SortAs: map[string]string{
|
||||
string(NameComponentKindSurname): "Rivera Barrientos",
|
||||
string(NameComponentKindGiven): "Diego",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEmailAddress(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "EmailAddress",
|
||||
"address": "camina@opa.org",
|
||||
"contexts": {
|
||||
"work": true,
|
||||
"private": true
|
||||
},
|
||||
"pref": 1,
|
||||
"label": "bosmang"
|
||||
}`, EmailAddress{
|
||||
Type: EmailAddressType,
|
||||
Address: "camina@opa.org",
|
||||
Contexts: map[EmailAddressContext]bool{
|
||||
EmailAddressContextWork: true,
|
||||
EmailAddressContextPrivate: true,
|
||||
},
|
||||
Pref: 1,
|
||||
Label: "bosmang",
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnlineService(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "OnlineService",
|
||||
"service": "OPA Network",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"uri": "https://opa.org/cdrummer",
|
||||
"user": "cdrummer@opa.org",
|
||||
"pref": 12,
|
||||
"label": "opa"
|
||||
}`, OnlineService{
|
||||
Type: OnlineServiceType,
|
||||
Service: "OPA Network",
|
||||
Contexts: map[OnlineServiceContext]bool{
|
||||
OnlineServiceContextWork: true,
|
||||
},
|
||||
Uri: "https://opa.org/cdrummer", //NOSONAR
|
||||
User: "cdrummer@opa.org",
|
||||
Pref: 12,
|
||||
Label: "opa",
|
||||
})
|
||||
}
|
||||
|
||||
func TestPhone(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Phone",
|
||||
"number": "+15551234567",
|
||||
"features": {
|
||||
"text": true,
|
||||
"main-number": true,
|
||||
"cell": true,
|
||||
"video": true,
|
||||
"voice": true
|
||||
},
|
||||
"contexts": {
|
||||
"work": true,
|
||||
"private": true
|
||||
},
|
||||
"pref": 42,
|
||||
"label": "opa"
|
||||
}`, Phone{
|
||||
Type: PhoneType,
|
||||
Number: "+15551234567",
|
||||
Features: map[PhoneFeature]bool{
|
||||
PhoneFeatureText: true,
|
||||
PhoneFeatureMainNumber: true,
|
||||
PhoneFeatureMobile: true,
|
||||
PhoneFeatureVideo: true,
|
||||
PhoneFeatureVoice: true,
|
||||
},
|
||||
Contexts: map[PhoneContext]bool{
|
||||
PhoneContextWork: true,
|
||||
PhoneContextPrivate: true,
|
||||
},
|
||||
Pref: 42,
|
||||
Label: "opa",
|
||||
})
|
||||
}
|
||||
|
||||
func TestLanguagePref(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "LanguagePref",
|
||||
"language": "fr-BE",
|
||||
"contexts": {
|
||||
"private": true
|
||||
},
|
||||
"pref": 2
|
||||
}`, LanguagePref{
|
||||
Type: LanguagePrefType,
|
||||
Language: "fr-BE",
|
||||
Contexts: map[LanguagePrefContext]bool{
|
||||
LanguagePrefContextPrivate: true,
|
||||
},
|
||||
Pref: 2,
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchedulingAddress(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "SchedulingAddress",
|
||||
"uri": "mailto:camina@opa.org",
|
||||
"contexts": {
|
||||
"work": true
|
||||
},
|
||||
"pref": 3,
|
||||
"label": "opa"
|
||||
}`, SchedulingAddress{
|
||||
Type: SchedulingAddressType,
|
||||
Uri: "mailto:camina@opa.org",
|
||||
Label: "opa",
|
||||
Contexts: map[SchedulingAddressContext]bool{
|
||||
SchedulingAddressContextWork: true,
|
||||
},
|
||||
Pref: 3,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddressComponent(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "AddressComponent",
|
||||
"kind": "postcode",
|
||||
"value": "12345",
|
||||
"phonetic": "un-deux-trois-quatre-cinq"
|
||||
}`, AddressComponent{
|
||||
Type: AddressComponentType,
|
||||
Kind: AddressComponentKindPostcode,
|
||||
Value: "12345",
|
||||
Phonetic: "un-deux-trois-quatre-cinq",
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddress(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Address",
|
||||
"contexts": {
|
||||
"delivery": true,
|
||||
"work": true
|
||||
},
|
||||
"components": [
|
||||
{"@type": "AddressComponent", "kind": "number", "value": "54321"},
|
||||
{"kind": "separator", "value": " "},
|
||||
{"kind": "name", "value": "Oak St"},
|
||||
{"kind": "locality", "value": "Reston"},
|
||||
{"kind": "region", "value": "VA"},
|
||||
{"kind": "separator", "value": " "},
|
||||
{"kind": "postcode", "value": "20190"},
|
||||
{"kind": "country", "value": "USA"}
|
||||
],
|
||||
"countryCode": "US",
|
||||
"defaultSeparator": ", ",
|
||||
"isOrdered": true
|
||||
}`, Address{
|
||||
Type: AddressType,
|
||||
Contexts: map[AddressContext]bool{
|
||||
AddressContextDelivery: true,
|
||||
AddressContextWork: true,
|
||||
},
|
||||
Components: []AddressComponent{
|
||||
{Type: AddressComponentType, Kind: AddressComponentKindNumber, Value: "54321"},
|
||||
{Kind: AddressComponentKindSeparator, Value: " "},
|
||||
{Kind: AddressComponentKindName, Value: "Oak St"},
|
||||
{Kind: AddressComponentKindLocality, Value: "Reston"},
|
||||
{Kind: AddressComponentKindRegion, Value: "VA"},
|
||||
{Kind: AddressComponentKindSeparator, Value: " "},
|
||||
{Kind: AddressComponentKindPostcode, Value: "20190"},
|
||||
{Kind: AddressComponentKindCountry, Value: "USA"},
|
||||
},
|
||||
CountryCode: "US",
|
||||
DefaultSeparator: ", ",
|
||||
IsOrdered: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestPartialDate(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "PartialDate",
|
||||
"year": 2025,
|
||||
"month": 9,
|
||||
"day": 25,
|
||||
"calendarScale": "iso8601"
|
||||
}`, PartialDate{
|
||||
Type: PartialDateType,
|
||||
Year: 2025,
|
||||
Month: 9,
|
||||
Day: 25,
|
||||
CalendarScale: "iso8601",
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimestamp(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00") //NOSONAR
|
||||
require.NoError(t, err)
|
||||
jsoneq(t, `{
|
||||
"@type": "Timestamp",
|
||||
"utc": "2025-09-25T18:26:14.094725532+02:00"
|
||||
}`, &Timestamp{
|
||||
Type: TimestampType,
|
||||
Utc: ts,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnniversaryWithPartialDate(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Anniversary",
|
||||
"kind": "birth",
|
||||
"date": {
|
||||
"@type": "PartialDate",
|
||||
"year": 2025,
|
||||
"month": 9,
|
||||
"day": 25
|
||||
}
|
||||
}`, Anniversary{
|
||||
Type: AnniversaryType,
|
||||
Kind: AnniversaryKindBirth,
|
||||
Date: &PartialDate{
|
||||
Type: PartialDateType,
|
||||
Year: 2025,
|
||||
Month: 9,
|
||||
Day: 25,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnniversaryWithTimestamp(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Anniversary",
|
||||
"kind": "birth",
|
||||
"date": {
|
||||
"@type": "Timestamp",
|
||||
"utc": "2025-09-25T18:26:14.094725532+02:00"
|
||||
}
|
||||
}`, Anniversary{
|
||||
Type: AnniversaryType,
|
||||
Kind: AnniversaryKindBirth,
|
||||
Date: &Timestamp{
|
||||
Type: TimestampType,
|
||||
Utc: ts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthor(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "Author",
|
||||
"name": "Camina Drummer",
|
||||
"uri": "https://opa.org/cdrummer"
|
||||
}`, Author{
|
||||
Type: AuthorType,
|
||||
Name: "Camina Drummer",
|
||||
Uri: "https://opa.org/cdrummer",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNote(t *testing.T) {
|
||||
ts, err := time.Parse(time.RFC3339, "2025-09-25T18:26:14.094725532+02:00")
|
||||
require.NoError(t, err)
|
||||
|
||||
jsoneq(t, `{
|
||||
"@type": "Note",
|
||||
"note": "this is a note",
|
||||
"created": "2025-09-25T18:26:14.094725532+02:00",
|
||||
"author": {
|
||||
"@type": "Author",
|
||||
"name": "Camina Drummer",
|
||||
"uri": "https://opa.org/cdrummer"
|
||||
}
|
||||
}`, Note{
|
||||
Type: NoteType,
|
||||
Note: "this is a note",
|
||||
Created: ts,
|
||||
Author: &Author{
|
||||
Type: AuthorType,
|
||||
Name: "Camina Drummer",
|
||||
Uri: "https://opa.org/cdrummer",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestPersonalInfo(t *testing.T) {
|
||||
jsoneq(t, `{
|
||||
"@type": "PersonalInfo",
|
||||
"kind": "expertise",
|
||||
"value": "motivation",
|
||||
"level": "high",
|
||||
"listAs": 1,
|
||||
"label": "opa"
|
||||
}`, PersonalInfo{
|
||||
Type: PersonalInfoType,
|
||||
Kind: PersonalInfoKindExpertise,
|
||||
Value: "motivation",
|
||||
Level: PersonalInfoLevelHigh,
|
||||
ListAs: 1,
|
||||
Label: "opa",
|
||||
})
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package log
|
||||
|
||||
import "github.com/rs/zerolog"
|
||||
|
||||
const (
|
||||
logMaxStrLength = 512
|
||||
logMaxStrArrayLength = 16 // 8kb
|
||||
)
|
||||
|
||||
// Safely caps a string to a given size to avoid log bombing.
|
||||
// Use this function to wrap strings that are user input (HTTP headers, path parameters, URI parameters, HTTP body, ...).
|
||||
func SafeString[S ~string](text S) string {
|
||||
t := string(text)
|
||||
runes := []rune(t)
|
||||
|
||||
if len(runes) <= logMaxStrLength {
|
||||
return t
|
||||
} else {
|
||||
return string(runes[0:logMaxStrLength-1]) + `\u2026` // hellip
|
||||
}
|
||||
}
|
||||
|
||||
type SafeLogStringArrayMarshaller struct {
|
||||
array []string
|
||||
}
|
||||
|
||||
func (m SafeLogStringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) {
|
||||
for i, elem := range m.array {
|
||||
if i >= logMaxStrArrayLength {
|
||||
return
|
||||
}
|
||||
a.Str(SafeString(elem))
|
||||
}
|
||||
}
|
||||
|
||||
var _ zerolog.LogArrayMarshaler = SafeLogStringArrayMarshaller{}
|
||||
|
||||
func SafeStringArray(array []string) SafeLogStringArrayMarshaller {
|
||||
return SafeLogStringArrayMarshaller{array: array}
|
||||
}
|
||||
|
||||
type StringArrayMarshaller struct {
|
||||
array []string
|
||||
}
|
||||
|
||||
func (m StringArrayMarshaller) MarshalZerologArray(a *zerolog.Array) {
|
||||
for _, elem := range m.array {
|
||||
a.Str(elem)
|
||||
}
|
||||
}
|
||||
|
||||
var _ zerolog.LogArrayMarshaler = StringArrayMarshaller{}
|
||||
|
||||
func StringArray(array []string) StringArrayMarshaller {
|
||||
return StringArrayMarshaller{array: array}
|
||||
}
|
||||
|
||||
func From(context zerolog.Context) *Logger {
|
||||
return &Logger{Logger: context.Logger()}
|
||||
}
|
||||
@@ -56,9 +56,9 @@ type Cache struct {
|
||||
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE" desc:"Disables persistence of the cache. Only applies when store type 'nats-js-kv' is configured. Defaults to false." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"auth_username" env:"OC_CACHE_AUTH_USERNAME" desc:"The username to use for authentication. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"auth_password" env:"OC_CACHE_AUTH_PASSWORD" desc:"The password to use for authentication. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_CACHE_ENABLE_TLS" desc:"Enable TLS for the connection to file metadata cache." introductionVersion:"%%NEXT%%"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_CACHE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_CACHE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided OC_CACHE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_CACHE_ENABLE_TLS" desc:"Enable TLS for the connection to file metadata cache." introductionVersion:"7.2.0"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_CACHE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"7.2.0"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_CACHE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided OC_CACHE_TLS_INSECURE will be seen as false." introductionVersion:"7.2.0"`
|
||||
}
|
||||
|
||||
// Commons holds configuration that are common to all extensions. Each extension can then decide whether
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
// Package structs provides some utility functions for dealing with structs.
|
||||
package structs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map"
|
||||
)
|
||||
|
||||
// CopyOrZeroValue returns a copy of s if s is not nil otherwise the zero value of T will be returned.
|
||||
func CopyOrZeroValue[T any](s *T) *T {
|
||||
cp := new(T)
|
||||
@@ -18,414 +9,3 @@ func CopyOrZeroValue[T any](s *T) *T {
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// Create an iterator from a slice, iterating over every single element of the slice, in order.
|
||||
func Seq[T any](s []T) iter.Seq[T] {
|
||||
return func(yield func(T) bool) {
|
||||
for _, elem := range s {
|
||||
if !yield(elem) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create an iterator from a slice that yields the position and the value,
|
||||
// iterating over every single element of the slice, in order.
|
||||
func Seq2[T any](s []T) iter.Seq2[int, T] {
|
||||
return func(yield func(int, T) bool) {
|
||||
for i, elem := range s {
|
||||
if !yield(i, elem) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a copy of an array with a unique set of elements.
|
||||
//
|
||||
// Element order is retained.
|
||||
func Uniq[T comparable](source []T) []T {
|
||||
m := orderedmap.New()
|
||||
for _, v := range source {
|
||||
m.Set(v, true)
|
||||
}
|
||||
set := make([]T, m.Len())
|
||||
i := 0
|
||||
for pair := m.Oldest(); pair != nil; pair = pair.Next() {
|
||||
set[i] = pair.Key.(T)
|
||||
i++
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Returns a slice containing the keys of the map.
|
||||
func Keys[K comparable, V any](source map[K]V) []K {
|
||||
if source == nil {
|
||||
var zero []K
|
||||
return zero
|
||||
}
|
||||
return slices.Collect(maps.Keys(source))
|
||||
}
|
||||
|
||||
// Creates a map from a slice, using the indexer func to determine the key for each value,
|
||||
// and the value being as-is.
|
||||
func Index[K comparable, V any](source []V, indexer func(V) K) map[K]V {
|
||||
if source == nil {
|
||||
var zero map[K]V
|
||||
return zero
|
||||
}
|
||||
result := map[K]V{}
|
||||
for _, v := range source {
|
||||
k := indexer(v)
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func Set[V comparable](source []V) map[V]struct{} {
|
||||
if source == nil {
|
||||
var zero map[V]struct{}
|
||||
return zero
|
||||
}
|
||||
result := map[V]struct{}{}
|
||||
for _, v := range source {
|
||||
result[v] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ToStrings[A ~string](s []A) []string {
|
||||
return Map(s, func(a A) string { return string(a) })
|
||||
}
|
||||
|
||||
// Creates a slice from a slice, putting each value from the source slice through the
|
||||
// mapper function to determine the value to store into the resulting slice.
|
||||
func Map[E any, R any](source []E, mapper func(E) R) []R {
|
||||
if source == nil {
|
||||
var zero []R
|
||||
return zero
|
||||
}
|
||||
result := make([]R, len(source))
|
||||
for i, e := range source {
|
||||
result[i] = mapper(e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Wraps an iterator with a transformer function.
|
||||
func MapSeq[A, B any](it iter.Seq[A], transformer func(A) B) iter.Seq[B] {
|
||||
return func(yield func(b B) bool) {
|
||||
for v := range it {
|
||||
t := transformer(v)
|
||||
if !yield(t) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a slice from a slice, putting each value from the source slice through the
|
||||
// mapper function to determine the value to store into the resulting slice, but skipping
|
||||
// the result of the mapper function if it returns nil.
|
||||
func MapN[E any, R any](source []E, indexer func(E) *R) []R {
|
||||
if source == nil {
|
||||
var zero []R
|
||||
return zero
|
||||
}
|
||||
result := []R{}
|
||||
for _, e := range source {
|
||||
opt := indexer(e)
|
||||
if opt != nil {
|
||||
result = append(result, *opt)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Creates a slice from a slice, putting each value from the source slice through the
|
||||
// mapper function to determine the value to store into the resulting slice, but skipping
|
||||
// the result of the mapper function if it returns false as its second return value.
|
||||
func MapO[E any, R any](source []E, indexer func(E) (R, bool)) []R {
|
||||
if source == nil {
|
||||
var zero []R
|
||||
return zero
|
||||
}
|
||||
result := []R{}
|
||||
for _, e := range source {
|
||||
if value, keep := indexer(e); keep {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Created a map from a map, mapping both the key and the value using the mapper function.
|
||||
func MapMap[A comparable, B any, X comparable, Y any](m map[A]B, mapper func(A, B) (X, Y)) map[X]Y {
|
||||
r := make(map[X]Y, len(m))
|
||||
for a, b := range m {
|
||||
x, y := mapper(a, b)
|
||||
r[x] = y
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func FlatMap[K comparable, V any, E any](m map[K]V, mapper func(K, V) E) []E {
|
||||
r := make([]E, len(m))
|
||||
for k, v := range m {
|
||||
r = append(r, mapper(k, v))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Creates a map from a map, keeping each key as-is, and using the mapper
|
||||
// function to determine the value to store into the resulting map.
|
||||
func MapValues[K comparable, S any, T any](m map[K]S, mapper func(S) T) map[K]T {
|
||||
r := make(map[K]T, len(m))
|
||||
for k, s := range m {
|
||||
r[k] = mapper(s)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Creates a map from a map, keeping each key as-is, and using the mapper function
|
||||
// that takes both the key and the value to determine the value to store into the resulting map.
|
||||
func MapValues2[K comparable, S any, T any](m map[K]S, mapper func(K, S) T) map[K]T {
|
||||
r := make(map[K]T, len(m))
|
||||
for k, s := range m {
|
||||
r[k] = mapper(k, s)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Creates a map from a map, keeping each value as-is, and using the mapper
|
||||
// function to determine the key to store into the resulting map.
|
||||
func MapKeys[S comparable, T comparable, V any](m map[S]V, mapper func(S) T) map[T]V {
|
||||
r := make(map[T]V, len(m))
|
||||
for s, v := range m {
|
||||
r[mapper(s)] = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Creates a map from a map, keeping each value as-is, and using the mapper function
|
||||
// that takes both the key and the value to determine the key to store into the resulting map.
|
||||
func MapKeys2[S comparable, T comparable, V any](m map[S]V, mapper func(S, V) T) map[T]V {
|
||||
r := make(map[T]V, len(m))
|
||||
for s, v := range m {
|
||||
r[mapper(s, v)] = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Creates a map from a slice, using the mapper function to determine the key and value
|
||||
// pair to use for each slice element in the resulting map.
|
||||
func ToMap[E any, K comparable, V any](source []E, mapper func(E) (K, V)) map[K]V {
|
||||
m := map[K]V{}
|
||||
for _, e := range source {
|
||||
k, v := mapper(e)
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Creates a map of booleans, using the values of the source slice as keys in the
|
||||
// resulting map.
|
||||
func ToBoolMap[E comparable](source []E) map[E]bool {
|
||||
m := make(map[E]bool, len(source))
|
||||
for _, v := range source {
|
||||
m[v] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Creates a map of ints, using the values of the source slice as keys in the
|
||||
// resulting map, and storing the number of occurences of every given value
|
||||
// as the int value in the map.
|
||||
func ToIntMap[E comparable](source []E) map[E]int {
|
||||
m := make(map[E]int, len(source))
|
||||
for _, v := range source {
|
||||
if e, ok := m[v]; ok {
|
||||
m[v] = e + 1
|
||||
} else {
|
||||
m[v] = 1
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Check whether two slices contain the same elements, ignoring order.
|
||||
func SameSlices[E comparable](x, y []E) bool {
|
||||
// https://stackoverflow.com/a/36000696
|
||||
if len(x) != len(y) {
|
||||
return false
|
||||
}
|
||||
// create a map of string -> int
|
||||
diff := make(map[E]int, len(x))
|
||||
for _, _x := range x {
|
||||
// 0 value for int is 0, so just increment a counter for the string
|
||||
diff[_x]++
|
||||
}
|
||||
for _, _y := range y {
|
||||
// If the string _y is not in diff bail out early
|
||||
if _, ok := diff[_y]; !ok {
|
||||
return false
|
||||
}
|
||||
diff[_y]--
|
||||
if diff[_y] == 0 {
|
||||
delete(diff, _y)
|
||||
}
|
||||
}
|
||||
return len(diff) == 0
|
||||
}
|
||||
|
||||
// Concatenate the elements of multiple slices into a single slice.
|
||||
//
|
||||
// Element order is preserved.
|
||||
func Concat[E any](arys ...[]E) []E {
|
||||
l := 0
|
||||
for _, ary := range arys {
|
||||
l += len(ary)
|
||||
}
|
||||
r := make([]E, l)
|
||||
|
||||
i := 0
|
||||
for _, ary := range arys {
|
||||
if ary != nil {
|
||||
i += copy(r[i:], ary)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Create a new slice from a slice, determining whether each element should
|
||||
// be added to the new slice by passing it to the predicate function.
|
||||
//
|
||||
// When the predicate function returns true, the element is stored in the
|
||||
// new slice.
|
||||
// When the predicate functoin returns false, the element is skipped and not
|
||||
// stored in the new slice.
|
||||
func Filter[E any](s []E, predicate func(E) bool) []E {
|
||||
if s == nil {
|
||||
var zero []E
|
||||
return zero
|
||||
}
|
||||
r := []E{}
|
||||
for _, e := range s {
|
||||
if predicate(e) {
|
||||
r = append(r, e)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Wrap an iterator with a conditional/filtering predicate function.
|
||||
func FilterSeq[T any](it iter.Seq[T], predicate func(T) bool) iter.Seq[T] {
|
||||
return func(yield func(s T) bool) {
|
||||
for v := range it {
|
||||
b := predicate(v)
|
||||
if b {
|
||||
if !yield(v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FilterKeys[K comparable, V any](m map[K]V, predicate func(K, V) bool) []K {
|
||||
if m == nil {
|
||||
return []K{}
|
||||
}
|
||||
r := []K{}
|
||||
for k, v := range m {
|
||||
if predicate(k, v) {
|
||||
r = append(r, k)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func FilterValues[K comparable, V any](m map[K]V, predicate func(K, V) bool) []V {
|
||||
if m == nil {
|
||||
return []V{}
|
||||
}
|
||||
r := []V{}
|
||||
for k, v := range m {
|
||||
if predicate(k, v) {
|
||||
r = append(r, v)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func MeshMap[A any, B any, K comparable, V any](keys []A, values []B, mapper func(A, B) (K, V, bool)) (map[K]V, error) {
|
||||
m := map[K]V{}
|
||||
if len(keys) != len(values) {
|
||||
return nil, fmt.Errorf("different length for slices")
|
||||
}
|
||||
for i := range keys {
|
||||
if k, v, b := mapper(keys[i], values[i]); b {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func First[T any](values []T, predicate func(T) bool) (T, bool) {
|
||||
for _, value := range values {
|
||||
if predicate(value) {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
func Reduce[T any](initialValue T, s []T, reducer func(a, b T) T) T {
|
||||
reduced := initialValue
|
||||
for _, value := range s {
|
||||
reduced = reducer(reduced, value)
|
||||
}
|
||||
return reduced
|
||||
}
|
||||
|
||||
func Flatten[T any](s [][]T) []T {
|
||||
l := 0
|
||||
for _, r := range s {
|
||||
if r != nil {
|
||||
l += len(r)
|
||||
}
|
||||
}
|
||||
result := make([]T, 0, l)
|
||||
for _, r := range s {
|
||||
if r != nil {
|
||||
result = append(result, r...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func AllMatch[T any](s []T, predicate func(e T) bool) bool {
|
||||
for _, e := range s {
|
||||
if !predicate(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func Distribute[T comparable, V any](s []V, distributor func(e V) T) map[T][]V {
|
||||
result := map[T][]V{}
|
||||
for _, e := range s {
|
||||
k := distributor(e)
|
||||
if l, ok := result[k]; ok {
|
||||
l = append(l, e)
|
||||
result[k] = l
|
||||
} else {
|
||||
l := []V{e}
|
||||
result[k] = l
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
+1
-307
@@ -1,13 +1,6 @@
|
||||
package structs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
type example struct {
|
||||
Attribute1 string
|
||||
@@ -43,302 +36,3 @@ func TestCopyOrZeroValue(t *testing.T) {
|
||||
t.Error("CopyOrZeroValue didn't correctly copy attributes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqWithInts(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []int
|
||||
expected []int
|
||||
}{
|
||||
{[]int{5, 1, 3, 1, 4}, []int{5, 1, 3, 4}},
|
||||
{[]int{1, 1, 1}, []int{1}},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) { //NOSONAR
|
||||
result := Uniq(tt.input)
|
||||
assert.EqualValues(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type u struct {
|
||||
x int
|
||||
y string
|
||||
}
|
||||
|
||||
var (
|
||||
u1 = u{x: 1, y: "un"}
|
||||
u2 = u{x: 2, y: "deux"}
|
||||
u3 = u{x: 3, y: "trois"}
|
||||
)
|
||||
|
||||
func TestUniqWithStructs(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []u
|
||||
expected []u
|
||||
}{
|
||||
{[]u{u3, u1, u2, u3, u2, u1}, []u{u3, u1, u2}},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := Uniq(tt.input)
|
||||
assert.EqualValues(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
input map[int]string
|
||||
expected []int
|
||||
}{
|
||||
{map[int]string{5: "cinq", 1: "un", 3: "trois", 4: "vier"}, []int{5, 1, 3, 4}},
|
||||
{map[int]string{1: "un"}, []int{1}},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := Keys(tt.input)
|
||||
assert.ElementsMatch(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []string
|
||||
indexer func(string) string
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
[]string{"un", "deux", "trois"},
|
||||
strings.ToUpper,
|
||||
map[string]string{"UN": "un", "DEUX": "deux", "TROIS": "trois"},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := Index(tt.input, tt.indexer)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMap(t *testing.T) {
|
||||
{
|
||||
m := map[string]int{
|
||||
"un": 1,
|
||||
"deux": 2,
|
||||
"trois": 3,
|
||||
}
|
||||
n := MapMap(m, func(a string, b int) (string, int) { return strings.ToUpper(a), b + 100 })
|
||||
assert.Len(t, n, 3)
|
||||
assert.Contains(t, n, "UN")
|
||||
assert.Equal(t, 101, n["UN"])
|
||||
assert.Contains(t, n, "DEUX")
|
||||
assert.Equal(t, 102, n["DEUX"])
|
||||
assert.Contains(t, n, "TROIS")
|
||||
assert.Equal(t, 103, n["TROIS"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []string
|
||||
mapper func(string) int
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
nil,
|
||||
func(s string) int { return len(s) },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]string{},
|
||||
func(s string) int { return len(s) },
|
||||
[]int{},
|
||||
},
|
||||
{
|
||||
[]string{"un", "deux", "trois"},
|
||||
func(s string) int { return len(s) },
|
||||
[]int{2, 4, 5},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := Map(tt.input, tt.mapper)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSeq(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []string
|
||||
mapper func(string) int
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
nil,
|
||||
func(s string) int { return len(s) },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]string{},
|
||||
func(s string) int { return len(s) },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]string{"un", "deux", "trois"},
|
||||
func(s string) int { return len(s) },
|
||||
[]int{2, 4, 5},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := slices.Collect(MapSeq(Seq(tt.input), tt.mapper))
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcat(t *testing.T) {
|
||||
assert.Equal(t, []string{"a", "b", "c", "d", "e", "f"}, Concat([]string{"a", "b"}, []string{"c"}, []string{"d", "e", "f"}))
|
||||
assert.Equal(t, []string{"a"}, Concat([]string{"a"}))
|
||||
assert.Equal(t, []string{"a"}, Concat([]string{}, nil, []string{"a"}))
|
||||
assert.Equal(t, []string{}, Concat[string]())
|
||||
}
|
||||
|
||||
func TestFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []int
|
||||
predicate func(int) bool
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
nil,
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]int{},
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
[]int{},
|
||||
},
|
||||
{
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
[]int{2, 4},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := Filter(tt.input, tt.predicate)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSeq(t *testing.T) {
|
||||
tests := []struct {
|
||||
input []int
|
||||
predicate func(int) bool
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
nil,
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]int{},
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
func(i int) bool { return i%2 == 0 },
|
||||
[]int{2, 4},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%d: testing %v", i+1, tt.input), func(t *testing.T) {
|
||||
result := slices.Collect(FilterSeq(Seq(tt.input), tt.predicate))
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
s := Set([]string{"a", "b", "c", "b", "d"})
|
||||
assert.Len(t, s, 4)
|
||||
for _, e := range []string{"a", "b", "c", "d"} {
|
||||
_, ok := s[e]
|
||||
assert.True(t, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduce(t *testing.T) {
|
||||
{
|
||||
result := Reduce(0, []int{1, 2, 3}, func(a, b int) int { return a + b })
|
||||
assert.Equal(t, 6, result)
|
||||
}
|
||||
{
|
||||
result := Reduce(0, []int{}, func(a, b int) int { return a + b })
|
||||
assert.Equal(t, 0, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatten(t *testing.T) {
|
||||
{
|
||||
result := Flatten([][]int{{1, 2, 3}, {4}, {5, 6, 7}, {}, {8}})
|
||||
assert.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8}, result)
|
||||
}
|
||||
{
|
||||
result := Flatten([][]int{})
|
||||
assert.Equal(t, []int{}, result)
|
||||
}
|
||||
{
|
||||
result := Flatten([][]int{nil, nil})
|
||||
assert.Equal(t, []int{}, result)
|
||||
}
|
||||
{
|
||||
result := Flatten([][]int{nil, {}, {1}})
|
||||
assert.Equal(t, []int{1}, result)
|
||||
}
|
||||
{
|
||||
result := Flatten[int](nil)
|
||||
assert.Equal(t, []int{}, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllMatch(t *testing.T) {
|
||||
assert.True(t, AllMatch([]int{1, 2, 3}, func(i int) bool { return i > 0 }))
|
||||
assert.False(t, AllMatch([]int{1, 2, 0, 3}, func(i int) bool { return i > 0 }))
|
||||
assert.False(t, AllMatch([]int{1, 2, 3, 0}, func(i int) bool { return i > 0 }))
|
||||
assert.False(t, AllMatch([]int{0}, func(i int) bool { return i > 0 }))
|
||||
assert.True(t, AllMatch([]int{}, func(i int) bool { return i > 0 }))
|
||||
}
|
||||
|
||||
func TestDistribute(t *testing.T) {
|
||||
{
|
||||
result := Distribute([]string{"Z", "a", "b", "X", "c", "Y"}, func(e string) int {
|
||||
if strings.ToUpper(e) == e {
|
||||
return 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
assert.Len(t, result, 2)
|
||||
assert.Contains(t, result, 1)
|
||||
assert.Equal(t, result[1], []string{"Z", "X", "Y"})
|
||||
assert.Contains(t, result, 0)
|
||||
assert.Equal(t, result[0], []string{"a", "b", "c"})
|
||||
}
|
||||
{
|
||||
result := Distribute([]string{}, func(e string) int { return 1 })
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
{
|
||||
result := Distribute(nil, func(e string) int { return 1 })
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ var (
|
||||
// LatestTag is the latest released version plus the dev meta version.
|
||||
// Will be overwritten by the release pipeline
|
||||
// Needs a manual change for every tagged release
|
||||
LatestTag = "7.1.0+dev"
|
||||
LatestTag = "7.2.0-rc.1+dev"
|
||||
|
||||
// Date indicates the build date.
|
||||
// This has been removed, it looks like you can only replace static strings with recent go versions
|
||||
|
||||
@@ -56,9 +56,9 @@ type Store struct {
|
||||
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;ACTIVITYLOG_STORE_TTL" desc:"Time to live for events in the store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
|
||||
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;ACTIVITYLOG_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;ACTIVITYLOG_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;ACTIVITYLOG_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"%%NEXT%%"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;ACTIVITYLOG_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;ACTIVITYLOG_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided ACTIVITYLOG_STORE_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"`
|
||||
EnableTLS bool `yaml:"enable_tls" env:"OC_PERSISTENT_STORE_ENABLE_TLS;ACTIVITYLOG_STORE_ENABLE_TLS" desc:"Enable TLS for the connection to the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"7.2.0"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_PERSISTENT_STORE_TLS_INSECURE;ACTIVITYLOG_STORE_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"7.2.0"`
|
||||
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_PERSISTENT_STORE_TLS_ROOT_CA_CERTIFICATE;ACTIVITYLOG_STORE_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided ACTIVITYLOG_STORE_TLS_INSECURE will be seen as false." introductionVersion:"7.2.0"`
|
||||
}
|
||||
|
||||
// ServiceAccount is the configuration for the used service account
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Ivan Fustero, 2025\n"
|
||||
"Language-Team: Catalan (https://app.transifex.com/opencloud-eu/teams/204053/ca/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Jörn Friedrich Dreyer <jfd@butonic.de>, 2025\n"
|
||||
"Language-Team: German (https://app.transifex.com/opencloud-eu/teams/204053/de/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Efstathios Iosifidis <eiosifidis@gmail.com>, 2026\n"
|
||||
"Language-Team: Greek (https://app.transifex.com/opencloud-eu/teams/204053/el/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Elías Martín, 2025\n"
|
||||
"Language-Team: Spanish (https://app.transifex.com/opencloud-eu/teams/204053/es/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2025\n"
|
||||
"Language-Team: Finnish (https://app.transifex.com/opencloud-eu/teams/204053/fi/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: eric_G <junk.eg@free.fr>, 2025\n"
|
||||
"Language-Team: French (https://app.transifex.com/opencloud-eu/teams/204053/fr/)\n"
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-06-29 23:15+0000\n"
|
||||
"POT-Creation-Date: 2026-06-09 09:30+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: mitibor, 2026\n"
|
||||
"Language-Team: Hungarian (https://app.transifex.com/opencloud-eu/teams/204053/hu/)\n"
|
||||
|
||||
Loaded 100 of 1585 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user