Compare commits

..

11 Commits

Author SHA1 Message Date
kolaente ab038ec6c4 feat(audit): forward audit entries to stdout, syslog and webhook sinks
Adds the audit.forwarders config list and a Sink interface with three
implementations: stdout, RFC 5424 syslog over UDP/TCP (hand-rolled —
stdlib log/syslog only emits RFC 3164 and does not build on Windows),
and webhook POSTs through the SSRF-safe HTTP client. Forwarders are
best-effort fan-out: the local file stays the source of truth and a
dead sink is logged instead of poison-queueing every event.
2026-06-10 22:22:20 +02:00
kolaente ae908be716 fix: dispatch pending events after user creation commits
The register handler, local/LDAP login and the OIDC callback all queue
the user.created event via DispatchOnCommit but never called
DispatchPending, so the event was silently dropped and its queue entry
leaked. Flush after commit and discard on rollback.
2026-06-10 22:20:59 +02:00
kolaente 126ea78dac refactor(events): pass context to DispatchPending directly
Every DispatchPending caller either has the request context in scope or
is genuinely request-less, so passing it as a parameter replaces the
stored-context mechanism on the pending queue and satisfies
contextcheck. Also fixes lint findings in the audit package.
2026-06-10 22:19:04 +02:00
kolaente 3fc5813888 docs(audit): add package documentation 2026-06-10 22:18:50 +02:00
kolaente 3d8c259242 test(audit): cover listener pipeline, license gating and rotation 2026-06-10 22:18:11 +02:00
kolaente 6ab03d3f87 feat(audit): register the audited event surface
One config-gated block in RegisterListeners maps every opted-in event
to its audit entry. Events with interface-typed doers are decoded via
a small doer ref that distinguishes link shares by their hash field.
2026-06-10 22:18:11 +02:00
kolaente de22af0048 feat(events): add auth boundary events
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.

None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
2026-06-10 22:18:11 +02:00
kolaente 4ff8181a47 feat(audit): wire request-meta middleware and writer initialization 2026-06-10 22:18:11 +02:00
kolaente 939daaf1ab feat(audit): add audit logging package
Entry schema with constructor-enforced actor/target types, a generic
RegisterEventForAudit helper that maps opted-in events to entries on
the existing watermill bus (license-gated per event since licenses are
runtime-mutable), and a JSONL writer with size-based rotation,
age-based cleanup of rotated files and batched fsync.
2026-06-10 22:18:11 +02:00
kolaente a4bbd02d6a feat(config): add audit logging config keys 2026-06-10 22:17:42 +02:00
kolaente 5db25ab75c feat(events): carry request metadata onto dispatched event messages
Adds a RequestMeta context bridge so events dispatched during an HTTP
request can be attributed to it: a middleware stashes IP/UA/request-id
on the request context, the generic Do* handlers associate that context
with the transaction key, and DispatchPending/DispatchWithContext copy
the metadata onto the watermill message at publish time. Existing
dispatch call sites are unchanged.
2026-06-10 21:00:41 +02:00
295 changed files with 6566 additions and 20341 deletions

3
.envrc Normal file
View File

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

View File

@ -79,7 +79,7 @@ runs:
} >> "$GITHUB_ENV"
- name: Download Mage binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
@ -89,7 +89,7 @@ runs:
- name: Download frontend dist (vikunja only)
if: inputs.project == 'vikunja'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: frontend/dist
@ -110,7 +110,7 @@ runs:
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
- name: Setup xgo cache
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
with:
path: /home/runner/.xgo-cache
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
@ -133,7 +133,7 @@ runs:
cd build && mage release:build "$PROJECT"
- name: GPG setup
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
@ -164,7 +164,7 @@ runs:
done
- name: Upload zips to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
@ -176,14 +176,14 @@ runs:
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
- name: Store binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_BINARIES_NAME }}
path: ./${{ env.DIST_PREFIX }}/binaries/*
- name: Store binary packages
if: github.ref_type == 'tag'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_ZIPS_NAME }}
path: ./${{ env.DIST_PREFIX }}/zip/*

View File

@ -91,12 +91,12 @@ runs:
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
- name: Download project binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: ${{ env.BINARIES_ARTIFACT_NAME }}
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
@ -123,7 +123,7 @@ runs:
- name: GPG setup for archlinux signing
if: inputs.packager == 'archlinux'
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
uses: kolaente/action-gpg@main
with:
gpg-passphrase: ${{ inputs.gpg-passphrase }}
gpg-sign-key: ${{ inputs.gpg-sign-key }}
@ -163,7 +163,7 @@ runs:
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
- name: Create package
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
uses: kolaente/action-gh-nfpm@master
with:
packager: ${{ inputs.packager }}
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
@ -186,7 +186,7 @@ runs:
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
- name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ inputs.s3-access-key-id }}
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
@ -198,7 +198,7 @@ runs:
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
- name: Store OS package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*

View File

@ -16,11 +16,11 @@ runs:
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
run_install: false
package_json_file: frontend/package.json
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version-file: frontend/.nvmrc
cache: 'pnpm'

View File

@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout (for prompt template)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
.github/workflows/auto-label.prompt.md
@ -29,7 +29,7 @@ jobs:
- name: Render system prompt from live labels
id: render
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
with:
@ -122,7 +122,7 @@ jobs:
- name: Classify with AI
id: classify
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4.1-mini
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
@ -132,7 +132,7 @@ jobs:
prompt-file: ${{ steps.prep.outputs.prompt_path }}
- name: Apply labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
AI_RESPONSE: ${{ steps.classify.outputs.response }}
with:

View File

@ -9,19 +9,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true
- name: push source files
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with:
command: 'push'
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: pull translations
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
with:
command: 'download'
command_args: '--export-only-approved --skip-untranslated-strings'
@ -29,7 +29,7 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version-file: frontend/.nvmrc
- name: Ensure file permissions
@ -55,7 +55,7 @@ jobs:
git commit -m "chore(i18n): update translations via Crowdin"
- name: Push changes
if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
uses: ad-m/github-push-action@master
with:
ssh: true
branch: ${{ github.ref }}

View File

@ -18,11 +18,11 @@ jobs:
directory: [frontend, desktop]
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Diff
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
uses: e18e/action-dependency-diff@v1
with:
working-directory: ${{ matrix.directory }}
@ -33,11 +33,11 @@ jobs:
directory: [frontend, desktop]
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check provenance downgrades
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
uses: danielroe/provenance-action@main
with:
workspace-path: ${{ matrix.directory }}
fail-on-provenance-change: true

View File

@ -10,14 +10,14 @@ jobs:
steps:
- name: Generate GitHub App token
id: generate-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.BOT_APP_ID }}
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
- name: Find closing PR or commit
id: find-closer
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@ -82,7 +82,7 @@ jobs:
- name: Comment on issue
if: steps.find-closer.outputs.closed_by_code == 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |

View File

@ -25,7 +25,7 @@ jobs:
docker-images: false
swap-storage: false
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
# For pull_request_target, we need to explicitly fetch the PR ref from forks
# since the PR's commit SHA is not reachable in the base repository.
@ -34,27 +34,27 @@ jobs:
ref: refs/pull/${{ github.event.pull_request.number }}/head
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
version: latest
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
tags: |
type=ref,event=pr
type=sha,format=long
- name: Build and push PR image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: linux/amd64
@ -66,7 +66,7 @@ jobs:
build-args: |
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Comment on PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
with:

View File

@ -8,14 +8,14 @@ jobs:
runs-on: ubuntu-latest
name: prepare-build-mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Cache build mage
id: cache-build-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
path: |
@ -33,7 +33,7 @@ jobs:
export PATH=$PATH:$GOPATH/bin
mage -compile ./build-mage-static
- name: Store build mage binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: build_mage_bin
path: ./build/build-mage-static
@ -43,14 +43,14 @@ jobs:
steps:
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -58,7 +58,7 @@ jobs:
- name: Docker meta version
if: ${{ github.ref_type == 'tag' }}
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: |
vikunja/vikunja
@ -70,7 +70,7 @@ jobs:
type=raw,value=latest
- name: Build and push unstable
if: ${{ github.ref_type != 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true
@ -81,7 +81,7 @@ jobs:
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
- name: Build and push version
if: ${{ github.ref_type == 'tag' }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
push: true
@ -93,10 +93,10 @@ jobs:
binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries
with:
project: vikunja
@ -112,10 +112,10 @@ jobs:
veans-binaries:
runs-on: blacksmith-8vcpu-ubuntu-2204
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-binaries
with:
project: veans
@ -147,10 +147,10 @@ jobs:
pkg: armv7
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package
with:
project: vikunja
@ -186,10 +186,10 @@ jobs:
pkg: armv7
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- uses: ./.github/actions/release-os-package
with:
project: veans
@ -235,19 +235,19 @@ jobs:
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
RELEASE_VERSION: unstable
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download build mage binary
# Statically compiled in test.yml's build-mage job so it runs inside
# ubuntu/fedora/archlinux containers without a Go toolchain.
if: matrix.format != 'apk'
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: build_mage_bin
path: build
- name: Download all server OS packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: vikunja_os_package_*
merge-multiple: true
@ -257,14 +257,14 @@ jobs:
# Merged into the same incoming dir so reprepro / createrepo_c /
# repo-add / the apk loop pick them up alongside vikunja's packages
# — same suite, same arch fan-out, no extra source entry for users.
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
path: dist/repo-work/incoming
- name: Download desktop packages (Linux)
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_ubuntu-latest
path: dist/repo-work/incoming-desktop
@ -309,7 +309,7 @@ jobs:
- name: GPG setup
if: matrix.format != 'apk'
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
uses: kolaente/action-gpg@main
with:
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
@ -384,7 +384,7 @@ jobs:
find dist/repo-output -type d -empty -delete 2>/dev/null || true
- name: Upload to R2
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -398,12 +398,12 @@ jobs:
config-yaml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: generate
@ -411,7 +411,7 @@ jobs:
chmod +x ./mage-static
./mage-static generate:config-yaml 1
- name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -431,16 +431,16 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
with:
package_json_file: desktop/package.json
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version-file: frontend/.nvmrc
cache: pnpm
@ -451,7 +451,7 @@ jobs:
sudo apt-get update
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
- name: get frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: frontend/dist
@ -461,7 +461,7 @@ jobs:
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
- name: Upload to S3
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
uses: kolaente/s3-action@main
with:
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
@ -473,7 +473,7 @@ jobs:
strip-path-prefix: desktop/dist/
exclude: "desktop/dist/*.blockmap"
- name: Store Desktop Package
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: vikunja_desktop_packages_${{ matrix.os }}
path: |
@ -486,16 +486,16 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
persist-credentials: true
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: generate
@ -520,7 +520,7 @@ jobs:
git commit -am "[skip ci] Updated swagger docs"
- name: Push changes
if: steps.check_changes.outputs.changes_exist != '0'
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
uses: ad-m/github-push-action@master
with:
ssh: true
branch: ${{ github.ref }}
@ -539,44 +539,44 @@ jobs:
contents: write
steps:
- name: Download Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin_packages
- name: Download OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: vikunja_os_package_*
merge-multiple: true
- name: Download Veans Binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: veans_bin_packages
- name: Download Veans OS Packages
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: veans_os_package_*
merge-multiple: true
- name: Download Desktop Package Linux
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_ubuntu-latest
- name: Download Desktop Package MacOS
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_macos-latest
- name: Download Desktop Package Windows
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_desktop_packages_windows-latest
- name: Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
if: github.ref_type == 'tag'
with:
draft: true

View File

@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
- uses: actions/stale@v9
with:
only-labels: 'waiting for reply'
days-before-issue-stale: 30
@ -24,7 +24,6 @@ jobs:
questions. If you're still seeing this on a recent version, just
drop a comment with the requested info and we'll reopen. Thanks
for the report!
stale-pr-label: 'waiting for reply'
days-before-pr-stale: 30
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 100

View File

@ -8,26 +8,26 @@ jobs:
runs-on: ubuntu-latest
name: prepare-mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Cache Mage
id: cache-mage
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
with:
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
path: |
./mage-static
- name: Compile Mage
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
with:
version: latest
args: -compile ./mage-static
- name: Store Mage Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: mage_bin
path: ./mage-static
@ -36,16 +36,16 @@ jobs:
runs-on: ubuntu-latest
needs: mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Build
@ -57,7 +57,7 @@ jobs:
chmod +x ./mage-static
./mage-static build
- name: Store Vikunja Binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: vikunja_bin
path: ./vikunja
@ -65,8 +65,8 @@ jobs:
api-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: prepare frontend files
@ -74,19 +74,19 @@ jobs:
mkdir -p frontend/dist
touch frontend/dist/index.html
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.10.1
veans-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: golangci-lint
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.10.1
working-directory: veans
@ -94,8 +94,8 @@ jobs:
veans-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
@ -115,9 +115,9 @@ jobs:
runs-on: ubuntu-latest
needs: mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Check
@ -152,7 +152,7 @@ jobs:
ports:
- 3306:3306
migration-smoke-db-postgres:
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
env:
POSTGRES_PASSWORD: vikunjatest
POSTGRES_DB: vikunjatest
@ -164,7 +164,7 @@ jobs:
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- name: run migration
@ -254,13 +254,13 @@ jobs:
ports:
- 389:389
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Configure Postgres for faster tests
@ -300,13 +300,13 @@ jobs:
needs:
- mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
@ -321,13 +321,13 @@ jobs:
needs:
- mage
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test
@ -351,13 +351,13 @@ jobs:
ports:
- 9000:9000
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Mage Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: mage_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: test S3 file storage integration
@ -382,7 +382,7 @@ jobs:
frontend-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend
- name: Lint
working-directory: frontend
@ -391,7 +391,7 @@ jobs:
frontend-stylelint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend
- name: Lint styles
working-directory: frontend
@ -400,7 +400,7 @@ jobs:
frontend-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend
- name: Typecheck
continue-on-error: true
@ -410,7 +410,7 @@ jobs:
test-frontend-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend
- name: Run unit tests
working-directory: frontend
@ -419,11 +419,11 @@ jobs:
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: ./.github/actions/setup-frontend
- name: Git describe
id: ghd
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
uses: proudust/gh-describe@v2
- name: Inject frontend version
working-directory: frontend
run: |
@ -432,7 +432,7 @@ jobs:
working-directory: frontend
run: pnpm build
- name: Store Frontend
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: frontend_dist
path: ./frontend/dist
@ -442,13 +442,13 @@ jobs:
needs:
- api-build
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
with:
go-version: stable
- name: Install mage
@ -501,7 +501,7 @@ jobs:
(cd veans && mage test:e2e)
- name: Upload API log on failure
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: veans-e2e-vikunja-log
path: /tmp/vikunja.log
@ -523,19 +523,19 @@ jobs:
ports:
- 5556:5556
container:
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
options: --user 1001
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Download Vikunja Binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: vikunja_bin
- uses: ./.github/actions/setup-frontend
with:
install-e2e-binaries: false # Playwright browsers already in container
- name: Download Frontend
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: frontend_dist
path: ./frontend/dist
@ -570,14 +570,14 @@ jobs:
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
- name: Upload Playwright Report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always()
with:
name: playwright-report-${{ matrix.shard }}
path: frontend/playwright-report/
retention-days: 30
- name: Upload Test Results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always()
with:
name: playwright-test-results-${{ matrix.shard }}

View File

@ -145,13 +145,6 @@ linters:
- revive
path: pkg/utils/*
text: 'var-naming: avoid meaningless package names'
- linters:
- revive
path: pkg/routes/api/shared/*
text: 'var-naming: avoid meaningless package names'
- linters:
- contextcheck
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
- linters:
- revive
text: 'var-naming: avoid package names that conflict with Go standard library package names'

1
CRUSH.md Symbolic link
View File

@ -0,0 +1 @@
AGENTS.md

View File

@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
WORKDIR /build
@ -14,7 +14,7 @@ COPY frontend/ ./
ARG RELEASE_VERSION=dev
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
RUN go install github.com/magefile/mage@latest && \
mv /go/bin/mage /usr/local/go/bin

View File

@ -1,5 +1,5 @@
module code.vikunja.io/build
go 1.26.4
go 1.25.0
require github.com/magefile/mage v1.17.2

View File

@ -849,11 +849,6 @@
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
},
{
"key": "groupsyncuseserviceaccount",
"default_value": "false",
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
},
{
"key": "avatarsyncattribute",
"default_value": "",
@ -1030,6 +1025,10 @@
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
}
]
},
{
"key": "forwarders",
"comment": "A list of sinks to forward each audit entry to, in addition to the local logfile. Each entry needs a `type` of `stdout`, `syslog` or `webhook`. `syslog` requires `address` (e.g. `udp://logs.example.com:514`) and accepts an optional `facility` (default `local0`). `webhook` requires `url` and accepts an optional `headers` map sent with each request.\nExample:\n\n```yaml\nforwarders:\n- type: stdout\n- type: syslog\n address: udp://logs.example.com:514\n facility: local0\n- type: webhook\n url: https://siem.example.com/ingest\n headers:\n Authorization: Bearer something\n```"
}
]
},

View File

@ -100,15 +100,10 @@ app.on('second-instance', (_event, argv) => {
return
}
// Reveal the main window. It may be hidden in the tray (not just minimized),
// so show() is required — focus() alone won't surface a hidden window, which
// made the app look dead when relaunched while running in the tray.
// Focus the main window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
} else if (serverPort) {
createMainWindow()
}
// Find the deep link URL in argv
@ -241,11 +236,6 @@ function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1680,
height: 960,
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
// an installed .desktop file. icon.png lives at the app root because
// build/ is electron-builder's buildResources dir and isn't packaged.
icon: path.join(__dirname, 'icon.png'),
webPreferences: {
...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'),
@ -553,14 +543,3 @@ app.on('window-all-closed', () => {
app.quit()
}
})
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
// handler the app ignores SIGTERM because the tray and express server keep the
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
// the hide-to-tray close handler doesn't swallow the quit.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
isQuitting = true
app.quit()
})
}

View File

@ -5,7 +5,7 @@
"main": "main.js",
"repository": "https://code.vikunja.io/desktop",
"license": "GPL-3.0-or-later",
"packageManager": "pnpm@10.34.4",
"packageManager": "pnpm@10.28.1",
"author": {
"email": "maintainers@vikunja.io",
"name": "Vikunja Team"
@ -61,9 +61,9 @@
}
},
"devDependencies": {
"electron": "40.10.5",
"electron-builder": "26.15.3",
"unzipper": "0.12.5"
"electron": "40.10.3",
"electron-builder": "26.15.2",
"unzipper": "0.12.3"
},
"dependencies": {
"express": "5.2.1"
@ -73,16 +73,12 @@
"electron"
],
"overrides": {
"minimatch": "10.2.5",
"tar": "7.5.17",
"@tootallnate/once": "3.0.1",
"picomatch": "4.0.4",
"tmp": "0.2.7",
"ip-address": "10.2.0",
"form-data": "4.0.6",
"js-yaml": "5.2.0",
"undici@6": "6.27.0",
"undici@7": "7.28.0"
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"@tootallnate/once": "^3.0.1",
"picomatch": ">=4.0.4",
"tmp": ">=0.2.6",
"ip-address": ">=10.1.1"
}
}
}

View File

@ -5,16 +5,12 @@ settings:
excludeLinksFromLockfile: false
overrides:
minimatch: 10.2.5
tar: 7.5.17
'@tootallnate/once': 3.0.1
picomatch: 4.0.4
tmp: 0.2.7
ip-address: 10.2.0
form-data: 4.0.6
js-yaml: 5.2.0
undici@6: 6.27.0
undici@7: 7.28.0
minimatch: ^10.2.3
tar: ^7.5.11
'@tootallnate/once': ^3.0.1
picomatch: '>=4.0.4'
tmp: '>=0.2.6'
ip-address: '>=10.1.1'
importers:
@ -25,14 +21,14 @@ importers:
version: 5.2.1
devDependencies:
electron:
specifier: 40.10.5
version: 40.10.5
specifier: 40.10.3
version: 40.10.3
electron-builder:
specifier: 26.15.3
version: 26.15.3(electron-builder-squirrel-windows@24.13.3)
specifier: 26.15.2
version: 26.15.2(electron-builder-squirrel-windows@24.13.3)
unzipper:
specifier: 0.12.5
version: 0.12.5
specifier: 0.12.3
version: 0.12.3
packages:
@ -238,12 +234,12 @@ packages:
dmg-builder: 24.13.3
electron-builder-squirrel-windows: 24.13.3
app-builder-lib@26.15.3:
resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
app-builder-lib@26.15.2:
resolution: {integrity: sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
dmg-builder: 26.15.3
electron-builder-squirrel-windows: 26.15.3
dmg-builder: 26.15.2
electron-builder-squirrel-windows: 26.15.2
archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
@ -333,8 +329,8 @@ packages:
builder-util@24.13.1:
resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==}
builder-util@26.15.3:
resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
builder-util@26.15.0:
resolution: {integrity: sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA==}
engines: {node: '>=14.0.0'}
bytes@3.1.2:
@ -487,8 +483,8 @@ packages:
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
dmg-builder@26.15.3:
resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
dmg-builder@26.15.2:
resolution: {integrity: sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==}
dotenv-expand@11.0.6:
resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==}
@ -526,19 +522,19 @@ packages:
electron-builder-squirrel-windows@24.13.3:
resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==}
electron-builder@26.15.3:
resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
electron-builder@26.15.2:
resolution: {integrity: sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==}
engines: {node: '>=14.0.0'}
hasBin: true
electron-publish@24.13.1:
resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==}
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron-publish@26.15.1:
resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==}
electron@40.10.5:
resolution: {integrity: sha512-VzTIvwOYXZZufT9B83GDQogR1TFqREygRYhm0LE++QhGPjvBeg+W7siOP9K5+9rHMUnRuCX4YU/0ivLekN/UZQ==}
electron@40.10.3:
resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==}
engines: {node: '>= 22.12.0'}
hasBin: true
@ -620,7 +616,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: 4.0.4
picomatch: '>=4.0.4'
peerDependenciesMeta:
picomatch:
optional: true
@ -636,8 +632,8 @@ packages:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
forwarded@0.2.0:
@ -655,6 +651,10 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fs-extra@11.2.0:
resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==}
engines: {node: '>=14.14'}
fs-extra@11.3.1:
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
engines: {node: '>=14.14'}
@ -736,10 +736,6 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hosted-git-info@4.1.0:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
@ -834,8 +830,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-yaml@5.2.0:
resolution: {integrity: sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
json-buffer@3.0.1:
@ -858,6 +854,9 @@ packages:
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@ -1299,8 +1298,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.17:
resolution: {integrity: sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==}
tar@7.5.15:
resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
engines: {node: '>=18'}
temp-file@3.4.0:
@ -1316,8 +1315,8 @@ packages:
tmp-promise@3.0.3:
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
tmp@0.2.7:
resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==}
tmp@0.2.6:
resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==}
engines: {node: '>=14.14'}
toidentifier@1.0.1:
@ -1346,12 +1345,12 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.27.0:
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
undici@6.26.0:
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
engines: {node: '>=18.17'}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
engines: {node: '>=20.18.1'}
universalify@0.1.2:
@ -1366,8 +1365,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
unzipper@0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
unzipper@0.12.3:
resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1488,7 +1487,7 @@ snapshots:
semver: 7.8.1
sumchecker: 3.0.1
optionalDependencies:
undici: 7.28.0
undici: 7.27.2
transitivePeerDependencies:
- supports-color
@ -1712,7 +1711,7 @@ snapshots:
app-builder-bin@4.0.0: {}
app-builder-lib@24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3):
app-builder-lib@24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3):
dependencies:
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.2.1
@ -1726,27 +1725,27 @@ snapshots:
builder-util-runtime: 9.2.4
chromium-pickle-js: 0.2.0
debug: 4.4.3
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
ejs: 3.1.10
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3)
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2)
electron-publish: 24.13.1
form-data: 4.0.6
form-data: 4.0.5
fs-extra: 10.1.0
hosted-git-info: 4.1.0
is-ci: 3.0.1
isbinaryfile: 5.0.7
js-yaml: 5.2.0
js-yaml: 4.1.1
lazy-val: 1.0.5
minimatch: 10.2.5
read-config-file: 6.3.2
sanitize-filename: 1.6.4
semver: 7.8.1
tar: 7.5.17
tar: 7.5.15
temp-file: 3.4.0
transitivePeerDependencies:
- supports-color
app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3):
app-builder-lib@26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3):
dependencies:
'@electron/asar': 3.4.1
'@electron/fuses': 1.8.0
@ -1762,22 +1761,22 @@ snapshots:
ajv: 8.20.0
asn1js: 3.0.10
async-exit-hook: 2.0.1
builder-util: 26.15.3
builder-util: 26.15.0
builder-util-runtime: 9.7.0
chromium-pickle-js: 0.2.0
ci-info: 4.3.1
debug: 4.4.3
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
dotenv: 16.4.5
dotenv-expand: 11.0.6
ejs: 3.1.10
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3)
electron-publish: 26.15.3
electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2)
electron-publish: 26.15.1
fs-extra: 10.1.0
hosted-git-info: 4.1.0
isbinaryfile: 5.0.7
jiti: 2.6.1
js-yaml: 5.2.0
js-yaml: 4.1.1
json5: 2.2.3
lazy-val: 1.0.5
minimatch: 10.2.5
@ -1786,10 +1785,10 @@ snapshots:
proper-lockfile: 4.1.2
resedit: 1.7.2
semver: 7.7.4
tar: 7.5.17
tar: 7.5.15
temp-file: 3.4.0
tiny-async-pool: 1.3.0
unzipper: 0.12.5
unzipper: 0.12.3
which: 5.0.0
transitivePeerDependencies:
- supports-color
@ -1924,14 +1923,14 @@ snapshots:
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
is-ci: 3.0.1
js-yaml: 5.2.0
js-yaml: 4.1.1
source-map-support: 0.5.21
stat-mode: 1.0.0
temp-file: 3.4.0
transitivePeerDependencies:
- supports-color
builder-util@26.15.3:
builder-util@26.15.0:
dependencies:
'@types/debug': 4.1.13
builder-util-runtime: 9.7.0
@ -1941,7 +1940,7 @@ snapshots:
fs-extra: 10.1.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.5
js-yaml: 5.2.0
js-yaml: 4.1.1
sanitize-filename: 1.6.4
source-map-support: 0.5.21
stat-mode: 1.0.0
@ -2089,12 +2088,12 @@ snapshots:
minimatch: 10.2.5
p-limit: 3.1.0
dmg-builder@26.15.3(electron-builder-squirrel-windows@24.13.3):
dmg-builder@26.15.2(electron-builder-squirrel-windows@24.13.3):
dependencies:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.3
app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.0
fs-extra: 10.1.0
js-yaml: 5.2.0
js-yaml: 4.1.1
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
@ -2127,9 +2126,9 @@ snapshots:
dependencies:
jake: 10.8.7
electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.3):
electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.2):
dependencies:
app-builder-lib: 24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
app-builder-lib: 24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
archiver: 5.3.2
builder-util: 24.13.1
fs-extra: 10.1.0
@ -2137,14 +2136,14 @@ snapshots:
- dmg-builder
- supports-color
electron-builder@26.15.3(electron-builder-squirrel-windows@24.13.3):
electron-builder@26.15.2(electron-builder-squirrel-windows@24.13.3):
dependencies:
app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.3
app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3)
builder-util: 26.15.0
builder-util-runtime: 9.7.0
chalk: 4.1.2
ci-info: 4.3.1
dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3)
dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3)
fs-extra: 10.1.0
lazy-val: 1.0.5
simple-update-notifier: 2.0.0
@ -2165,21 +2164,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-publish@26.15.3:
electron-publish@26.15.1:
dependencies:
'@types/fs-extra': 9.0.13
aws4: 1.13.2
builder-util: 26.15.3
builder-util: 26.15.0
builder-util-runtime: 9.7.0
chalk: 4.1.2
form-data: 4.0.6
form-data: 4.0.5
fs-extra: 10.1.0
lazy-val: 1.0.5
mime: 2.6.0
transitivePeerDependencies:
- supports-color
electron@40.10.5:
electron@40.10.3:
dependencies:
'@electron-internal/extract-zip': 1.0.2
'@electron/get': 5.0.0
@ -2216,7 +2215,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
hasown: 2.0.2
es6-error@4.1.1:
optional: true
@ -2295,12 +2294,12 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.6:
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
hasown: 2.0.2
mime-types: 2.1.35
forwarded@0.2.0: {}
@ -2315,6 +2314,12 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@11.2.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
fs-extra@11.3.1:
dependencies:
graceful-fs: 4.2.11
@ -2431,10 +2436,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
@ -2533,7 +2534,7 @@ snapshots:
jiti@2.6.1: {}
js-yaml@5.2.0:
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@ -2552,6 +2553,12 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
@ -2649,9 +2656,9 @@ snapshots:
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.1
tar: 7.5.17
tar: 7.5.15
tinyglobby: 0.2.15
undici: 6.27.0
undici: 6.26.0
which: 6.0.1
node-int64@0.4.0: {}
@ -2784,7 +2791,7 @@ snapshots:
config-file-ts: 0.2.6
dotenv: 9.0.2
dotenv-expand: 5.1.0
js-yaml: 5.2.0
js-yaml: 4.1.1
json5: 2.2.3
lazy-val: 1.0.5
@ -3001,7 +3008,7 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.5.17:
tar@7.5.15:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
@ -3025,9 +3032,9 @@ snapshots:
tmp-promise@3.0.3:
dependencies:
tmp: 0.2.7
tmp: 0.2.6
tmp@0.2.7: {}
tmp@0.2.6: {}
toidentifier@1.0.1: {}
@ -3050,9 +3057,9 @@ snapshots:
undici-types@7.16.0: {}
undici@6.27.0: {}
undici@6.26.0: {}
undici@7.28.0:
undici@7.27.2:
optional: true
universalify@0.1.2: {}
@ -3061,11 +3068,11 @@ snapshots:
unpipe@1.0.0: {}
unzipper@0.12.5:
unzipper@0.12.3:
dependencies:
bluebird: 3.7.2
duplexer2: 0.1.4
fs-extra: 11.3.1
fs-extra: 11.2.0
graceful-fs: 4.2.11
node-int64: 0.4.0

View File

@ -3,11 +3,10 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1782492839,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"lastModified": 1773012232,
"owner": "cachix",
"repo": "devenv",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"type": "github"
},
"original": {
@ -22,11 +21,10 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1782132010,
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
"lastModified": 1772749504,
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"type": "github"
},
"original": {
@ -39,11 +37,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"type": "github"
},
"original": {
@ -55,11 +53,10 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1782467914,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"lastModified": 1772773019,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"type": "github"
},
"original": {

View File

@ -1 +1 @@
24.18.0
24.13.0

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@10.34.4",
"packageManager": "pnpm@10.28.1",
"engines": {
"node": ">=24.0.0"
},
@ -51,95 +51,95 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@floating-ui/dom": "1.7.6",
"@fortawesome/fontawesome-svg-core": "7.3.0",
"@fortawesome/free-regular-svg-icons": "7.3.0",
"@fortawesome/free-solid-svg-icons": "7.3.0",
"@fortawesome/vue-fontawesome": "3.3.0",
"@intlify/unplugin-vue-i18n": "11.2.4",
"@floating-ui/dom": "1.7.4",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.3",
"@intlify/unplugin-vue-i18n": "11.0.3",
"@kyvg/vue3-notification": "3.4.2",
"@sentry/vue": "10.62.0",
"@tiptap/core": "3.27.1",
"@tiptap/extension-blockquote": "3.27.1",
"@tiptap/extension-code-block-lowlight": "3.27.1",
"@tiptap/extension-hard-break": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-link": "3.27.1",
"@tiptap/extension-list": "3.27.1",
"@tiptap/extension-mention": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/extension-typography": "3.27.1",
"@tiptap/extension-underline": "3.27.1",
"@tiptap/extensions": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"@tiptap/vue-3": "3.27.1",
"@vueuse/core": "14.3.0",
"@vueuse/router": "14.3.0",
"axios": "1.18.1",
"@sentry/vue": "10.36.0",
"@tiptap/core": "3.17.0",
"@tiptap/extension-blockquote": "3.17.0",
"@tiptap/extension-code-block-lowlight": "3.17.0",
"@tiptap/extension-hard-break": "3.17.0",
"@tiptap/extension-image": "3.17.0",
"@tiptap/extension-link": "3.17.0",
"@tiptap/extension-list": "3.17.0",
"@tiptap/extension-mention": "3.17.0",
"@tiptap/extension-table": "3.17.0",
"@tiptap/extension-typography": "3.17.0",
"@tiptap/extension-underline": "3.17.0",
"@tiptap/extensions": "3.17.0",
"@tiptap/pm": "3.17.0",
"@tiptap/starter-kit": "3.17.0",
"@tiptap/suggestion": "3.17.0",
"@tiptap/vue-3": "3.17.0",
"@vueuse/core": "14.1.0",
"@vueuse/router": "14.1.0",
"axios": "1.16.0",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"change-case": "5.4.4",
"dayjs": "1.11.21",
"dompurify": "3.4.11",
"dayjs": "1.11.19",
"dompurify": "3.4.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"floating-vue": "5.2.2",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lowlight": "3.3.0",
"marked": "17.0.6",
"nanoid": "5.1.16",
"marked": "17.0.1",
"nanoid": "5.1.6",
"pinia": "3.0.4",
"register-service-worker": "1.7.2",
"sortablejs": "1.15.7",
"ufo": "1.6.4",
"vue": "3.5.39",
"sortablejs": "1.15.6",
"ufo": "1.6.3",
"vue": "3.5.27",
"vue-advanced-cropper": "2.8.9",
"vue-flatpickr-component": "11.0.5",
"vue-i18n": "11.4.6",
"vue-i18n": "11.2.8",
"vue-router": "4.6.4",
"vuemoji-picker": "0.3.2",
"workbox-precaching": "7.4.1",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@faker-js/faker": "10.5.0",
"@faker-js/faker": "10.4.0",
"@histoire/plugin-screenshot": "1.0.0-beta.1",
"@histoire/plugin-vue": "1.0.0-beta.1",
"@playwright/test": "1.61.1",
"@playwright/test": "1.58.2",
"@sentry/vite-plugin": "3.6.1",
"@tailwindcss/vite": "4.3.1",
"@tailwindcss/vite": "4.3.0",
"@tsconfig/node24": "24.0.4",
"@types/codemirror": "5.60.17",
"@types/is-touch-device": "1.0.3",
"@types/node": "24.13.2",
"@types/node": "24.13.1",
"@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.0",
"@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.9.0",
"@vue/eslint-config-typescript": "14.8.0",
"@vue/test-utils": "2.4.11",
"@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.3.0",
"autoprefixer": "10.5.2",
"browserslist": "4.28.4",
"caniuse-lite": "1.0.30001799",
"autoprefixer": "10.5.0",
"browserslist": "4.28.2",
"caniuse-lite": "1.0.30001797",
"csstype": "3.2.3",
"esbuild": "0.28.1",
"esbuild": "0.28.0",
"eslint": "9.39.4",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.6",
"happy-dom": "20.10.2",
"histoire": "1.0.0-beta.1",
"otplib": "12.0.1",
"postcss": "8.5.15",
"postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1",
"postcss-preset-env": "11.3.1",
"rollup": "4.62.2",
"postcss-preset-env": "11.3.0",
"rollup": "4.61.1",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0",
"stylelint": "17.13.0",
@ -147,15 +147,15 @@
"stylelint-config-recommended-vue": "1.6.1",
"stylelint-config-standard-scss": "17.0.0",
"stylelint-use-logical": "2.1.3",
"tailwindcss": "4.3.1",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"unplugin-inject-preload": "3.0.0",
"vite": "7.3.6",
"vite": "7.3.5",
"vite-plugin-pwa": "1.3.0",
"vite-plugin-vue-devtools": "8.1.4",
"vite-plugin-vue-devtools": "8.1.2",
"vite-svg-loader": "5.1.1",
"vitest": "4.1.9",
"vue-tsc": "3.3.5",
"vitest": "4.1.8",
"vue-tsc": "3.3.4",
"wait-on": "9.0.10",
"workbox-cli": "7.4.1",
"ws": "8.21.0"
@ -169,20 +169,14 @@
"vue-demi"
],
"overrides": {
"minimatch": "10.2.5",
"minimatch": "^10.2.3",
"rollup": "$rollup",
"basic-ftp": "6.0.1",
"serialize-javascript": "7.0.6",
"flatted": "3.4.2",
"ip-address": "10.2.0",
"postcss": "8.5.15",
"tmp": "0.2.7",
"esbuild": "0.28.1",
"form-data": "4.0.6",
"markdown-it": "14.2.0",
"launch-editor": "2.14.1",
"@babel/core": "8.0.1",
"js-yaml@4": "5.2.0"
"basic-ftp": ">=5.2.2",
"serialize-javascript": "^7.0.5",
"flatted": "^3.4.1",
"ip-address": ">=10.1.1",
"postcss": ">=8.5.10",
"tmp": ">=0.2.6"
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -61,7 +61,6 @@ import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
@ -108,7 +107,6 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
useColorScheme()
useTimeTrackingFavicon()
</script>
<style src="@/styles/tailwind.css" />

View File

@ -13,14 +13,14 @@
<div class="gantt-chart-wrapper">
<GanttTimelineHeader
:timeline-data="timelineData"
:day-width-pixels="dayWidthPixels"
:day-width-pixels="DAY_WIDTH_PIXELS"
/>
<GanttVerticalGridLines
:timeline-data="timelineData"
:total-width="totalWidth"
:height="ganttRows.length * 40"
:day-width-pixels="dayWidthPixels"
:day-width-pixels="DAY_WIDTH_PIXELS"
/>
<GanttChartBody
@ -57,7 +57,7 @@
:total-width="totalWidth"
:date-from-date="dateFromDate"
:date-to-date="dateToDate"
:day-width-pixels="dayWidthPixels"
:day-width-pixels="DAY_WIDTH_PIXELS"
:is-dragging="isDragging"
:is-resizing="isResizing"
:drag-state="dragState"
@ -89,7 +89,7 @@
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
import {useRouter} from 'vue-router'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
@ -126,9 +126,7 @@ const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAY_WIDTH_PIXELS_MIN = 30
const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver
const DAY_WIDTH_PIXELS = 30
const {tasks, filters} = toRefs(props)
@ -160,7 +158,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
return dateDiff * dayWidthPixels.value
return dateDiff * DAY_WIDTH_PIXELS
})
const timelineData = computed(() => {
@ -299,55 +297,6 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
}
}
function updateDayWidthPixels() {
const node = ganttContainer.value
if (!node) return
const rect = node.getBoundingClientRect()
const styles = window.getComputedStyle(node)
const marginLeft = parseFloat(styles.marginLeft) || 0
const marginRight = parseFloat(styles.marginRight) || 0
// max width without overflow
const maxWidth = rect.width - marginLeft - marginRight
const dayCount = Math.ceil(
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
)
dayWidthPixels.value = Math.max(
maxWidth / dayCount,
DAY_WIDTH_PIXELS_MIN,
)
}
onMounted(async () => {
await nextTick()
updateDayWidthPixels()
if (ganttContainer.value) {
resizeObserver = new ResizeObserver(updateDayWidthPixels)
resizeObserver.observe(ganttContainer.value)
}
window.addEventListener('resize', updateDayWidthPixels)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updateDayWidthPixels)
})
watch(
[dateFromDate, dateToDate],
async () => {
await nextTick()
updateDayWidthPixels()
},
{flush: 'post'},
)
// Build the task tree when tasks change
watch(
[tasks, filters],
@ -402,7 +351,7 @@ const ROW_HEIGHT = 40
const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) {
@ -437,7 +386,7 @@ function computeBarX(date: Date): number {
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY,
)
return diff * dayWidthPixels.value
return diff * DAY_WIDTH_PIXELS
}
function computeBarWidth(bar: GanttBarModel): number {
@ -445,7 +394,7 @@ function computeBarWidth(bar: GanttBarModel): number {
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY,
)
return diff * dayWidthPixels.value
return diff * DAY_WIDTH_PIXELS
}
// Compute relation arrows
@ -641,7 +590,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / dayWidthPixels.value)
const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days
@ -703,7 +652,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / dayWidthPixels.value)
const days = Math.round(diff / DAY_WIDTH_PIXELS)
if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart)
@ -781,7 +730,7 @@ function focusTaskBar(rowId: string) {
setTimeout(() => {
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
if (taskBarElement) {
taskBarElement.focus({preventScroll: true})
taskBarElement.focus()
}
}, 0)
}

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return
}
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
const url = await inputPrompt(event.target.getBoundingClientRect())
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()

View File

@ -5,7 +5,6 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -79,7 +78,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
getPopupContainer(props.editor).appendChild(popupElement)
document.body.appendChild(popupElement)
const rect = props.clientRect()
if (!rect) {
@ -109,7 +108,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null
}
if (popupElement) {
popupElement.remove()
document.body.removeChild(popupElement)
popupElement = null
}
component?.destroy()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
const url = await inputPrompt(pos, previousUrl)
// empty
if (url === '') {

View File

@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
wide?: boolean,
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), {
enabled: true,
overflow: false,
@ -211,13 +211,7 @@ $modal-width: 1024px;
// Reset UA dialog styles
padding: 0;
border: none;
// The scrim lives on the dialog element, not on ::backdrop: Chromium
// intermittently stops painting a styled ::backdrop (e.g. after the
// dialog's subtree re-renders, or while display is transitioned) even
// though getComputedStyle still reports the color. The dialog fills the
// viewport anyway, and its opacity transition fades the scrim with it
// same as the old div-based .modal-mask.
background: rgba(0, 0, 0, .8);
background: transparent;
color: #ffffff;
// Fill viewport
position: fixed;
@ -227,12 +221,10 @@ $modal-width: 1024px;
max-inline-size: 100%;
max-block-size: 100%;
// Transitions. No display/allow-discrete transition needed: the close
// fade runs while the dialog is still [open] (data-closing + timer in
// closeDialog), and transitioning display triggers the Chromium paint
// bug above.
// Transitions
opacity: 0;
transition: opacity 150ms ease;
transition: opacity 150ms ease,
display 150ms ease allow-discrete;
&[open]:not([data-closing]) {
opacity: 1;
@ -244,11 +236,16 @@ $modal-width: 1024px;
&::backdrop {
background-color: rgba(0, 0, 0, 0);
transition: background-color 150ms ease,
display 150ms ease allow-discrete;
}
// in quick-add mode the Electron window itself is the overlay no scrim
&:has(.is-quick-add-mode) {
background: transparent;
&[open]:not([data-closing])::backdrop {
background-color: rgba(0, 0, 0, .8);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
}
@ -264,20 +261,13 @@ $modal-width: 1024px;
}
.default .modal-content,
.hint-modal .modal-content,
.top .modal-content {
.hint-modal .modal-content {
text-align: center;
position: absolute;
// fine to use top/left since we're only using this to position it centered
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
// Cap centered content to the viewport and scroll inside it. Without this a
// taller-than-viewport modal centres its top edge above the viewport, where
// the container's overflow can't scroll to it (the .top variant overrides
// both values below).
max-block-size: calc(100dvh - 2rem);
overflow: auto;
[dir="rtl"] & {
transform: translate(50%, -50%);
@ -287,9 +277,6 @@ $modal-width: 1024px;
margin: 0;
position: static;
transform: none;
// the fullscreen mobile layout flows and scrolls in .modal-container
max-block-size: none;
overflow: visible;
}
.modal-header {
@ -302,31 +289,11 @@ $modal-width: 1024px;
}
}
// anchored below the top edge instead of centered, used for QuickActions
.top .modal-content {
inset-block-start: 3rem;
transform: translate(-50%, 0);
max-block-size: calc(100dvh - 6rem);
overflow: auto;
[dir="rtl"] & {
transform: translate(50%, 0);
}
// the fullscreen mobile layout flows and scrolls in .modal-container
@media screen and (max-width: $tablet) {
transform: none;
max-block-size: none;
overflow: visible;
}
}
// Default width for centered modals. Scoped with :not(.is-wide) so the
// `wide` prop can still expand the modal (the .is-wide rule below would
// otherwise be outranked by .default .modal-content's specificity).
.default .modal-content:not(.is-wide),
.hint-modal .modal-content:not(.is-wide),
.top .modal-content:not(.is-wide) {
.hint-modal .modal-content:not(.is-wide) {
inline-size: calc(100% - 2rem);
max-inline-size: 640px;
@ -436,7 +403,6 @@ $modal-width: 1024px;
block-size: auto;
max-inline-size: none;
max-block-size: none;
background: transparent;
&::backdrop {
display: none;

View File

@ -2,7 +2,6 @@
<Modal
:enabled="active"
:overflow="isNewTaskCommand"
variant="top"
@close="closeQuickActions"
>
<div
@ -705,16 +704,15 @@ function reset() {
<style lang="scss" scoped>
.quick-actions {
// global Bulma .card styles are gone (ported into Card.vue, scoped),
// so this bare .card div needs its own card visuals
background-color: var(--white);
border-radius: $radius;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
color: var(--text);
overflow: hidden;
justify-content: flex-start !important;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
inset-block-start: 3rem;
transform: translate(-50%, 0);
}
&.is-quick-add-mode {
padding: 0;
margin: 0;

View File

@ -25,7 +25,6 @@
rows="1"
@keydown="resetEmptyTitleError"
@keydown.enter="handleEnter"
@keydown.esc="blurTaskInput"
/>
<QuickAddMagic
:highlight-hint-icon="taskAddHovered"
@ -283,10 +282,6 @@ function focusTaskInput() {
newTaskInput.value?.focus()
}
function blurTaskInput() {
newTaskInput.value?.blur()
}
defineExpose({
focusTaskInput,
})

View File

@ -123,7 +123,7 @@
</XButton>
<!-- Dropzone -->
<Teleport :to="dropzoneTeleportTarget">
<Teleport to="body">
<div
v-if="editEnabled"
:class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {ref, shallowReactive, computed, watch} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue'
@ -322,34 +322,6 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
)
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => {
if (!enabled) {
resetDragState()
@ -506,7 +478,7 @@ defineExpose({
inset-inline-start: 0;
inset-block-end: 0;
inset-inline-end: 0;
z-index: 4001; // above app chrome when teleported to body (no modal open)
z-index: 4001; // modal z-index is 4000
text-align: center;
&.hidden {

View File

@ -4,7 +4,7 @@
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
id="showRelatedTasksFormButton"
v-tooltip="$t('task.relation.add')"
class="is-pulled-end add-task-relation-button d-print-none"
class="is-pulled-right add-task-relation-button d-print-none"
:class="{'is-active': showNewRelationForm}"
variant="secondary"
icon="plus"

View File

@ -326,17 +326,9 @@ const isOverdue = computed(() => (
let oldTask
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
oldTask = {...task.value}
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
// teardown during the animation delay can neither drop the save nor make it send a stale state.
const updatePromise = taskStore.update({
...task.value,
done: checked,
})
const finish = async () => {
const newTask = await updatePromise
const updateFunc = async () => {
oldTask = {...task.value}
const newTask = await taskStore.update(task.value)
task.value = newTask
updateDueDate()
@ -362,9 +354,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
}
if (checked) {
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
} else {
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
}

View File

@ -1,4 +1,4 @@
import { getCurrentInstance, ref } from 'vue'
import { ref } from 'vue'
import { createGlobalState, useIntervalFn } from '@vueuse/core'
import { onBeforeRouteUpdate } from 'vue-router'
@ -18,14 +18,10 @@ export const useGlobalNow = createGlobalState(() => {
useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true })
// Now that this state can be initialised from a plain helper (formatDateSince), the
// first caller is not guaranteed to be a component — guard the route hook accordingly.
if (getCurrentInstance()) {
// ensure the now value is refreshed when the route changes
onBeforeRouteUpdate(() => {
update()
})
}
// ensure the now value is refreshed when the route changes
onBeforeRouteUpdate(() => {
update()
})
return {
now,

View File

@ -1,34 +0,0 @@
import {describe, it, expect} from 'vitest'
import {buildStoredQuery} from './useTaskList'
describe('buildStoredQuery', () => {
it('includes sort when set', () => {
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'due_date:asc'})
})
it('includes filter and search when set', () => {
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
.toEqual({filter: 'done = false', s: 'foo'})
})
it('omits page when it equals the default of 1', () => {
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'id:desc'})
})
it('includes page when greater than 1', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
.toEqual({page: '3'})
})
it('returns an empty object when nothing is set', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
.toEqual({})
})
it('skips empty strings', () => {
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
.toEqual({})
})
})

View File

@ -1,6 +1,4 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRouter, isNavigationFailure} from 'vue-router'
import type {LocationQueryRaw} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, {
@ -12,7 +10,6 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -62,22 +59,6 @@ const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
interface TaskListQueryState {
sort: string | undefined
filter: string | undefined
s: string | undefined
page: number
}
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
const query: LocationQueryRaw = {}
if (state.sort) query.sort = state.sort
if (state.filter) query.filter = state.filter
if (state.s) query.s = state.s
if (state.page > 1) query.page = String(state.page)
return query
}
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
@ -113,9 +94,6 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number })
@ -141,55 +119,6 @@ export function useTaskList(
},
})
// Mirror the URL query bits this composable owns into the store so
// in-project tab switches and sidebar re-visits can restore them.
//
// `ProjectList`/`ProjectTable` are reused across project switches (no
// `:key` on them in ProjectView.vue), so setup runs only once. We track
// the last viewId we synced — on every viewId transition, if the URL has
// none of our params and the store has an entry, restore it via
// `router.replace` and skip writing back the empty state we'd otherwise
// clobber the saved entry with.
let lastSyncedViewId: number | undefined
watch(
[projectViewId, sortQuery, filter, s, page],
([viewId, sortValue, filterValue, sValue, pageValue]) => {
const viewIdChanged = viewId !== lastSyncedViewId
lastSyncedViewId = viewId
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
// the default so it neither blocks restoration nor wipes stored state.
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
if (viewIdChanged && urlIsEmpty) {
const storedQuery = viewFiltersStore.getViewQuery(viewId)
if (Object.keys(storedQuery).length > 0) {
// Merge so unrelated query params on the route survive the restore.
// Swallow navigation failures (e.g. aborted/duplicated) so the
// ignored promise can't surface as an unhandled rejection.
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
.catch(failure => {
if (!isNavigationFailure(failure)) throw failure
})
return
}
}
const query = buildStoredQuery({
sort: sortValue as string | undefined,
filter: filterValue as string | undefined,
s: sValue as string | undefined,
page: currentPage,
})
if (Object.keys(query).length > 0) {
viewFiltersStore.setViewQuery(viewId, query)
} else {
viewFiltersStore.clearViewQuery(viewId)
}
},
{immediate: true},
)
const allParams = computed(() => {
const loadParams = {...params.value}

View File

@ -1,32 +0,0 @@
import {watch} from 'vue'
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
import {storeToRefs} from 'pinia'
import {useTimeTrackingStore} from '@/stores/timeTracking'
import {getFullBaseUrl} from '@/helpers/getFullBaseUrl'
const TRACKING_FAVICON = `${getFullBaseUrl()}images/icons/favicon-tracking-32x32.png`
function getFaviconLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
}
// Swaps in a favicon with a small red dot in the lower left corner while a timer
// is running, so an active time tracking session is visible even when the tab
// isn't focused.
export const useTimeTrackingFavicon = createSharedComposable(() => {
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
const originalHref = getFaviconLink()?.getAttribute('href') ?? '/favicon.ico'
function update(active: boolean) {
const link = getFaviconLink()
if (link === null) {
return
}
link.href = active ? TRACKING_FAVICON : originalHref
}
watch(hasActiveTimer, update, {flush: 'post'})
tryOnMounted(() => update(hasActiveTimer.value))
})

View File

@ -1,12 +0,0 @@
/**
* Hash-fragment prefix used to carry a post-login destination in the URL.
*
* Unlike the localStorage redirect, this lives in the address bar so the URL
* stays copyable between browsers (needed for native OAuth clients that open
* /oauth/authorize, see #2654). It uses the hash not a query param so the
* embedded OAuth parameters never reach server or proxy access logs.
*
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
* special-cases.
*/
export const REDIRECT_HASH_PREFIX = '#redirect='

View File

@ -1,153 +0,0 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {refreshToken, removeToken} from './auth'
// Count how many times the refresh endpoint is actually POSTed. The whole point
// of the in-flight dedup is that concurrent refreshToken() calls share a single
// underlying POST, independent of the Web Locks API.
let postCallCount = 0
let resolvePost: ((value: unknown) => void) | null = null
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => ({
post: vi.fn(() => {
postCallCount++
return new Promise((resolve) => {
resolvePost = resolve
})
}),
}),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => false,
refreshDesktopToken: vi.fn(),
}))
const FAKE_TOKEN = 'header.payload.signature'
function settlePost() {
resolvePost?.({data: {token: FAKE_TOKEN}})
}
describe('refreshToken in-flight dedup', () => {
const originalLocks = navigator.locks
beforeEach(() => {
postCallCount = 0
resolvePost = null
removeToken()
localStorage.clear()
})
afterEach(() => {
Object.defineProperty(navigator, 'locks', {
value: originalLocks,
configurable: true,
writable: true,
})
})
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
// undefined, so without this the test would silently fall through to
// the insecure-HTTP branch and never exercise navigator.locks.request.
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
Object.defineProperty(navigator, 'locks', {
value: {request: requestSpy},
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
// Both calls share one underlying request.
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2])
// The Web Locks branch actually ran...
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
// ...and the in-flight dedup still collapsed both calls into one POST.
expect(postCallCount).toBe(1)
})
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
// Simulate an insecure HTTP context where navigator.locks is undefined.
Object.defineProperty(navigator, 'locks', {
value: undefined,
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
const p3 = refreshToken(true)
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2, p3])
expect(postCallCount).toBe(1)
})
it('allows a fresh refresh after the previous one settled', async () => {
const p1 = refreshToken(true)
settlePost()
await p1
expect(postCallCount).toBe(1)
// The in-flight promise was reset, so a later refresh runs anew.
const p2 = refreshToken(true)
expect(postCallCount).toBe(2)
settlePost()
await p2
})
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
const p1 = refreshToken(true)
expect(postCallCount).toBe(1)
// User logs out while the refresh POST is still in flight.
removeToken()
// The in-flight POST resolves afterwards — it must not undo the logout.
settlePost()
await p1
expect(localStorage.getItem('token')).toBeNull()
})
it('an older refresh settling does not clobber a newer in-flight one', async () => {
// Refresh A starts and stays in flight.
const pA = refreshToken(true)
expect(postCallCount).toBe(1)
const resolveA = resolvePost
// User logs out, which drops the in-flight reference to A.
removeToken()
// Refresh B starts; it must claim the in-flight slot.
const pB = refreshToken(true)
expect(postCallCount).toBe(2)
const resolveB = resolvePost
// A settles after B started. Its cleanup must NOT null the in-flight
// slot, since that slot now belongs to B. Without the `=== p` guard,
// A's .finally would clobber B and let a concurrent caller fire a
// second parallel POST.
resolveA?.({data: {token: FAKE_TOKEN}})
await pA
// A concurrent caller while B is still in flight must dedup to B —
// no third POST.
const pB2 = refreshToken(true)
expect(postCallCount).toBe(2)
resolveB?.({data: {token: FAKE_TOKEN}})
await Promise.all([pB, pB2])
})
})

View File

@ -33,53 +33,18 @@ export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
localStorage.removeItem('desktopOAuthRefreshToken')
// Bump the epoch and drop the in-flight refresh so a refresh that started
// before this logout can't re-persist a token after we cleared it.
authEpoch++
inFlightRefresh = null
}
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
// without this guard, refreshes firing close together each spend the single-use
// cookie and all but one get a 401.
let inFlightRefresh: Promise<void> | null = null
// Incremented on every removeToken()/logout. A refresh captures the epoch when
// it starts and only persists its result if the epoch is unchanged, so a
// refresh that resolves after a logout can't undo it.
let authEpoch = 0
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* The refresh token is sent automatically as an HttpOnly cookie.
* The server rotates the cookie on every call.
*
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
* Web Locks API inside adds cross-tab coordination only in secure contexts.
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
* performs the actual refresh; other tabs waiting for the lock detect that
* the token in localStorage was already updated and adopt it directly.
*/
export async function refreshToken(persist: boolean): Promise<void> {
if (inFlightRefresh) {
return inFlightRefresh
}
const p = doRefresh(persist)
inFlightRefresh = p
// Only clear if it still points to this promise — a logout (or a newer
// refresh started after it) may have replaced inFlightRefresh meanwhile.
p.finally(() => {
if (inFlightRefresh === p) {
inFlightRefresh = null
}
})
return p
}
async function doRefresh(persist: boolean): Promise<void> {
// Snapshot the epoch so we can tell if a logout happened while we awaited.
const epochAtStart = authEpoch
const loggedOutSinceStart = () => authEpoch !== epochAtStart
// In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
@ -88,9 +53,6 @@ async function doRefresh(persist: boolean): Promise<void> {
}
try {
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
if (loggedOutSinceStart()) {
return
}
saveToken(tokens.access_token, persist)
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
} catch (e) {
@ -103,13 +65,7 @@ async function doRefresh(persist: boolean): Promise<void> {
// if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token')
const refreshUnderLock = async () => {
// A logout may have happened while we waited for the lock — don't
// re-adopt or re-fetch a token after the user signed out.
if (loggedOutSinceStart()) {
return
}
const doRefresh = async () => {
// If the token in localStorage changed while waiting for the lock,
// another tab already refreshed. Just adopt the new token.
const currentToken = localStorage.getItem('token')
@ -122,9 +78,6 @@ async function doRefresh(persist: boolean): Promise<void> {
const HTTP = HTTPFactory()
try {
const response = await HTTP.post('user/token/refresh')
if (loggedOutSinceStart()) {
return
}
saveToken(response.data.token, persist)
} catch (e) {
throw new Error('Error renewing token: ', {cause: e})
@ -132,10 +85,10 @@ async function doRefresh(persist: boolean): Promise<void> {
}
if (navigator.locks) {
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
await navigator.locks.request('vikunja-token-refresh', doRefresh)
} else {
// Fallback for environments without Web Locks (e.g. insecure HTTP)
await refreshUnderLock()
await doRefresh()
}
}

View File

@ -10,9 +10,5 @@ export function getProjectTitle(project: IProject) {
return i18n.global.t('project.inboxTitle')
}
if (project.title === 'My Open Tasks') {
return i18n.global.t('project.myOpenTasksFilterTitle')
}
return project.title
}

View File

@ -2,17 +2,10 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut'
import type {Editor} from '@tiptap/core'
import {getPopupContainer} from '@/components/input/editor/popupContainer'
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
return new Promise((resolve) => {
const id = 'link-input-' + createRandomID()
// Append inside the open task <dialog> (top-layer) when present, otherwise
// document.body. A body-level popup is painted behind a showModal() dialog
// and unfocusable through its focus trap, breaking the link prompt in the
// Kanban task popup (#2940).
const container = getPopupContainer(editor)
// Create popup element
const popupElement = document.createElement('div')
@ -33,7 +26,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
inputElement.value = oldValue
wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv)
container.appendChild(popupElement)
document.body.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
@ -89,41 +82,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
nextTick(() => document.getElementById(id)?.focus())
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
// dialogs close themselves on Escape ("cancel"); swallow that while the
// prompt is open so Escape only dismisses the prompt, not the task dialog.
const dialog = container.closest('dialog') as HTMLDialogElement | null
const handleDialogCancel = (event: Event) => event.preventDefault()
dialog?.addEventListener('cancel', handleDialogCancel)
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
}
}
const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true)
document.removeEventListener('click', handleClickOutside)
dialog?.removeEventListener('cancel', handleDialogCancel)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
if (document.body.contains(popupElement)) {
document.body.removeChild(popupElement)
}
}
document.getElementById(id)?.addEventListener('keydown', event => {
const shortcutString = eventToShortcutString(event)
if (shortcutString === 'Escape') {
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
event.preventDefault()
event.stopPropagation()
resolve('')
cleanup()
return
}
if (shortcutString !== 'Enter') {
return
}
@ -138,6 +105,15 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = '', edit
cleanup()
})
// Close on click outside
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
document.removeEventListener('click', handleClickOutside)
}
}
// Add slight delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleClickOutside)

View File

@ -24,10 +24,8 @@ export const redirectToProvider = (provider: IProvider) => {
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
}
export const redirectToProviderOnLogout = (provider: IProvider): boolean => {
export const redirectToProviderOnLogout = (provider: IProvider) => {
if (provider.logoutUrl.length > 0) {
window.location.href = `${provider.logoutUrl}`
return true
}
return false
}

View File

@ -5,7 +5,6 @@ import {i18n} from '@/i18n'
import {createSharedComposable} from '@vueuse/core'
import {computed, toValue, type MaybeRefOrGetter} from 'vue'
import {useDateDisplay} from '@/composables/useDateDisplay'
import {useGlobalNow} from '@/composables/useGlobalNow'
import {useTimeFormat} from '@/composables/useTimeFormat'
import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay'
import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat'
@ -50,13 +49,8 @@ export const formatDateSince = (date: Date | string | null) => {
const locale = DAYJS_LOCALE_MAPPING[i18n.global.locale.value.toLowerCase()] ?? 'en'
// Computing the relative string against the shared, ticking `now` (instead of fromNow's
// internal Date.now()) makes every reactive caller re-render on the 60s tick, so open views
// don't keep showing a stale "x minutes ago".
const {now} = useGlobalNow()
return date
? dayjs(date).locale(locale).from(now.value)
? dayjs(date).locale(locale).fromNow()
: ''
}

View File

@ -30,7 +30,6 @@ export const SUPPORTED_LOCALES = {
'ja-JP': '日本語',
'hu-HU': 'Magyar',
'ar-SA': 'اَلْعَرَبِيَّةُ',
'fa-IR': 'فارسی',
'sl-SI': 'Slovenščina',
'pt-BR': 'Português Brasileiro',
'hr-HR': 'Hrvatski',
@ -53,7 +52,7 @@ export const DEFAULT_LANGUAGE: SupportedLocale= 'en'
export type ISOLanguage = string
const RTL_LANGUAGES = ['ar-SA', 'he-IL', 'fa-IR'] as const
const RTL_LANGUAGES = ['ar-SA', 'he-IL'] as const
export function isRTLLanguage(locale: SupportedLocale): boolean {
return RTL_LANGUAGES.includes(locale as typeof RTL_LANGUAGES[number])

View File

@ -349,7 +349,6 @@
"shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen",
@ -394,7 +393,6 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -349,7 +349,6 @@
"shared": "Geteilte Projekte",
"noDescriptionAvailable": "Keine Projektbeschreibung verfügbar.",
"inboxTitle": "Eingang",
"myOpenTasksFilterTitle": "Meine offenen Aufgaben",
"favorite": "Dieses Projekt als Favorit markieren",
"unfavorite": "Dieses Projekt von Favoriten entfernen",
"openSettingsMenu": "Projekteinstellungen öffnen",
@ -394,7 +393,6 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -172,7 +172,6 @@
"yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ"
},
"timeFormat": "Μορφή ώρας",
"timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα",
"timeFormatOptions": {
"12h": "12 ώρες (ΠΜ/ΜΜ)",
"24h": "24 ώρες (ΩΩ:ΛΛ)"
@ -393,7 +392,6 @@
"title": "Αντιγραφή του έργου",
"label": "Αντιγραφή",
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
"success": "Το έργο αντιγράφηκε με επιτυχία."
},
"edit": {
@ -783,10 +781,7 @@
"closeDialog": "Κλείσμο του διαλόγου",
"closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών",
"skipToContent": "Μετάβαση στο κύριο περιεχόμενο",
"sortBy": "Ταξινόμηση ανά",
"dateRange": "Εύρος ημερομηνιών",
"notSet": "Μη ορισμένο",
"user": "Χρήστης"
"sortBy": "Ταξινόμηση ανά"
},
"input": {
"projectColor": "Χρώμα έργου",
@ -996,7 +991,6 @@
"repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος",
"percentDone": "Ορισμός Προόδου",
"attachments": "Προσθήκη Συνημμένων",
"timeTracking": "Χρόνος ίχνους",
"relatedTasks": "Προσθήκη Συσχέτισης",
"moveProject": "Μετακίνηση",
"duplicate": "Αντιγραφή",
@ -1466,32 +1460,6 @@
"frontendVersion": "Έκδοση frontend: {version}",
"apiVersion": "Έκδοση API: {version}"
},
"timeTracking": {
"title": "Ιχνηλάτηση χρόνου",
"stop": "Διακοπή χρονομέτρου",
"logTime": "Καταγραφή χρόνου",
"editEntry": "Επεξεργασία εγγραφής",
"form": {
"task": "Εργασία",
"taskSearch": "Αναζήτηση για μια εργασία…",
"commentPlaceholder": "Σε τι δουλέψατε;",
"save": "Αποθήκευση εγγραφής",
"startTimer": "Έναρξη χρονοµέτρου",
"update": "Ενημέρωση εγγραφής",
"smartFill": "Συμπλήρωση από την τελευταία καταχώριση"
},
"list": {
"emptyTask": "Δεν καταγράφηκε ακόμη χρόνος για αυτήν την εργασία.",
"emptyFiltered": "Δεν καταγράφηκε χρόνος με βάση τα επιλεγμένα φίλτρα.",
"total": "Σύνολο",
"time": "Ώρα",
"duration": "Διάρκεια"
},
"browse": {
"selectRange": "Επιλέξτε ένα εύρος",
"userSearch": "Αναζήτηση για ένα χρήστη…"
}
},
"time": {
"units": {
"seconds": "δευτερόλεπτο|δευτερόλεπτα",

View File

@ -349,7 +349,6 @@
"shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.",
"inboxTitle": "Inbox",
"myOpenTasksFilterTitle": "My Open Tasks",
"favorite": "Mark this project as favorite",
"unfavorite": "Remove this project from favorites",
"openSettingsMenu": "Open project settings menu",
@ -394,7 +393,6 @@
"title": "Duplicate this project",
"label": "Duplicate",
"text": "Select a parent project which should hold the duplicated project:",
"shares": "Copy shares (users, teams and link shares) to the duplicate",
"success": "The project was successfully duplicated."
},
"edit": {

View File

@ -5,32 +5,9 @@
},
"home": {
"welcomeNight": "Доброй ночи, {username}!",
"welcomeNightOwl": "Привет, ночная сова {username}",
"welcomeNightBurning": "Работаешь допоздна, {username}?",
"welcomeNightQuiet": "Тихие часы, {username}",
"welcomeNightLate": "Поздно, {username}",
"welcomeMorning": "Доброе утро, {username}!",
"welcomeMorningHey": "Привет, {username}, готов?",
"welcomeMorningFresh": "Свежий старт, {username}",
"welcomeMorningCoffee": "Кофе и задачи, {username}?",
"welcomeMorningRise": "Проснись и планируй, {username}",
"welcomeMorningBack": "С возвращением, {username}",
"welcomeMondayFresh": "Свежая неделя, {username}",
"welcomeTuesday": "Счастливого вторника, {username}",
"welcomeWednesdayMid": "Уже середина недели, {username}",
"welcomeThursday": "Почти готово, {username}",
"welcomeFridayPush": "Пятница, {username}?",
"welcomeSaturday": "Режим выходных, {username}",
"welcomeSundaySession": "Воскресный сеанс, {username}?",
"welcomeDay": "Привет, {username}!",
"welcomeDayFocus": "Давайте сосредоточимся, {username}",
"welcomeDayKeepGoing": "Так держать, {username}",
"welcomeDayWhatsNext": "Что дальше, {username}?",
"welcomeDayGood": "Добрый день, {username}",
"welcomeEvening": "Добрый вечер, {username}!",
"welcomeEveningWind": "Заканчиваешь, {username}?",
"welcomeEveningReturns": "{username} возвращается",
"welcomeEveningOneMore": "Ещё одна вещь, {username}?",
"lastViewed": "Последние просмотренные",
"addToHomeScreen": "Добавьте это приложение на домашний экран для быстрого доступа и удобной работы.",
"goToOverview": "Перейти к обзору",
@ -80,11 +57,6 @@
"openIdTotpSubmit": "Продолжить",
"oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}",
"oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.",
"desktopTryDemo": "Попробовать демо-версию",
"desktopCustomServer": "Пользовательский URL сервера",
"desktopCustomServerDescription": "Введите URL сервера Vikunja, чтобы начать.",
"desktopWaitingForAuth": "Ожидание аутентификации…",
"desktopOAuthError": "Ошибка аутентификации: {error}",
"logout": "Выйти",
"emailInvalid": "Введите корректный email адрес.",
"usernameRequired": "Введите имя пользователя.",
@ -103,19 +75,6 @@
"registrationFailed": "Произошла ошибка при регистрации. Проверьте введённые данные и повторите попытку."
},
"settings": {
"bots": {
"title": "Боты",
"description": "Боты — это пользователи, которые принадлежат вам и которые имеют доступ только к API. Их можно добавить в проекты, назначить задачи, и аутентификация выполняется с помощью токенов API. Боты не могут использовать обычный интерфейс.",
"namePlaceholder": "Мой помощник",
"create": "Создать бота",
"enable": "Включить",
"badge": "Бот",
"delete": {
"header": "Удалить бота",
"text1": "Удалить бота «{username}»?",
"text2": "Это необратимо. Любые токены API, принадлежащие этому боту, будут аннулированы."
}
},
"title": "Настройки",
"newPasswordTitle": "Изменить пароль",
"newPassword": "Новый пароль",
@ -141,11 +100,6 @@
"weekStart": "Первый день недели",
"weekStartSunday": "Воскресенье",
"weekStartMonday": "Понедельник",
"weekStartTuesday": "Вторник",
"weekStartWednesday": "Среда",
"weekStartThursday": "Четверг",
"weekStartFriday": "Пятница",
"weekStartSaturday": "Суббота",
"language": "Язык",
"defaultProject": "Проект по умолчанию",
"defaultView": "Представление по умолчанию",
@ -179,13 +133,7 @@
"taskAndNotifications": "Проекты и задачи",
"privacy": "Конфиденциальность",
"localization": "Локализация",
"appearance": "Внешний вид и поведение",
"desktop": "Настольное приложение"
},
"desktop": {
"quickEntryShortcut": "Ярлык быстрого входа",
"shortcutRecorderPlaceholder": "Нажмите, чтобы задать ярлык",
"shortcutRecorderRecording": "Нажмите комбинацию клавиш…"
"appearance": "Внешний вид и поведение"
},
"totp": {
"title": "Двухфакторная аутентификация",
@ -215,13 +163,6 @@
"usernameIs": "Имя пользователя для CalDAV: {0}",
"apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}."
},
"feeds": {
"title": "Atom-лента",
"howTo": "Вы можете подписаться на уведомления Vikunja в любом приложении для чтения новостей, поддерживающем Atom-ленты. Используйте следующий URL:",
"usernameIs": "Имя пользователя для доступа к ленте: {0}",
"apiTokenHint": "Для аутентификации используйте токен API с разрешением {scope}. Создайте его на странице {link}.",
"tokenTitle": "Atom-лента"
},
"avatar": {
"title": "Аватар",
"initials": "Инициалы",
@ -344,7 +285,6 @@
"shared": "Общие проекты",
"noDescriptionAvailable": "Описание проекта отсутствует.",
"inboxTitle": "Входящие",
"myOpenTasksFilterTitle": "Мои открытые задачи",
"favorite": "Отметить проект как избранный",
"unfavorite": "Удалить проект из избранного",
"openSettingsMenu": "Открыть настройки проекта",
@ -389,7 +329,6 @@
"title": "Создание копии проекта",
"label": "Создать копию",
"text": "Выберите родительский проект, в который поместить копию проекта:",
"shares": "Скопировать настройки доступа (пользователей, групп и ссылок для обмена)",
"success": "Копия проекта создана."
},
"edit": {
@ -486,8 +425,7 @@
"partialDatesStart": "Только дата начала (без окончания)",
"partialDatesEnd": "Только дата окончания (без начала)",
"expandGroup": "Развернуть группу: {task}",
"collapseGroup": "Свернуть группу: {task}",
"toggleRelationArrows": "Переключить стрелки связи"
"collapseGroup": "Свернуть группу: {task}"
},
"table": {
"title": "Таблица",
@ -516,8 +454,7 @@
"bucketTitleSavedSuccess": "Название колонки сохранено.",
"bucketLimitSavedSuccess": "Лимит колонки сохранён.",
"collapse": "Свернуть эту колонку",
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи.",
"bucketOptions": "Настройки колонки"
"bucketLimitReached": "Вы достигли лимита колонки. Удалите какие-нибудь задачи или увеличьте лимит, чтобы добавить новые задачи."
},
"pseudo": {
"favorites": {
@ -740,9 +677,7 @@
"upcoming": "Предстоящие задачи",
"settings": "Настройки",
"imprint": "Отпечаток",
"privacy": "Политика конфиденциальности",
"closeSidebar": "Закрыть боковую панель",
"home": "Главная страница Vikunja"
"privacy": "Политика конфиденциальности"
},
"misc": {
"loading": "Загрузка…",
@ -774,17 +709,9 @@
"createdBy": "Создатель {0}",
"actions": "Действия",
"cannotBeUndone": "Это действие отменить нельзя!",
"avatarOfUser": "Изображение профиля {user}",
"closeBanner": "Закрыть баннер",
"closeDialog": "Закрыть диалог",
"closeQuickActions": "Закрыть быстрые действия",
"skipToContent": "Перейти к основному содержимому",
"dateRange": "Диапазон",
"notSet": "Не задано",
"user": "Пользователь"
"avatarOfUser": "Изображение профиля {user}"
},
"input": {
"projectColor": "Цвет проекта",
"resetColor": "Сбросить цвет",
"datepicker": {
"today": "Сегодня",
@ -857,7 +784,6 @@
"date": "Дата",
"ranges": {
"today": "Сегодня",
"tomorrow": "Завтра",
"thisWeek": "Эта неделя",
"restOfThisWeek": "Остаток этой недели",
"nextWeek": "Следующая неделя",
@ -965,8 +891,6 @@
"belongsToProject": "Задача принадлежит проекту «{project}»",
"back": "Вернуться к проекту",
"due": "Истекает {at}",
"closeTaskDetail": "Закрыть детали задачи",
"title": "Детали задачи",
"scrollToBottom": "Прокрутить до конца страницы",
"organization": "Организация",
"management": "Управление",
@ -1060,10 +984,7 @@
"addedSuccess": "Комментарий добавлен.",
"permalink": "Скопировать постоянную ссылку на комментарий",
"sortNewestFirst": "Сначала новые",
"sortOldestFirst": "Сначала старые",
"reply": "Ответить",
"jumpToOriginal": "Перейти к исходному комментарию",
"deletedComment": "удалённый комментарий"
"sortOldestFirst": "Сначала старые"
},
"mention": {
"noUsersFound": "Пользователи не найдены"
@ -1327,11 +1248,9 @@
"none": "Уведомлений нет. Хорошего дня!",
"explainer": "Здесь появятся уведомления, когда что-нибудь произойдёт с проектами или задачами, на которые вы подписаны.",
"markAllRead": "Отметить всё как прочитанное",
"markAllReadSuccess": "Все уведомления отмечены как прочитанные.",
"subscribeFeed": "Подписаться на уведомления через Atom-ленту"
"markAllReadSuccess": "Все уведомления отмечены как прочитанные."
},
"quickActions": {
"notLoggedIn": "Сначала войдите в главное окно Vikunja.",
"commands": "Команды",
"placeholder": "Введите команду или поисковый запрос…",
"hint": "Используйте {project}, чтобы ограничить поиск проектом. Комбинируйте {project} и {label} (метки) с поисковым запросом для поиска задачи с этими метками или на этом проекте. Используйте {assignee} для поиска команд.",
@ -1458,66 +1377,5 @@
"weeks": "неделя|недели|недель",
"years": "год|года|лет"
}
},
"admin": {
"title": "Администрирование",
"labels": {
"users": "Пользователи",
"tasks": "Задачи"
},
"overview": {
"shares": "Общий доступ",
"linkSharesShort": "ссылка",
"teamSharesShort": "группа",
"userSharesShort": "пользователь",
"version": "Версия",
"license": "Лицензия",
"licenseValidUntil": "Истекает",
"licenseExpiresIn": "через {days} дней",
"licenseLastVerified": "Последняя проверка",
"licenseNever": "никогда",
"licenseLastCheckFailed": "последняя проверка не удалась",
"licenseFeatures": "Возможности",
"licenseInstance": "ID экземпляра",
"licenseManage": "Управление"
},
"searchUsersPlaceholder": "Поиск по имени пользователя или электронной почте…",
"users": {
"status": "Статус",
"details": "Детали",
"detailsTitle": "Пользователь: {username}",
"issuer": "Издатель",
"issuerLocal": "Локальный",
"issuerUrl": "URL издателя",
"subject": "Тема",
"statusActive": "Активен",
"statusEmailConfirmation": "Нужно подтвердить почту",
"statusDisabled": "Отключен",
"statusLocked": "Заблокирован",
"isAdminLabel": "Администратор",
"addUser": "Добавить пользователя",
"createTitle": "Создать пользователя",
"nameLabel": "Имя",
"skipEmailConfirm": "Пропустить подтверждение по электронной почте",
"createSubmit": "Создать пользователя",
"saveButton": "Сохранить изменения",
"createdSuccess": "Пользователь {username} создан.",
"updatedSuccess": "Пользователь {username} обновлён.",
"deletedSuccess": "Пользователь {username} удалён.",
"deleteScheduledSuccess": "Пользователь {username} получит подтверждение по электронной почте для запланированного удаления.",
"confirmDeleteTitle": "Удалить пользователя?",
"confirmDeleteIntro": "Как следует удалить пользователя {username}?",
"deleteModeScheduled": "Запланировать удаление",
"deleteModeScheduledHelp": "Запланированное удаление отправляет пользователю письмо с подтверждением, как если бы пользователь сам запросил удаление аккаунта.",
"deleteModeNow": "Удалить сейчас",
"deleteModeNowHelp": "Удаление сейчас удаляет пользователя и все его данные сразу. Это не может быть отменено."
},
"projects": {
"ownerLabel": "Владелец",
"reassignOwner": "Переназначить владельца",
"reassignTitle": "Переназначить {title}",
"reassignedSuccess": "Владелец проекта переназначен.",
"newOwnerLabel": "Новый владелец"
}
}
}

View File

@ -172,7 +172,6 @@
"yyyy/mm/dd": "YYYY/MM/DD"
},
"timeFormat": "Формат часу",
"timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу",
"timeFormatOptions": {
"12h": "12-годинний (AM/PM)",
"24h": "24-годинний (HH:mm)"
@ -393,7 +392,6 @@
"title": "Дублювати цей проєкт",
"label": "Дублювати",
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
"success": "Проєкт дубльовано."
},
"edit": {
@ -783,10 +781,7 @@
"closeDialog": "Закрити діалог",
"closeQuickActions": "Закрити швидкі дії",
"skipToContent": "Перейти до основного вмісту",
"sortBy": "Сортувати за",
"dateRange": "Діапазон дат",
"notSet": "Не встановлено",
"user": "Користувач"
"sortBy": "Сортувати за"
},
"input": {
"projectColor": "Колір проєкту",
@ -989,14 +984,13 @@
"assign": "Доручити",
"label": "Позначки",
"priority": "Встановити пріоритет",
"dueDate": "Встановити термін виконання",
"dueDate": "Встановити термін",
"startDate": "Почати",
"endDate": "Встановити дату завершення",
"reminders": "Нагадування",
"repeatAfter": "Повторювати",
"percentDone": "Встановити прогрес",
"attachments": "Вкласти",
"timeTracking": "Відстежити час",
"relatedTasks": "Пов'язати",
"moveProject": "Перемістити",
"duplicate": "Дублювати",
@ -1152,7 +1146,6 @@
"repeat": {
"everyDay": "Щодня",
"everyWeek": "Щотижня",
"every30d": "Кожні 30 днів",
"mode": "Спосіб",
"monthly": "Щомісяця",
"fromCurrentDate": "З дня закінчення",
@ -1466,32 +1459,6 @@
"frontendVersion": "Версія інтерфейсу: {version}",
"apiVersion": "API версія: {version}"
},
"timeTracking": {
"title": "Відстеження часу",
"stop": "Зупинити таймер",
"logTime": "Записати час",
"editEntry": "Редагувати запис",
"form": {
"task": "Завдання",
"taskSearch": "Знайти завдання…",
"commentPlaceholder": "Над чим ви працювали?",
"save": "Зберегти запис",
"startTimer": "Запустити таймер",
"update": "Оновити запис",
"smartFill": "Заповнити з останнього запису"
},
"list": {
"emptyTask": "Для цього завдання ще немає записів обліку часу.",
"emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.",
"total": "Загалом",
"time": "Час",
"duration": "Тривалість"
},
"browse": {
"selectRange": "Обрати діапазон",
"userSearch": "Знайти користувача…"
}
},
"time": {
"units": {
"seconds": "секунда|секунд(и)",

View File

@ -22,7 +22,6 @@ export const DAYJS_LOCALE_MAPPING = {
'ja-jp': 'ja',
'hu-hu': 'hu',
'ar-sa': 'ar-sa',
'fa-ir': 'fa',
'sl-si': 'sl',
'pt-br': 'pt',
'hr-hr': 'hr',
@ -56,7 +55,6 @@ export const DAYJS_LANGUAGE_IMPORTS = {
'ja-jp': () => import('dayjs/locale/ja'),
'hu-hu': () => import('dayjs/locale/hu'),
'ar-sa': () => import('dayjs/locale/ar-sa'),
'fa-ir': () => import('dayjs/locale/fa'),
'sl-si': () => import('dayjs/locale/sl'),
'pt-br': () => import('dayjs/locale/pt-br'),
'hr-hr': () => import('dayjs/locale/hr'),

View File

@ -5,5 +5,4 @@ export interface IProjectDuplicate extends IAbstract {
projectId: number
duplicatedProject: IProject | null
parentProjectId: IProject['id']
duplicateShares: boolean
}

View File

@ -8,7 +8,6 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
projectId = 0
duplicatedProject: IProject | null = null
parentProjectId = 0
duplicateShares = false
constructor(data : Partial<IProjectDuplicate>) {
super()

View File

@ -6,7 +6,6 @@ import {getProjectViewId} from '@/helpers/projectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {REDIRECT_HASH_PREFIX} from '@/constants/redirectHash'
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
import {PRO_FEATURE} from '@/constants/proFeatures'
@ -31,7 +30,7 @@ const router = createRouter({
}
// Scroll to anchor should still work
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX) && !to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
return {el: to.hash}
}
@ -473,22 +472,10 @@ const router = createRouter({
})
export async function getAuthForRoute(to: RouteLocation, authStore) {
// vue-router already decoded to.hash once, so slicing off the prefix yields the original
// fullPath (e.g. /oauth/authorize?...) losslessly — no extra decodeURIComponent needed.
const redirectDest = to.name === 'user.login' && to.hash.startsWith(REDIRECT_HASH_PREFIX)
? to.hash.slice(REDIRECT_HASH_PREFIX.length)
: ''
if (authStore.authUser || authStore.authLinkShare) {
// An already-signed-in browser that opens a copied /login#redirect=<oauth.authorize> URL
// must run the OAuth flow with its existing session instead of short-circuiting to home.
// The destination has no redirect hash, so the second guard pass just early-returns (#2654).
if (redirectDest) {
return redirectDest
}
return
}
// Check if password reset token is in query params
const resetToken = to.query.userPasswordReset as string | undefined
@ -512,35 +499,15 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
}
}
// Keep the destination in the address bar (not just per-browser localStorage) so a native
// client's /oauth/authorize URL stays copyable into another browser. Hash, not query, so the
// embedded OAuth params never reach access logs (#2654). Pass fullPath raw: vue-router encodes
// the hash itself, so an extra encodeURIComponent here would be double-encoded in the URL.
if (to.name === 'oauth.authorize') {
return {
name: 'user.login',
hash: REDIRECT_HASH_PREFIX + to.fullPath,
}
}
// Fold the hash destination into localStorage: it's the only bridge that survives the
// external OIDC round-trip out of the SPA, so redirectIfSaved() works after any auth method.
// vue-router already decoded to.hash once, so it equals the fullPath we wrote above as-is.
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
const destination = to.hash.slice(REDIRECT_HASH_PREFIX.length)
const resolved = router.resolve(destination)
saveLastVisited(resolved.name as string, resolved.params, resolved.query)
}
// Check if the route the user wants to go to is a route which needs authentication. We use this to
// redirect the user after successful login.
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
localStorage.getItem('emailConfirmToken') === null
if (isValidUserAppRoute) {
saveLastVisited(to.name as string, to.params, to.query)
}
if (isValidUserAppRoute) {
return {name: 'user.login'}
}
@ -598,25 +565,12 @@ router.beforeEach(async (to, from) => {
const newRoute = await getAuthForRoute(to, authStore)
if(newRoute) {
// A string target (the decoded redirect destination for an authed browser) already
// carries its own query/path and no redirect hash, so navigate to it verbatim — don't
// re-attach to.hash or it would re-enter the redirect loop.
if (typeof newRoute === 'string') {
return newRoute
}
return {
hash: to.hash,
...newRoute,
hash: to.hash,
}
}
// to.fullPath keeps the redirect hash url-encoded while to.hash is decoded, so the endsWith
// check below never matches and would re-append the hash forever. The hash is already on the
// URL here, so skip the re-attach (#2654).
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
return
}
if(!to.fullPath.endsWith(to.hash)) {
return to.fullPath + to.hash
}

View File

@ -1,139 +0,0 @@
import {describe, it, expect, beforeEach, vi} from 'vitest'
import {setActivePinia, createPinia} from 'pinia'
import {useAuthStore} from './auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
const {refreshTokenMock, routerPushMock, getTokenMock} = vi.hoisted(() => ({
refreshTokenMock: vi.fn(),
routerPushMock: vi.fn(),
getTokenMock: vi.fn(() => null as string | null),
}))
vi.mock('@/helpers/auth', () => ({
refreshToken: refreshTokenMock,
getToken: getTokenMock,
saveToken: vi.fn(),
removeToken: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {push: routerPushMock},
}))
vi.mock('@/composables/useWebSocket', () => ({
useWebSocket: () => ({disconnect: vi.fn(), connect: vi.fn()}),
}))
function fakeHttp() {
return {
post: vi.fn().mockResolvedValue({data: {}}),
get: vi.fn().mockResolvedValue({data: {}}),
request: vi.fn().mockResolvedValue({data: {}}),
interceptors: {
request: {use: vi.fn()},
response: {use: vi.fn()},
},
}
}
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => fakeHttp(),
AuthenticatedHTTPFactory: () => fakeHttp(),
getApiBaseUrl: () => 'http://localhost/api/v1/',
}))
vi.mock('@/helpers/redirectToProvider', () => ({
getRedirectUrlFromCurrentFrontendPath: vi.fn(),
redirectToProvider: vi.fn(),
redirectToProviderOnLogout: vi.fn(),
}))
// A refresh failure that looks like a real network/HTTP error so renewToken's
// "is this a genuine logout?" check (it inspects the error cause's status) fires.
function refreshError() {
return new Error('Error renewing token: ', {
cause: {response: {status: 401}},
})
}
// A JWT carrying a not-yet-expired user session, so the checkAuth() call that
// renewToken() runs after a successful refresh treats the session as live.
function freshUserJwt() {
const payload = {
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) + 3600,
}
const encoded = btoa(JSON.stringify(payload))
return `header.${encoded}.signature`
}
describe('auth store renewToken retry (issue #2863)', () => {
beforeEach(() => {
setActivePinia(createPinia())
refreshTokenMock.mockReset()
routerPushMock.mockReset()
getTokenMock.mockReset().mockReturnValue(null)
})
function setupExpiredUserSession(store: ReturnType<typeof useAuthStore>) {
store.setAuthenticated(true)
// Expired exp so renewToken treats a refresh failure as a real logout.
store.setUser({
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) - 60,
} as never, false)
}
it('does NOT log out when the first refresh fails but the retry succeeds', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
// The retry "succeeds" only if it actually leaves a usable token behind:
// renewToken() runs checkAuth() afterwards, which reads getToken(). Start
// with no token, then hand back a fresh JWT once the refresh resolves.
getTokenMock.mockReturnValue(null)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockImplementationOnce(async () => {
getTokenMock.mockReturnValue(freshUserJwt())
})
await store.renewToken()
// Two refresh attempts: the initial one and the single retry.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
// The retry recovered the session: the user is still authenticated...
expect(store.authenticated).toBe(true)
// ...and was not bounced to login.
expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'})
})
it('logs out when BOTH the refresh and its retry fail', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockRejectedValueOnce(refreshError())
await store.renewToken()
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
expect(routerPushMock).toHaveBeenCalledWith({name: 'user.login'})
})
it('retries exactly once (no infinite loop) when the session is genuinely dead', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock.mockRejectedValue(refreshError())
await store.renewToken()
// Initial attempt + exactly one retry — never more.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -28,11 +28,6 @@ import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
import type {IProvider} from '@/types/IProvider'
// Set on explicit logout so the login page won't immediately bounce the user
// back to the OIDC provider. Lives in sessionStorage so it survives the
// round-trip to the IdP within the tab and isn't wiped by localStorage.clear().
export const JUST_LOGGED_OUT_KEY = 'justLoggedOut'
function redirectToSpecifiedProvider() {
const {auth} = useConfigStore()
@ -60,17 +55,6 @@ function redirectToSpecifiedProvider() {
}
}
// A race-loser's refresh fails but the rotated cookie is already valid, so a
// second attempt succeeds — recovering what would otherwise be a spurious
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
try {
await refreshToken(persist)
} catch {
await refreshToken(persist)
}
}
function getLoggedInVia(): string | null {
return localStorage.getItem('loggedInViaProvider')
}
@ -368,7 +352,7 @@ export const useAuthStore = defineStore('auth', () => {
// refresh before giving up. This lets users who reopen the app
// after the short JWT TTL seamlessly resume their session.
try {
await refreshTokenWithRetry(true)
await refreshToken(true)
const freshJwt = getToken()
if (freshJwt) {
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@ -528,7 +512,7 @@ export const useAuthStore = defineStore('auth', () => {
saveToken(response.data.token, false)
} else {
// User sessions renew via the refresh-token cookie.
await refreshTokenWithRetry(true)
await refreshToken(true)
}
await checkAuth()
} catch (e) {
@ -549,11 +533,9 @@ export const useAuthStore = defineStore('auth', () => {
// Revoke the server session so the refresh token can't be reused.
// Best-effort: if the network call fails, still clean up locally.
let oidcLogoutUrl = ''
try {
const HTTP = AuthenticatedHTTPFactory()
const {data} = await HTTP.post('user/logout')
oidcLogoutUrl = data?.oidc_logout_url ?? ''
await HTTP.post('user/logout')
} catch (_e) {
// Ignore — session will expire naturally
}
@ -562,25 +544,14 @@ export const useAuthStore = defineStore('auth', () => {
const loggedInVia = getLoggedInVia()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
lastUserInfoRefresh.value = null
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true')
// Redirect to the OIDC provider to end its session too. Prefer the
// server-built RP-Initiated Logout URL, falling back to the static one.
// These full-page redirects return the user to the login page, so we
// must not router.push there first — that would consume
// JUST_LOGGED_OUT_KEY before the round-trip lands.
if (oidcLogoutUrl) {
window.location.href = oidcLogoutUrl
return
}
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider && redirectToProviderOnLogout(fullProvider)) {
return
}
await router.push({name: 'user.login'})
await checkAuth()
// if configured, redirect to OIDC Provider on logout
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider) {
redirectToProviderOnLogout(fullProvider)
}
}
return {

View File

@ -47,7 +47,6 @@ export interface ConfigState {
publicTeamsEnabled: boolean,
allowIconChanges: boolean,
enabledProFeatures: string[],
concurrentWrites: boolean,
}
export const useConfigStore = defineStore('config', () => {
@ -89,7 +88,6 @@ export const useConfigStore = defineStore('config', () => {
publicTeamsEnabled: false,
allowIconChanges: true,
enabledProFeatures: [],
concurrentWrites: false,
})
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)

View File

@ -380,11 +380,10 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
success({message: t('project.edit.success')})
}
async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) {
async function duplicateProject(parentProjectId: IProject['id']) {
const projectDuplicate = new ProjectDuplicateModel({
projectId: Number(toValue(projectId)),
parentProjectId,
duplicateShares,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)

View File

@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'
import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks'
import {buildDefaultRemindersForQuickAdd} from './tasks'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
@ -42,39 +42,3 @@ describe('buildDefaultRemindersForQuickAdd', () => {
expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE)
})
})
describe('runWrites', () => {
function deferredWrite() {
const inFlight: string[] = []
let maxConcurrent = 0
const completed: string[] = []
const write = async (item: string) => {
inFlight.push(item)
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
await Promise.resolve()
inFlight.splice(inFlight.indexOf(item), 1)
completed.push(item)
}
return {write, completed, getMaxConcurrent: () => maxConcurrent}
}
it('runs all writes in parallel when concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, true)
expect(completed).toHaveLength(3)
expect(getMaxConcurrent()).toBeGreaterThan(1)
})
it('runs writes one at a time when not concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, false)
expect(completed).toEqual(['a', 'b', 'c'])
expect(getMaxConcurrent()).toBe(1)
})
it('does nothing for an empty list', async () => {
const {write, completed} = deferredWrite()
await runWrites([], write, false)
expect(completed).toHaveLength(0)
})
})

View File

@ -27,7 +27,6 @@ import type {IProject} from '@/modelTypes/IProject'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {setModuleLoading} from '@/stores/helper'
import {useConfigStore} from '@/stores/config'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
@ -60,22 +59,6 @@ export function buildDefaultRemindersForQuickAdd(
}))
}
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
export async function runWrites<T>(
items: readonly T[],
write: (item: T) => Promise<unknown>,
concurrent: boolean,
): Promise<void> {
if (concurrent) {
await Promise.all(items.map(item => write(item)))
return
}
for (const item of items) {
await write(item)
}
}
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value, fuzzy = false) {
return Object.values(object).find(l => {
@ -148,7 +131,6 @@ export const useTaskStore = defineStore('task', () => {
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
const isLoading = ref(false)
@ -413,7 +395,10 @@ export const useTaskStore = defineStore('task', () => {
}
const labels = await ensureLabelsExist(parsedLabels)
await runWrites(labels, l => addLabelToTask(task, l), configStore.concurrentWrites)
const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l))
// This waits until all labels are created and added to the task
await Promise.all(labelAddsToWaitFor)
return task
}

View File

@ -4,10 +4,6 @@
}
}
.is-pulled-end {
.is-pulled-right {
float: right !important;
}
[dir="rtl"] .is-pulled-end {
float: left !important;
}

View File

@ -5,7 +5,7 @@
>
<XButton
:to="{name:'labels.create'}"
class="is-pulled-end"
class="is-pulled-right"
icon="plus"
>
{{ $t('label.create.header') }}

View File

@ -8,12 +8,6 @@
>
<p>{{ $t('project.duplicate.text') }}</p>
<ProjectSearch v-model="parentProject" />
<FancyCheckbox
v-model="duplicateShares"
class="mbs-2"
>
{{ $t('project.duplicate.shares') }}
</FancyCheckbox>
</CreateEdit>
</template>
@ -24,7 +18,6 @@ import {useI18n} from 'vue-i18n'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
@ -40,7 +33,6 @@ const projectStore = useProjectStore()
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
const parentProject = ref<IProject | null>(null)
const duplicateShares = ref(true)
const isDuplicating = ref(false)
const loadingModel = computed({
@ -61,7 +53,7 @@ async function duplicate() {
isDuplicating.value = true
try {
await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value)
await duplicateProject(parentProject.value?.id ?? 0)
success({message: t('project.duplicate.success')})
} finally {
isDuplicating.value = false

View File

@ -5,7 +5,7 @@
>
<XButton
:to="{name:'teams.create'}"
class="is-pulled-end"
class="is-pulled-right"
icon="plus"
>
{{ $t('team.create.title') }}

View File

@ -136,7 +136,7 @@ import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {isDesktopApp} from '@/helpers/desktopAuth'
import {useAuthStore, JUST_LOGGED_OUT_KEY} from '@/stores/auth'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
@ -181,25 +181,6 @@ onBeforeMount(() => {
// route before the submit() handler gets a chance to use it.
if (authenticated.value) {
router.push({name: 'home'})
return
}
// Don't auto-redirect right after an explicit logout, otherwise we'd
// immediately re-authenticate the user we just logged out.
if (sessionStorage.getItem(JUST_LOGGED_OUT_KEY)) {
sessionStorage.removeItem(JUST_LOGGED_OUT_KEY)
return
}
// When the login page offers nothing but a single OIDC provider, skip it
// and send the user straight there.
if (
!localAuthEnabled.value &&
!ldapAuthEnabled.value &&
hasOpenIdProviders.value &&
openidConnect.value.providers.length === 1
) {
redirectToProvider(openidConnect.value.providers[0])
}
})

View File

@ -1,124 +0,0 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {BucketFactory} from '../../factories/bucket'
import {TaskFactory} from '../../factories/task'
import {TaskBucketFactory} from '../../factories/task_buckets'
// Regression test for #2940: in the Kanban task popup the description editor is
// rendered inside a native <dialog> opened via showModal() (browser top-layer).
// The link prompt used to be appended to document.body, so it was painted behind
// the dialog and unfocusable through its focus trap, making "set link" a no-op.
test.describe('Editor link prompt inside the Kanban task popup', () => {
test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
// The task popup must be a native <dialog> in the top layer.
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.fill('https://vikunja.io')
await urlInput.press('Enter')
const link = editor.locator('a[href="https://vikunja.io"]')
await expect(link).toBeVisible()
await expect(link).toHaveText('link me')
})
// The link prompt is a sub-modal of the task <dialog>: pressing Escape while
// it is open must cancel only the prompt and leave the task dialog open,
// instead of falling through to the native <dialog>'s Escape-to-close.
test('Escape cancels the link prompt without closing the task dialog', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.press('Escape')
// The prompt is gone, but the task dialog stays open.
await expect(urlInput).toBeHidden()
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
})
})

View File

@ -1,55 +0,0 @@
import {type Page} from '@playwright/test'
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
async function selectSortInList(page: Page, optionLabel: string) {
await page.locator('.filter-container').getByRole('button', {name: 'Sort', exact: true}).click()
await page.getByLabel('Sort by').selectOption({label: optionLabel})
await page.getByRole('button', {name: 'Apply sort'}).click()
}
async function navigateViaSidebar(page: Page, projectTitle: string) {
await page.locator('.menu-list .list-menu-link', {
has: page.locator('.project-menu-title', {hasText: new RegExp(`^${projectTitle}$`)}),
}).first().click()
}
test.describe('Sort persistence across sidebar navigation (#2753)', () => {
test('List view: sort persists after navigating to another project and back', async ({authenticatedPage: page}) => {
const projects = await createProjects(2)
const [projectA, projectB] = projects
await TaskFactory.create(3, {
id: '{increment}',
project_id: projectA.id,
title: 'Task {increment}',
})
const listViewA = projectA.views[0].id
await page.goto(`/projects/${projectA.id}/${listViewA}`)
await expect(page).not.toHaveURL(/sort=/)
await selectSortInList(page, 'Due date (Earliest first)')
await expect(page).toHaveURL(/sort=due_date:asc/)
await navigateViaSidebar(page, projectB.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectB.id}/`))
await navigateViaSidebar(page, projectA.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectA.id}/`))
await expect(page).toHaveURL(/sort=due_date:asc/)
})
test('List view: explicit URL sort wins over stored sort', async ({authenticatedPage: page}) => {
const projects = await createProjects(1)
const listView = projects[0].views[0].id
// Seed the store with one sort by visiting with it set.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=due_date:asc`)
await expect(page).toHaveURL(/sort=due_date:asc/)
// Visit a URL that explicitly sets a different sort — that should win.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=priority:desc`)
await expect(page).toHaveURL(/sort=priority:desc/)
})
})

View File

@ -32,20 +32,10 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
})
// Navigate to the OAuth authorize frontend route.
// The user is not logged in, so the router guard redirects to /login while
// carrying the authorize destination in a copyable #redirect= hash (not a
// query param, to keep the OAuth params out of access logs).
// The user is not logged in, so the router guard saves the route
// and redirects to /login.
await page.goto(`/oauth/authorize?${authorizeParams}`)
await expect(page).toHaveURL(/\/login#redirect=/)
// The decoded #redirect= destination must carry the full authorize URL, including the
// OAuth params — checking only for the path would pass even if the query were dropped.
const redirectHash = decodeURIComponent(new URL(page.url()).hash)
expect(redirectHash).toContain('/oauth/authorize')
expect(redirectHash).toContain('response_type=code')
expect(redirectHash).toContain('client_id=vikunja')
expect(redirectHash).toContain(`code_challenge=${codeChallenge}`)
expect(redirectHash).toContain(`state=${state}`)
await expect(page).toHaveURL(/\/login/)
// Register the response listener BEFORE clicking Login, because after
// login redirectIfSaved() navigates back to /oauth/authorize and the
@ -87,70 +77,4 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
// The primary #2654 scenario: the native client opened a different default browser that is
// already signed in to Vikunja. Opening the copied /login#redirect=<oauth.authorize> URL must
// run the OAuth flow with the existing session instead of short-circuiting to home.
test('Already-authenticated browser opening the copied login redirect runs the authorize flow', async ({authenticatedPage, apiContext, currentUser}) => {
const page = authenticatedPage
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
})
// The component POSTs as soon as it mounts with the existing session, so register the
// listener before navigating.
const authorizeResponsePromise = page.waitForResponse(
response => response.url().includes('/api/v1/oauth/authorize') && response.request().method() === 'POST',
{timeout: 15000},
)
// Open the copyable login URL exactly as it would be pasted from another browser
// (#redirect= is REDIRECT_HASH_PREFIX from @/constants/redirectHash, inlined here because
// the e2e runner has no @ alias).
const redirectDestination = `/oauth/authorize?${authorizeParams}`
await page.goto(`/login#redirect=${encodeURIComponent(redirectDestination)}`)
// The authed guard must send us straight to /oauth/authorize, not home.
await expect(page).toHaveURL(/\/oauth\/authorize/)
const landed = new URL(page.url())
expect(landed.pathname).toBe('/oauth/authorize')
expect(landed.searchParams.get('response_type')).toBe('code')
expect(landed.searchParams.get('client_id')).toBe('vikunja')
expect(landed.searchParams.get('code_challenge')).toBe(codeChallenge)
expect(landed.searchParams.get('state')).toBe(state)
// The PKCE flow completes with the existing session — no second login.
const authorizeResponse = await authorizeResponsePromise
const authorizeBody = await authorizeResponse.json()
expect(authorizeBody.code).toBeTruthy()
expect(authorizeBody.redirect_uri).toBe('vikunja-flutter://callback')
expect(authorizeBody.state).toBe(state)
const tokenResponse = await apiContext.post('oauth/token', {
data: {
grant_type: 'authorization_code',
code: authorizeBody.code,
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_verifier: codeVerifier,
},
})
expect(tokenResponse.ok()).toBe(true)
const tokenBody = await tokenResponse.json()
expect(tokenBody.access_token).toBeTruthy()
expect(tokenBody.refresh_token).toBeTruthy()
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
})

123
go.mod
View File

@ -16,58 +16,56 @@
module code.vikunja.io/api
go 1.26.4
go 1.25.7
require (
code.dny.dev/ssrf v0.2.0
dario.cat/mergo v1.0.2
github.com/JohannesKaufmann/dom v0.3.1
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/ThreeDotsLabs/watermill v1.5.2
github.com/ThreeDotsLabs/watermill v1.5.1
github.com/adlio/trello v1.12.0
github.com/arran4/golang-ical v0.3.5
github.com/arran4/golang-ical v0.3.2
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.26
github.com/aws/aws-sdk-go-v2/credentials v1.19.25
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1
github.com/aws/smithy-go v1.27.3
github.com/bbrks/go-blurhash v1.2.0
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/smithy-go v1.24.2
github.com/bbrks/go-blurhash v1.1.1
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
github.com/coder/websocket v1.8.15
github.com/coreos/go-oidc/v3 v3.19.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.17.0
github.com/d4l3k/messagediff v1.2.1
github.com/danielgtaylor/huma/v2 v2.38.0
github.com/danielgtaylor/huma/v2 v2.37.3
github.com/disintegration/imaging v1.6.2
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
github.com/fatih/color v1.19.0
github.com/fatih/color v1.18.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/ganigeorgiev/fexpr v0.5.0
github.com/getsentry/sentry-go v0.41.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-sql-driver/mysql v1.10.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-sql-driver/mysql v1.9.3
github.com/go-testfixtures/testfixtures/v3 v3.19.0
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/feeds v1.2.0
github.com/hashicorp/go-version v1.9.0
github.com/hashicorp/go-version v1.8.0
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
github.com/huandu/go-clone/generic v1.7.3
github.com/iancoleman/strcase v0.3.0
github.com/jaswdr/faker/v2 v2.9.1
github.com/jinzhu/copier v0.4.0
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6
github.com/labstack/echo-jwt/v5 v5.0.1
github.com/labstack/echo/v5 v5.2.1
github.com/lib/pq v1.12.3
github.com/magefile/mage v1.17.2
github.com/mattn/go-sqlite3 v1.14.47
github.com/labstack/echo-jwt/v5 v5.0.0
github.com/labstack/echo/v5 v5.0.3
github.com/lib/pq v1.10.9
github.com/magefile/mage v1.15.0
github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27
github.com/olekukonko/tablewriter v1.1.4
github.com/olekukonko/tablewriter v1.1.3
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0
github.com/redis/go-redis/v9 v9.17.3
github.com/robfig/cron/v3 v3.0.1
github.com/samedi/caldav-go v3.0.0+incompatible
github.com/schollz/progressbar/v3 v3.19.0
@ -78,43 +76,43 @@ require (
github.com/tkuchiki/go-timezone v0.2.3
github.com/traefik/yaegi v0.16.1
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.7.3
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.53.0
github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.16
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.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/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.38.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.35.0
gopkg.in/d4l3k/messagediff.v1 v1.2.1
mvdan.cc/xurls/v2 v2.6.0
src.techknowlogick.com/xormigrate v1.7.1
xorm.io/builder v0.3.13
xorm.io/xorm v1.4.1
xorm.io/xorm v1.3.11
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -136,7 +134,7 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@ -147,7 +145,7 @@ require (
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.1 // indirect
@ -158,8 +156,8 @@ require (
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.53.0 // indirect
@ -168,18 +166,18 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid v1.3.1 // 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
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.16.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@ -199,13 +197,12 @@ require (
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

179
go.sum
View File

@ -4,8 +4,6 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE=
@ -17,10 +15,6 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
@ -30,116 +24,56 @@ github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY=
github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4=
github.com/ThreeDotsLabs/watermill v1.5.2 h1:0ES33Eq1jEsP/pWvtE4n8bE0bs+9Jq7boT7wGBCVY6Q=
github.com/ThreeDotsLabs/watermill v1.5.2/go.mod h1:i9/968UriGphWfEbfMuYSD1qFbYRjb0mE0r+rV0FPp4=
github.com/adlio/trello v1.12.0 h1:JqOE2GFHQ9YtEviRRRSnicSxPbt4WFOxhqXzjMOw8lw=
github.com/adlio/trello v1.12.0/go.mod h1:I4Lti4jf2KxjTNgTqs5W3lLuE78QZZdYbbPnQQGwjOo=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY=
github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo=
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
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-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/config v1.32.26 h1:JI+W5B3jUA8UBz2ggbICGd9UCR6/+SB21G8EFl0SFTQ=
github.com/aws/aws-sdk-go-v2/config v1.32.26/go.mod h1:RLE2Ls/wRstvdSz1GPrIWNnXcKZ/znDdWyMuiQxdBoY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.25 h1:TzPVjfUZ1hsKafvYE+DIzKXIik2KufQxsPHanlkttbo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.25/go.mod h1:K4hw0buguVvtC74HnVfTRr0LzQQHAWPqJbBU9QGk2Pg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30 h1:4HbXxyipSYxexU0juMIpdS05dilL6dbB2VQHxxN2vGU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.30/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1 h1:yb03KevaOAG5e8suo79Af74vjIQvoeKmjl79WQchLrs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.1/go.mod h1:mreYODw0Y4yv7xeczvqC6vciwFao8lPE9k1l1ulfY6E=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1 h1:BeJmkm5YOZs6lGRGcNoIuLSoTTtGLLCEqlSiRKYodfM=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.1/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11/go.mod h1:0DO9B5EUJQlIDif+XJRWCljZRKsAFKh3gpFz7UnDtOo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4 h1:i465b/3c7xJd++pobNIDOggouekCuiWOnB0goQJy+94=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.4/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7 h1:xbmJAnBbyYPkTzoCNCF/bpJ6ymQHRdXX1vquYfDIGYk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.7/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4 h1:Np0vmL7op0Zs5xGacYMMX3v5O5pvZ46xhb5LwDgPj8M=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.4/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
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/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=
github.com/bbrks/go-blurhash v1.1.1/go.mod h1:lkAsdyXp+EhARcUo85yS2G1o+Sh43I2ebF5togC4bAY=
github.com/bbrks/go-blurhash v1.2.0 h1:99w0YT50b/B7uoZyM79Nqy+UemMOh8fO/ONyyxmr9MU=
github.com/bbrks/go-blurhash v1.2.0/go.mod h1:r4N4/ViVMa2h6Ex6e1aoCWMTkykYWS/VXvYMCrbkRpw=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@ -169,16 +103,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
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-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@ -192,8 +122,6 @@ github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM=
github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g=
github.com/danielgtaylor/huma/v2 v2.38.0 h1:fb0WZCatnaiHLphMQDDWDjygNxfMkX/ENma3QsRl7vY=
github.com/danielgtaylor/huma/v2 v2.38.0/go.mod h1:k9hwjlgWFt1t2jsmQGlsgXAG2FBTZa4kkjV581qAtfo=
github.com/danielgtaylor/mexpr v1.9.1 h1:nA9bsGRmNlJeVCPFgGf7WhrLuKag/+iWfOaJ03iKFPI=
github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8=
github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ=
@ -224,8 +152,6 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@ -236,8 +162,6 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
@ -255,8 +179,6 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@ -279,8 +201,6 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-testfixtures/testfixtures/v3 v3.19.0 h1:/Y0bars250zggm+1A2PvwaJQsJel7/tS4D/Hhwt66Bc=
@ -293,15 +213,11 @@ github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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-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/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@ -343,8 +259,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346 h1:Odeq5rB6OZSkib5gqTG+EM1iF0bUVjYYd33XB1ULv00=
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346/go.mod h1:4ggHM2qnyyZjenBb7RpwVzIj+JMsu9kHCVxMjB30hGs=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@ -442,14 +356,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-jwt/v5 v5.0.0 h1:uPp+FpkI/PKpMPPygtnK3RQOpg5a2wlM04UgfpWLVyI=
github.com/labstack/echo-jwt/v5 v5.0.0/go.mod h1:RYF2ojWXbaY09QQ5J9vVtPUtkyI5UztS0gJotmCRz/U=
github.com/labstack/echo-jwt/v5 v5.0.1 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
github.com/labstack/echo/v5 v5.0.3 h1:Jql8sDtCYXrhh2Mbs6jKwjR6r7X8FSQQmch+w6QS7kc=
github.com/labstack/echo/v5 v5.0.3/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc=
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/labstack/echo/v5 v5.2.1 h1:TzpIksY6zLMzV0T0ycYbvTEoj9w6o6AcL5twg182VTY=
github.com/labstack/echo/v5 v5.2.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef h1:RZnRnSID1skF35j/15KJ6hKZkdIC/teQClJK5wP5LU4=
github.com/laurent22/ical-go v0.1.1-0.20181107184520-7e5d6ade8eef/go.mod h1:4LATl0uhhtytR6p9n1AlktDyIz4u2iUnWEdI3L/hXiw=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -459,14 +367,10 @@ github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@ -482,18 +386,14 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
@ -524,16 +424,10 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
@ -550,8 +444,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
@ -569,16 +461,10 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
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/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@ -600,10 +486,6 @@ github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeH
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@ -660,16 +542,14 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@ -690,8 +570,6 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
@ -703,8 +581,6 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
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/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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -720,10 +596,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9 h1:yZNXmy+j/JpX19vZkVktWqAo7Gny4PBWYYK3zskGpx4=
golang.org/x/exp v0.0.0-20221126150942-6ab00d035af9/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@ -734,10 +608,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -752,20 +624,16 @@ golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
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/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -793,18 +661,15 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
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/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -812,10 +677,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
@ -831,10 +694,8 @@ golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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 v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -938,5 +799,3 @@ xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=

View File

@ -1,4 +1,4 @@
[tools]
node = "24.18.0" # keep in sync with frontend/.nvmrc
pnpm = "10.34.4" # keep in sync with frontend/package.json#packageManager
go = "1.26.4" # keep in sync with go.mod
node = "24.13.0" # keep in sync with frontend/.nvmrc
pnpm = "10.28.1" # keep in sync with frontend/package.json#packageManager
go = "1.25.7" # keep in sync with go.mod

46
pkg/audit/doc.go Normal file
View File

@ -0,0 +1,46 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package audit persists an audit trail of authentication, authorization and
// data lifecycle events as JSONL, with optional forwarding to stdout, syslog
// or webhook sinks.
//
// Events opt in via RegisterEventForAudit, which subscribes one audit
// listener per event on the existing watermill bus; the event→Entry mapping
// is a closure passed at registration. The catalog of audited events lives in
// registerEventsForAuditLogging in pkg/models/listeners.go.
//
// Entries reference actors and targets by opaque ID only — deleting a user
// row orphans their audit references, which satisfies GDPR erasure without
// log redaction.
//
// Audit logging is gated twice: registration on the audit.enabled config key,
// and each write on the licensed audit_logs feature. The license is checked
// per event because it can change at runtime; enabled-but-unlicensed means
// listeners run and write nothing.
//
// Request attribution (IP, user agent, request id) flows from an Echo
// middleware through the request context onto message metadata — see
// pkg/events.RequestMeta. Events dispatched outside a request get
// source type "system" instead.
//
// The local file is the source of truth: a failed file write is returned to
// the router for retry, while forwarder failures are only logged so a dead
// sink cannot poison-queue every event. Tamper evidence comes from filesystem
// permissions (the file is created 0600) plus shipping entries to an external
// sink, not from hash chains or signatures. Rotation is size-based with
// age-based cleanup of rotated files; retention is the operator's concern.
package audit

View File

@ -14,33 +14,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package audit persists an audit trail of authentication, authorization and
// data lifecycle events as JSONL.
//
// Events opt in via RegisterEventForAudit, which subscribes one audit
// listener per event on the existing watermill bus; the event→Entry mapping
// is a closure passed at registration. The catalog of audited events lives in
// registerEventsForAuditLogging in pkg/models/listeners.go.
//
// Entries reference actors and targets by opaque ID only — deleting a user
// row orphans their audit references, which satisfies GDPR erasure without
// log redaction.
//
// Audit logging is gated twice: registration on the audit.enabled config key,
// and each write on the licensed audit_logs feature. The license is checked
// per event because it can change at runtime; enabled-but-unlicensed means
// listeners run and write nothing.
//
// Request attribution (IP, user agent, request id) flows from an Echo
// middleware through the request context onto message metadata — see
// pkg/events.RequestMeta. Events dispatched outside a request get
// source type "system" instead.
//
// A failed file write is returned to the router for retry. Tamper evidence
// comes from filesystem permissions (the file is created 0600) plus shipping
// the file to an external system, not from hash chains or signatures.
// Rotation is size-based with age-based cleanup of rotated files; retention
// is the operator's concern.
package audit
import "time"

View File

@ -45,15 +45,27 @@ func RegisterEventForAudit[T any, PT interface {
events.Event
}](toEntry func(PT) *Entry) {
name := PT(new(T)).Name()
RegisterEventNameForAudit(name, func(payload []byte) (*Entry, error) {
e := PT(new(T)) // fresh instance per message — handlers run concurrently
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return toEntry(e), nil
})
}
// RegisterEventNameForAudit is the untyped variant for events which cannot be
// unmarshaled into their Go struct directly (e.g. interface-typed Doer
// fields); the mapping decodes the raw payload itself.
func RegisterEventNameForAudit(name string, toEntry func(payload []byte) (*Entry, error)) {
events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error {
if !license.IsFeatureEnabled(license.FeatureAuditLogs) {
return nil // license is runtime-mutable — checked per event, not at registration
}
e := PT(new(T)) // fresh instance per message — handlers run concurrently
if err := json.Unmarshal(msg.Payload, e); err != nil {
entry, err := toEntry(msg.Payload)
if err != nil {
return err
}
entry := toEntry(e)
if entry == nil {
return nil
}

24
pkg/audit/sinks/sink.go Normal file
View File

@ -0,0 +1,24 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package sinks contains the forwarding targets for audit log entries.
package sinks
// Sink forwards a single audit entry, passed as its serialized JSON line
// without a trailing newline. Implementations must be safe for concurrent use.
type Sink interface {
Write(line []byte) error
}

View File

@ -14,34 +14,31 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package richtext
package sinks
import (
"io"
"os"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"sync"
)
// TestMain bootstraps a test DB with user fixtures so the mention-resolution
// tests can look up real users. The pure converter tests don't touch the DB.
func TestMain(m *testing.M) {
log.InitLogger()
x, err := db.CreateTestEngine()
if err != nil {
log.Fatal(err)
}
if err := x.Sync2(user.GetTables()...); err != nil {
log.Fatal(err)
}
if err := db.InitTestFixtures("users"); err != nil {
log.Fatal(err)
}
os.Exit(m.Run())
// Stdout writes each entry as one line to standard output.
type Stdout struct {
mu sync.Mutex
// out exists so tests can capture the output.
out io.Writer
}
func NewStdout() *Stdout {
return &Stdout{out: os.Stdout}
}
func (s *Stdout) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.out.Write(line); err != nil {
return err
}
_, err := s.out.Write([]byte{'\n'})
return err
}

116
pkg/audit/sinks/syslog.go Normal file
View File

@ -0,0 +1,116 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package sinks
import (
"context"
"fmt"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
)
// Hand-rolled RFC 5424 instead of log/syslog: the stdlib package only emits
// the older RFC 3164 format and does not build on Windows.
type Syslog struct {
network string
address string
facility int
hostname string
procid string
mu sync.Mutex
conn net.Conn
}
var syslogFacilities = map[string]int{
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5,
"lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
}
// NewSyslog creates a syslog sink. The address has the form
// udp://host:port or tcp://host:port; the scheme defaults to udp.
func NewSyslog(address, facility string) (*Syslog, error) {
if address == "" {
return nil, fmt.Errorf("syslog forwarder requires an address")
}
if !strings.Contains(address, "://") {
address = "udp://" + address
}
u, err := url.Parse(address)
if err != nil {
return nil, fmt.Errorf("invalid syslog address %q: %w", address, err)
}
if u.Scheme != "udp" && u.Scheme != "tcp" {
return nil, fmt.Errorf("unsupported syslog scheme %q, must be udp or tcp", u.Scheme)
}
if facility == "" {
facility = "local0"
}
facilityCode, ok := syslogFacilities[strings.ToLower(facility)]
if !ok {
return nil, fmt.Errorf("unknown syslog facility %q", facility)
}
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "-"
}
return &Syslog{
network: u.Scheme,
address: u.Host,
facility: facilityCode,
hostname: hostname,
procid: fmt.Sprintf("%d", os.Getpid()),
}, nil
}
func (s *Syslog) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(context.Background(), s.network, s.address)
if err != nil {
return fmt.Errorf("could not connect to syslog at %s://%s: %w", s.network, s.address, err)
}
s.conn = conn
}
pri := s.facility*8 + 6 // severity: informational
frame := fmt.Sprintf("<%d>1 %s %s vikunja %s audit - %s",
pri, time.Now().UTC().Format(time.RFC3339Nano), s.hostname, s.procid, line)
if s.network == "tcp" {
frame += "\n" // RFC 6587 non-transparent framing
}
if _, err := s.conn.Write([]byte(frame)); err != nil {
// Drop the connection so the next write redials.
_ = s.conn.Close()
s.conn = nil
return err
}
return nil
}

View File

@ -0,0 +1,69 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package sinks
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"code.vikunja.io/api/pkg/utils"
)
// Webhook POSTs each entry as a JSON body to a fixed URL.
type Webhook struct {
url string
headers map[string]string
client *http.Client
}
func NewWebhook(url string, headers map[string]string) (*Webhook, error) {
if url == "" {
return nil, fmt.Errorf("webhook forwarder requires a url")
}
return &Webhook{
url: url,
headers: headers,
client: utils.NewSSRFSafeHTTPClient(),
}, nil
}
func (w *Webhook) Write(line []byte) error {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, w.url, bytes.NewReader(line))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Vikunja/audit")
for key, value := range w.headers {
req.Header.Set(key, value)
}
resp, err := w.client.Do(req) // #nosec G704 -- URL is the operator-configured sink target; the SSRF-safe client enforces IP restrictions
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("audit webhook %s returned status %d", w.url, resp.StatusCode)
}
return nil
}

View File

@ -18,7 +18,6 @@ package audit
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@ -26,6 +25,7 @@ import (
"sync"
"time"
"code.vikunja.io/api/pkg/audit/sinks"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
@ -41,9 +41,10 @@ var (
maxSizeBytes int64
maxAge time.Duration
lastSync time.Time
forwarders []sinks.Sink
)
// Init opens the audit log file.
// Init opens the audit log file and sets up the configured forwarders.
// Safe to call again to re-read the config (used by tests).
func Init() error {
mu.Lock()
@ -65,6 +66,13 @@ func Init() error {
return err
}
var err error
forwarders, err = buildForwarders(config.AuditForwarders.Get())
if err != nil {
closeLocked()
return err
}
initialized = true
return nil
}
@ -82,6 +90,7 @@ func closeLocked() {
_ = logFile.Close()
logFile = nil
}
forwarders = nil
initialized = false
}
@ -100,8 +109,74 @@ func openLogFileLocked() error {
return nil
}
// WriteAuditEvent writes one entry to the local audit log. A failed write is
// returned so the event router retries it.
func buildForwarders(raw any) (built []sinks.Sink, err error) {
if raw == nil {
return nil, nil
}
rawList, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("audit.forwarders must be a list, got %T", raw)
}
for i, rawEntry := range rawList {
entry, ok := toStringMap(rawEntry)
if !ok {
return nil, fmt.Errorf("audit.forwarders[%d] must be a map", i)
}
var sink sinks.Sink
typ, _ := entry["type"].(string)
switch typ {
case "stdout":
sink = sinks.NewStdout()
case "syslog":
address, _ := entry["address"].(string)
facility, _ := entry["facility"].(string)
sink, err = sinks.NewSyslog(address, facility)
case "webhook":
targetURL, _ := entry["url"].(string)
headers := map[string]string{}
if rawHeaders, ok := toStringMap(entry["headers"]); ok {
for key, value := range rawHeaders {
headers[key], _ = value.(string)
}
}
sink, err = sinks.NewWebhook(targetURL, headers)
default:
return nil, fmt.Errorf("audit.forwarders[%d] has unknown type %q", i, typ)
}
if err != nil {
return nil, fmt.Errorf("audit.forwarders[%d]: %w", i, err)
}
built = append(built, sink)
}
return built, nil
}
// toStringMap normalizes the two map shapes viper produces depending on the
// config source (file vs. programmatic Set).
func toStringMap(raw any) (map[string]any, bool) {
switch m := raw.(type) {
case map[string]any:
return m, true
case map[any]any:
out := make(map[string]any, len(m))
for key, value := range m {
keyStr, ok := key.(string)
if !ok {
return nil, false
}
out[keyStr] = value
}
return out, true
}
return nil, false
}
// WriteAuditEvent writes one entry to the local audit log and forwards it to
// all configured sinks. The local write is the source of truth — its failure
// is returned so the event router retries; forwarder failures are only
// logged, since a dead sink must not poison-queue every event.
func WriteAuditEvent(entry *Entry) error {
if entry.EventID == "" {
id, err := uuid.NewV7()
@ -133,27 +208,24 @@ func WriteAuditEvent(entry *Entry) error {
return err
}
// A failed rotation can leave us without an open file — retry the open
// here so writes self-heal via the router's retries instead of panicking.
if logFile == nil {
if err := openLogFileLocked(); err != nil {
mu.Unlock()
return err
}
}
written, err := logFile.Write(append(line, '\n'))
currentSize += int64(written)
if err == nil && time.Since(lastSync) > time.Second {
err = logFile.Sync()
lastSync = time.Now()
}
currentForwarders := forwarders
mu.Unlock()
if err != nil {
return fmt.Errorf("could not write audit entry: %w", err)
}
for _, forwarder := range currentForwarders {
if ferr := forwarder.Write(line); ferr != nil {
log.Errorf("Could not forward audit entry %s: %s", entry.EventID, ferr)
}
}
return nil
}
@ -169,9 +241,7 @@ func rotateIfNeededLocked(addition int64) error {
rotatedPath := rotatedFileName(logfilePath, time.Now().UTC())
if err := os.Rename(logfilePath, rotatedPath); err != nil {
// Reopen the original so logging continues even if rotation failed.
if openErr := openLogFileLocked(); openErr != nil {
return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr)
}
_ = openLogFileLocked()
return fmt.Errorf("could not rotate audit log: %w", err)
}

View File

@ -21,9 +21,7 @@ import (
"strings"
"time"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/richtext"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
)
@ -181,18 +179,8 @@ DURATION:PT` + formatDuration(t.Duration)
DTEND:` + makeCalDavTimeFromTimeStamp(t.End)
}
if t.Description != "" {
// CalDAV clients show plain text, so emit markdown. On the near-impossible
// conversion error, log it and keep the stored value (GetContent can't
// return an error) rather than drop the description.
description, err := richtext.HTMLToMarkdown(t.Description)
if err != nil {
log.Errorf("[CALDAV] Failed to convert description to markdown for task %q: %v", t.UID, err)
description = t.Description
}
if description != "" {
caldavtodos += `
DESCRIPTION:` + escapeICalText(description)
}
caldavtodos += `
DESCRIPTION:` + escapeICalText(t.Description)
}
if t.Completed.Unix() > 0 {
caldavtodos += `

View File

@ -47,11 +47,12 @@ func TestParseTodos(t *testing.T) {
},
todos: []*Todo{
{
Summary: "Todo #1",
Description: `<p>Lorem Ipsum</p><p>Dolor sit amet</p>`,
UID: "randommduid",
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
Color: "affffe",
Summary: "Todo #1",
Description: `Lorem Ipsum
Dolor sit amet`,
UID: "randommduid",
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
Color: "affffe",
},
},
},
@ -72,7 +73,7 @@ X-APPLE-CALENDAR-COLOR:#affffeFF
X-OUTLOOK-COLOR:#affffeFF
X-FUNAMBOL-COLOR:#affffeFF
COLOR:#affffeFF
DESCRIPTION:Lorem Ipsum\n\nDolor sit amet
DESCRIPTION:Lorem Ipsum\nDolor sit amet
LAST-MODIFIED:00010101T000000Z
END:VTODO
END:VCALENDAR`,
@ -437,33 +438,6 @@ END:VCALENDAR`,
}
}
func TestParseTodosRichTextDescription(t *testing.T) {
cfg := &Config{Name: "test", ProdID: "Vikunja"}
ts := time.Unix(1543626724, 0).In(config.GetTimeZone())
t.Run("rich html serializes as markdown", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{
Summary: "Todo",
UID: "uid",
Timestamp: ts,
Description: `<p>Hello <strong>bold</strong> and <mention-user data-id="user1" data-label="User One">@User One</mention-user></p>` +
`<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>done</p></div></li></ul>`,
}})
// iCal escapes the markdown's newlines as "\n".
assert.Contains(t, out, `DESCRIPTION:Hello **bold** and @user1\n\n- [x] done`)
})
t.Run("empty html omits the description line", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "<p></p>"}})
assert.NotContains(t, out, "DESCRIPTION:")
})
t.Run("plain text description is unaffected", func(t *testing.T) {
out := ParseTodos(cfg, []*Todo{{Summary: "Todo", UID: "uid", Timestamp: ts, Description: "just plain text"}})
assert.Contains(t, out, "DESCRIPTION:just plain text")
})
}
func TestGetCaldavColor(t *testing.T) {
tests := []struct {
name string

View File

@ -92,15 +92,14 @@ const (
AuthLdapVerifyTLS Key = `auth.ldap.verifytls`
AuthLdapBindDN Key = `auth.ldap.binddn`
// #nosec G101
AuthLdapBindPassword Key = `auth.ldap.bindpassword`
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
AuthLdapGroupSyncUseServiceAccount Key = `auth.ldap.groupsyncuseserviceaccount`
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname`
AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
AuthLdapBindPassword Key = `auth.ldap.bindpassword`
AuthLdapGroupSyncEnabled Key = `auth.ldap.groupsyncenabled`
AuthLdapGroupSyncFilter Key = `auth.ldap.groupsyncfilter`
AuthLdapAvatarSyncAttribute Key = `auth.ldap.avatarsyncattribute`
AuthLdapAttributeUsername Key = `auth.ldap.attribute.username`
AuthLdapAttributeEmail Key = `auth.ldap.attribute.email`
AuthLdapAttributeDisplayname Key = `auth.ldap.attribute.displayname`
AuthLdapAttributeMemberID Key = `auth.ldap.attribute.memberid`
LegalImprintURL Key = `legal.imprinturl`
LegalPrivacyURL Key = `legal.privacyurl`
@ -225,6 +224,7 @@ const (
AuditLogfile Key = `audit.logfile`
AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb`
AuditRotationMaxAge Key = `audit.rotation.maxage`
AuditForwarders Key = `audit.forwarders`
OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips`
OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl`
@ -390,7 +390,6 @@ func InitDefaultConfig() {
AuthLdapVerifyTLS.setDefault(true)
AuthLdapGroupSyncEnabled.setDefault(false)
AuthLdapGroupSyncFilter.setDefault("(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))")
AuthLdapGroupSyncUseServiceAccount.setDefault(false)
AuthLdapAttributeUsername.setDefault("uid")
AuthLdapAttributeEmail.setDefault("mail")
AuthLdapAttributeDisplayname.setDefault("displayName")

View File

@ -127,7 +127,7 @@ func RestoreAndTruncate(table string, contents []map[string]interface{}) (err er
return err
}
} else {
if _, err := x.Query("TRUNCATE TABLE " + x.Quote(table)); err != nil {
if _, err := x.Query("TRUNCATE TABLE ?", table); err != nil {
return err
}
}
@ -148,7 +148,7 @@ func TruncateAllTables() error {
return err
}
} else {
if _, err := x.Query("TRUNCATE TABLE " + x.Quote(name)); err != nil {
if _, err := x.Query("TRUNCATE TABLE ?", name); err != nil {
return err
}
}

View File

@ -41,41 +41,3 @@
created_by_id: 3
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
# Webhooks 6-8 are user-level (project_id null, user_id set) and back the v2
# user-webhook tests. #6/#7 belong to user6; #6 carries credentials so masking
# can be asserted. #8 belongs to user1 so the owner-isolation check (user6 must
# not see or mutate another user's webhook) has a target.
#
# Event choice matters because the pkg/e2etests user-webhook suite shares these
# fixtures and dispatches real events. The WebhookListener fans a fired event out
# to ALL of the event-user's webhooks, asynchronously; a user-level fixture
# subscribed to a user-directed event the suite dispatches for its owner fires a
# real (failing) delivery to example.com, and that in-flight write then races the
# next test's fixture reload ("database table is locked: webhooks"). The suite
# dispatches user-directed events only for user1, so #6/#7 are owned by user6, and
# #8 (owned by user1) subscribes to task.updated — a project-only event the
# listener never matches for user webhooks. None of the three can fire there.
- id: 6
target_url: "https://example.com/user-webhook-fixture"
events: '["task.reminder.fired"]'
user_id: 6
secret: "uwh-secret-fixture"
basic_auth_user: "uwh-basicauth-user"
basic_auth_password: "uwh-basicauth-pass"
created_by_id: 6
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
- id: 7
target_url: "https://example.com/user-webhook-second"
events: '["task.reminder.fired"]'
user_id: 6
created_by_id: 6
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00
- id: 8
target_url: "https://example.com/user-webhook-other"
events: '["task.updated"]'
user_id: 1
created_by_id: 1
created: 2024-01-01 00:00:00
updated: 2024-01-01 00:00:00

View File

@ -76,18 +76,6 @@ func ClearDispatchedEvents() {
dispatchedTestEvents = nil
}
// GetDispatchedEvents returns all dispatched test events matching the given name, letting tests
// assert on the event payload (not just that it was dispatched).
func GetDispatchedEvents(eventName string) []Event {
var events []Event
for _, testEvent := range dispatchedTestEvents {
if testEvent.Name() == eventName {
events = append(events, testEvent)
}
}
return events
}
// CountDispatchedEvents counts how many events of a specific type have been dispatched.
func CountDispatchedEvents(eventName string) int {
count := 0

View File

@ -1,127 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"fmt"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
type taskPosition20260617153629 struct {
TaskID int64 `xorm:"bigint not null index"`
ProjectViewID int64 `xorm:"bigint not null index"`
Position float64 `xorm:"double not null"`
}
func (taskPosition20260617153629) TableName() string {
return "task_positions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260617153629",
Description: "deduplicate task positions and add a unique index on task_id + project_view_id",
Migrate: func(tx *xorm.Engine) error {
s := tx.NewSession()
defer s.Close()
err := s.Begin()
if err != nil {
return err
}
// First remove all duplicate entries. A task may only ever have a
// single position per view; rapid task creation could race and
// insert more than one row before this constraint existed.
duplicates := []taskPosition20260617153629{}
err = s.
Select("task_id, project_view_id").
GroupBy("task_id, project_view_id").
Having("count(*) > 1").
Find(&duplicates)
if err != nil {
_ = s.Rollback()
return err
}
// Keep the lowest position of each group so the result is
// deterministic across databases.
kept := []taskPosition20260617153629{}
for _, dup := range duplicates {
row := taskPosition20260617153629{}
has, err := s.
Where("task_id = ? AND project_view_id = ?", dup.TaskID, dup.ProjectViewID).
OrderBy("position ASC").
Get(&row)
if err != nil {
_ = s.Rollback()
return err
}
if !has {
// The pair was just reported as duplicated by the GroupBy above,
// so a row must exist. If it doesn't, fail instead of continuing —
// the delete loop below would otherwise drop every row for the pair
// without re-inserting one.
_ = s.Rollback()
return fmt.Errorf("no task_positions row found for task %d and project view %d while deduplicating positions", dup.TaskID, dup.ProjectViewID)
}
kept = append(kept, row)
}
for _, dup := range duplicates {
_, err = s.
Where("task_id = ? AND project_view_id = ?", dup.TaskID, dup.ProjectViewID).
Delete(&taskPosition20260617153629{})
if err != nil {
_ = s.Rollback()
return err
}
}
for _, position := range kept {
_, err = s.Insert(&position)
if err != nil {
_ = s.Rollback()
return err
}
}
err = s.Commit()
if err != nil {
return err
}
// Then create the unique index
var query string
switch tx.Dialect().URI().DBType {
case schemas.MYSQL:
query = "CREATE UNIQUE INDEX UQE_task_positions_task_project_view ON task_positions (task_id, project_view_id)"
default:
query = "CREATE UNIQUE INDEX IF NOT EXISTS UQE_task_positions_task_project_view ON task_positions (task_id, project_view_id)"
}
_, err = tx.Exec(query)
return err
},
Rollback: func(_ *xorm.Engine) error {
return nil
},
})
}

View File

@ -1,55 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
// Mirrors models.Session; adds the two columns RP-Initiated Logout needs.
type sessionOIDCLogout20260619155410 struct {
ID string `xorm:"varchar(36) not null unique pk"`
UserID int64 `xorm:"bigint not null index"`
TokenHash string `xorm:"varchar(64) not null unique index"`
DeviceInfo string `xorm:"text"`
IPAddress string `xorm:"varchar(100)"`
IsLongSession bool `xorm:"not null default false"`
OIDCIDToken string `xorm:"text"`
OIDCProviderKey string `xorm:"varchar(250)"`
LastActive time.Time `xorm:"not null"`
Created time.Time `xorm:"created not null"`
}
func (sessionOIDCLogout20260619155410) TableName() string {
return "sessions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260619155410",
Description: "Add oidc_id_token and oidc_provider_key columns to sessions for RP-Initiated Logout",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(sessionOIDCLogout20260619155410{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -1,83 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/license"
"xorm.io/xorm"
)
type ShareCounts struct {
LinkShares int64 `json:"link_shares" readOnly:"true" doc:"Number of link shares across all projects."`
TeamShares int64 `json:"team_shares" readOnly:"true" doc:"Number of team-project shares."`
UserShares int64 `json:"user_shares" readOnly:"true" doc:"Number of user-project shares."`
}
type Overview struct {
Users int64 `json:"users" readOnly:"true" doc:"Total number of user accounts."`
Projects int64 `json:"projects" readOnly:"true" doc:"Total number of projects."`
Tasks int64 `json:"tasks" readOnly:"true" doc:"Total number of tasks."`
Teams int64 `json:"teams" readOnly:"true" doc:"Total number of teams."`
Shares ShareCounts `json:"shares" readOnly:"true" doc:"Aggregate share counts."`
License license.Info `json:"license" readOnly:"true" doc:"Snapshot of the instance license state."`
}
// BuildOverview returns aggregate instance counts plus the current license snapshot.
func BuildOverview(s *xorm.Session) (*Overview, error) {
users, err := s.Table("users").Count()
if err != nil {
return nil, err
}
projects, err := s.Table("projects").Count()
if err != nil {
return nil, err
}
tasks, err := s.Table("tasks").Count()
if err != nil {
return nil, err
}
teams, err := s.Table("teams").Count()
if err != nil {
return nil, err
}
linkShares, err := s.Table("link_shares").Count()
if err != nil {
return nil, err
}
teamShares, err := s.Table("team_projects").Count()
if err != nil {
return nil, err
}
userShares, err := s.Table("users_projects").Count()
if err != nil {
return nil, err
}
return &Overview{
Users: users,
Projects: projects,
Tasks: tasks,
Teams: teams,
Shares: ShareCounts{
LinkShares: linkShares,
TeamShares: teamShares,
UserShares: userShares,
},
License: license.CurrentInfo(),
}, nil
}

View File

@ -1,106 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/user"
"xorm.io/xorm"
)
// loadAdminTargetUser fetches a user by ID for the admin actions, returning
// ErrUserDoesNotExist for an invalid ID or a missing row.
func loadAdminTargetUser(s *xorm.Session, id int64) (*user.User, error) {
if id < 1 {
return nil, user.ErrUserDoesNotExist{UserID: id}
}
target := &user.User{ID: id}
has, err := s.Get(target)
if err != nil {
return nil, err
}
if !has {
return nil, user.ErrUserDoesNotExist{UserID: id}
}
return target, nil
}
// SetUserAdminFlag sets a user's instance-admin flag. Demoting the last
// reachable admin is refused via GuardLastAdmin. It does not commit; the caller
// owns the transaction.
func SetUserAdminFlag(s *xorm.Session, id int64, isAdmin bool) (*user.User, error) {
target, err := loadAdminTargetUser(s, id)
if err != nil {
return nil, err
}
if !isAdmin {
if err := user.GuardLastAdmin(s, target); err != nil {
return nil, err
}
}
target.IsAdmin = isAdmin
if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil {
return nil, err
}
return target, nil
}
// SetUserStatusAsAdmin sets a user's account status. Moving the last reachable
// admin out of Active is refused via GuardLastAdmin (any non-Active status
// blocks login, so it is equivalent to demotion). It does not commit; the caller
// owns the transaction.
func SetUserStatusAsAdmin(s *xorm.Session, id int64, status user.Status) (*user.User, error) {
target, err := loadAdminTargetUser(s, id)
if err != nil {
return nil, err
}
if target.IsAdmin && status != user.StatusActive {
if err := user.GuardLastAdmin(s, target); err != nil {
return nil, err
}
}
if err := user.SetUserStatus(s, target, status); err != nil {
return nil, err
}
// Reflect the change on the returned struct; GetUserByID refuses disabled accounts.
target.Status = status
return target, nil
}
// DeleteUserAsAdmin removes a user. mode "now" deletes immediately; any other
// value triggers the email-confirmation self-deletion flow. Deleting the last
// reachable admin is refused via GuardLastAdmin. It does not commit; the caller
// owns the transaction.
func DeleteUserAsAdmin(s *xorm.Session, id int64, mode string) error {
target, err := loadAdminTargetUser(s, id)
if err != nil {
return err
}
if err := user.GuardLastAdmin(s, target); err != nil {
return err
}
if mode == "now" {
return DeleteUser(s, target)
}
return user.RequestDeletion(s, target)
}

View File

@ -1,80 +0,0 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"xorm.io/xorm"
)
// CreateUserBody wraps user.APIUserPassword with admin-only fields.
type CreateUserBody struct {
// The full name of the new user. Optional.
Name string `json:"name" doc:"The full name of the new user. Optional."`
// The language of the new user. Must be a valid IETF BCP 47 language code and exist in Vikunja.
Language string `json:"language" valid:"language" doc:"IETF BCP 47 language code; must exist in Vikunja."`
user.APIUserPassword
// Mark the new user as an instance admin.
IsAdmin bool `json:"is_admin" doc:"Mark the new user as an instance admin."`
// Activate the new user immediately without email confirmation.
SkipEmailConfirm bool `json:"skip_email_confirm" doc:"Activate the new user immediately, skipping email confirmation."`
}
// CreateUserAsAdmin provisions a new local account on behalf of an instance admin,
// honouring the admin-only is_admin and skip_email_confirm fields and bypassing the
// public-registration toggle. It commits s and returns the persisted user reloaded
// so the status reflects what was actually stored.
func CreateUserAsAdmin(s *xorm.Session, body *CreateUserBody) (*user.User, error) {
newUser, err := RegisterUser(s, &user.User{
Username: body.Username,
Password: body.Password,
Email: body.Email,
Name: body.Name,
Language: body.Language,
})
if err != nil {
return nil, err
}
if body.IsAdmin {
if _, err := s.ID(newUser.ID).Cols("is_admin").Update(&user.User{IsAdmin: true}); err != nil {
return nil, err
}
newUser.IsAdmin = true
}
// Force Active when the admin asked to skip, or when no mailer exists to send the confirmation.
if body.SkipEmailConfirm || !config.MailerEnabled.GetBool() {
if err := user.SetUserStatus(s, newUser, user.StatusActive); err != nil {
return nil, err
}
newUser.Status = user.StatusActive
}
if err := s.Commit(); err != nil {
return nil, err
}
// Reload on a fresh session so the returned status reflects what was actually
// persisted (e.g. StatusEmailConfirmationRequired on mail-enabled instances).
rs := db.NewSession()
defer rs.Close()
return user.GetUserByID(rs, newUser.ID)
}

View File

@ -74,13 +74,6 @@ func stripAPIVersion(path string) string {
return path
}
// canonicalAPITokenGroup snake_cases a permission group name. The frontend
// snake_cases request payloads, so a hyphenated group slug (e.g. from
// /api/v2/time-entries) can't round-trip and fails validation on save.
func canonicalAPITokenGroup(group string) string {
return strings.ReplaceAll(group, "-", "_")
}
func getRouteGroupName(path string) (finalName string, filteredParts []string) {
parts := strings.Split(stripAPIVersion(path), "/")
filteredParts = []string{}
@ -89,7 +82,7 @@ func getRouteGroupName(path string) (finalName string, filteredParts []string) {
continue
}
filteredParts = append(filteredParts, canonicalAPITokenGroup(part))
filteredParts = append(filteredParts, part)
}
finalName = strings.Join(filteredParts, "_")
@ -190,7 +183,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
"comments": true,
"relations": true,
"attachments": true,
"time_entries": true,
"time-entries": true,
"projects_views": true,
"projects_teams": true,
"projects_users": true,
@ -410,8 +403,7 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
}
method := c.Request().Method
for rawGroup, perms := range token.APIPermissions {
group := canonicalAPITokenGroup(rawGroup)
for group, perms := range token.APIPermissions {
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
for _, routes := range tables {
if routes == nil {
@ -435,8 +427,7 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
// Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks" ||
path == "/api/v2/tasks" || path == "/api/v2/projects/:project/tasks") {
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
return true
}
}
@ -456,9 +447,8 @@ func PermissionsAreValid(permissions APIPermissions) (err error) {
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
// validating against the union lets tokens grant them. CanDoAPIRoute
// already consults both tables when authorising.
group := canonicalAPITokenGroup(key)
v1Routes := apiTokenRoutes[group]
v2Routes := apiTokenRoutesV2[group]
v1Routes := apiTokenRoutes[key]
v2Routes := apiTokenRoutesV2[key]
if v1Routes == nil && v2Routes == nil {
return &ErrInvalidAPITokenPermission{
Group: key,

View File

@ -121,9 +121,9 @@ func TestCollectRoutesV2(t *testing.T) {
assert.Equal(t, "DELETE", labels["delete"].Method)
}
// TestCollectRoutes_TimeEntriesV2 pins the v2-only time-entries resource to a
// snake_case "time_entries" group (not the "other" catch-all, not a hyphenated
// key the frontend's snake_case transform would mangle on save).
// TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource
// lands under a clean "time-entries" group rather than the "other" catch-all,
// so its scopes read sensibly for token clients.
func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
@ -137,11 +137,8 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
_, isOther := apiTokenRoutesV2["other"]
assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket")
_, hyphenated := apiTokenRoutesV2["time-entries"]
assert.False(t, hyphenated, "group key must be canonicalised to snake_case")
te, has := apiTokenRoutesV2["time_entries"]
require.True(t, has, "time_entries group should exist in the v2 table")
te, has := apiTokenRoutesV2["time-entries"]
require.True(t, has, "time-entries group should exist in the v2 table")
assert.Equal(t, "GET", te["read_all"].Method)
assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path)
assert.Equal(t, "GET", te["read_one"].Method)
@ -151,7 +148,7 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
}
// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges
// v2-only groups (time_entries has no v1 counterpart) so token clients can
// v2-only groups (time-entries has no v1 counterpart) so token clients can
// discover and grant them, without mutating the v1 table itself.
func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
@ -165,35 +162,14 @@ func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
_, hasLabels := routes["labels"]
assert.True(t, hasLabels, "v1 groups stay exposed")
te, hasTE := routes["time_entries"]
require.True(t, hasTE, "v2-only time_entries must be exposed via /routes")
te, hasTE := routes["time-entries"]
require.True(t, hasTE, "v2-only time-entries must be exposed via /routes")
assert.Equal(t, "GET", te["read_all"].Method)
_, v1HasTE := apiTokenRoutes["time_entries"]
_, v1HasTE := apiTokenRoutes["time-entries"]
assert.False(t, v1HasTE, "the merge must not mutate the v1 table")
}
// TestCanDoAPIRoute_TimeEntriesHyphenLegacy proves a token stored under the old
// hyphenated "time-entries" key still validates and authorises — no migration.
func TestCanDoAPIRoute_TimeEntriesHyphenLegacy(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true)
for _, key := range []string{"time_entries", "time-entries"} {
t.Run(key, func(t *testing.T) {
perms := APIPermissions{key: []string{"read_all"}}
require.NoError(t, PermissionsAreValid(perms), "%s must validate", key)
token := &APIToken{APIPermissions: perms}
req := httptest.NewRequest("GET", "/api/v2/time-entries", nil)
c := echo.New().NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token), "%s must authorise", key)
})
}
}
// TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create,
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
// path.
@ -270,40 +246,6 @@ func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
})
}
// TestCanDoAPIRoute_V2TasksReadAll verifies that tasks.read_all authorises
// both the global /api/v2/tasks and project-scoped /api/v2/projects/:project/tasks
// endpoints. Both normalise to tasks.read_all via getRouteGroupName, but only
// one RouteDetail survives in the map — the special case in CanDoAPIRoute must
// accept either path.
func TestCanDoAPIRoute_V2TasksReadAll(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
}
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/tasks"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/projects/:project/tasks"}, true)
token := &APIToken{
APIPermissions: APIPermissions{"tasks": []string{"read_all"}},
}
e := echo.New()
t.Run("global /api/v2/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
t.Run("project-scoped /api/v2/projects/:project/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/projects/:project/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
}
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
// scenarios in that file) which exercises the full auth pipeline.

View File

@ -535,34 +535,6 @@ func (err *ErrProjectViewDoesNotExist) HTTPError() web.HTTPError {
}
}
// ErrProjectHasNoBackground represents an error where a project has no background set.
type ErrProjectHasNoBackground struct {
ProjectID int64
}
// IsErrProjectHasNoBackground checks if an error is ErrProjectHasNoBackground.
func IsErrProjectHasNoBackground(err error) bool {
_, ok := err.(*ErrProjectHasNoBackground)
return ok
}
func (err *ErrProjectHasNoBackground) Error() string {
return fmt.Sprintf("Project has no background [ProjectID: %d]", err.ProjectID)
}
// ErrCodeProjectHasNoBackground holds the unique world-error code of this error
const ErrCodeProjectHasNoBackground = 3015
// HTTPError holds the http error description
func (err *ErrProjectHasNoBackground) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeProjectHasNoBackground,
// Message kept verbatim from v1's inline handler error so the wire body is unchanged.
Message: "Project background not found",
}
}
// ==============
// Task errors
// ==============
@ -2624,32 +2596,3 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
Message: "A time entry's end time cannot be before its start time.",
}
}
// =================
// User export errors
// =================
// ErrUserDataExportDoesNotExist represents an error where a user has no ready data export to download.
type ErrUserDataExportDoesNotExist struct{}
// IsErrUserDataExportDoesNotExist checks if an error is ErrUserDataExportDoesNotExist.
func IsErrUserDataExportDoesNotExist(err error) bool {
_, ok := err.(ErrUserDataExportDoesNotExist)
return ok
}
func (err ErrUserDataExportDoesNotExist) Error() string {
return "No user data export found"
}
// ErrCodeUserDataExportDoesNotExist holds the unique world-error code of this error
const ErrCodeUserDataExportDoesNotExist = 19001
// HTTPError holds the http error description
func (err ErrUserDataExportDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeUserDataExportDoesNotExist,
Message: "No user data export found.",
}
}

View File

@ -18,6 +18,7 @@ package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
)
/////////////////
@ -229,8 +230,8 @@ func (l *ProjectCreatedEvent) Name() string {
// ProjectUpdatedEvent represents an event where a project has been updated
type ProjectUpdatedEvent struct {
Project *Project `json:"project"`
Doer *user.User `json:"doer"`
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectUpdatedEvent
@ -240,8 +241,8 @@ func (p *ProjectUpdatedEvent) Name() string {
// ProjectDeletedEvent represents an event where a project has been deleted
type ProjectDeletedEvent struct {
Project *Project `json:"project"`
Doer *user.User `json:"doer"`
Project *Project `json:"project"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectDeletedEvent
@ -257,7 +258,7 @@ func (p *ProjectDeletedEvent) Name() string {
type ProjectSharedWithUserEvent struct {
Project *Project `json:"project"`
User *user.User `json:"user"`
Doer *user.User `json:"doer"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectSharedWithUserEvent
@ -267,9 +268,9 @@ func (p *ProjectSharedWithUserEvent) Name() string {
// ProjectSharedWithTeamEvent represents an event where a project has been shared with a team
type ProjectSharedWithTeamEvent struct {
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for ProjectSharedWithTeamEvent
@ -307,8 +308,8 @@ func (t *TeamMemberRemovedEvent) Name() string {
// TeamCreatedEvent represents a TeamCreatedEvent event
type TeamCreatedEvent struct {
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for TeamCreatedEvent
@ -318,8 +319,8 @@ func (t *TeamCreatedEvent) Name() string {
// TeamDeletedEvent represents a TeamDeletedEvent event
type TeamDeletedEvent struct {
Team *Team `json:"team"`
Doer *user.User `json:"doer"`
Team *Team `json:"team"`
Doer web.Auth `json:"doer"`
}
// Name defines the name for TeamDeletedEvent

View File

@ -404,64 +404,6 @@ func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (er
return utils.WriteFilesToZip(backgroundFiles, wr)
}
// GetUserDataExportFile loads the user's ready data export with its bytes open for
// reading. It returns ErrUserDataExportDoesNotExist when the user never requested an
// export or the underlying file is gone. The caller must close the returned reader.
func GetUserDataExportFile(u *user.User) (*files.File, error) {
if u.ExportFileID == 0 {
return nil, ErrUserDataExportDoesNotExist{}
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
if files.IsErrFileDoesNotExist(err) {
return nil, ErrUserDataExportDoesNotExist{}
}
return nil, err
}
if err := exportFile.LoadFileByID(); err != nil {
if os.IsNotExist(err) {
return nil, ErrUserDataExportDoesNotExist{}
}
return nil, err
}
return exportFile, nil
}
// GetUserDataExportStatus returns metadata about the user's current data export, or
// nil when none exists. The expiry mirrors the cleanup cron's 7-day retention.
func GetUserDataExportStatus(u *user.User) (*UserExportStatus, error) {
if u.ExportFileID == 0 {
return nil, nil
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
// A missing meta row means there is no export — mirror the download path
// (404 there) instead of surfacing a 500.
if files.IsErrFileDoesNotExist(err) {
return nil, nil
}
return nil, err
}
return &UserExportStatus{
ID: exportFile.ID,
Size: exportFile.Size,
Created: exportFile.Created,
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
}, nil
}
// UserExportStatus is the metadata returned for a user's current data export.
type UserExportStatus struct {
ID int64 `json:"id" readOnly:"true" doc:"The id of the export file."`
Size uint64 `json:"size" readOnly:"true" doc:"The size of the export file in bytes."`
Created time.Time `json:"created" readOnly:"true" doc:"When the export was created."`
Expires time.Time `json:"expires" readOnly:"true" doc:"When the export will be automatically deleted (7 days after creation)."`
}
func RegisterOldExportCleanupCron() {
const logPrefix = "[User Export Cleanup Cron] "

Some files were not shown because too many files have changed in this diff Show More