From f819b685d8c96019526ebc7ece8feac5cc862e17 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Fri, 12 Jun 2026 00:35:31 +0000 Subject: [PATCH 001/111] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/uk-UA.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/lang/uk-UA.json b/frontend/src/i18n/lang/uk-UA.json index aff96ecfa..d92642a0d 100644 --- a/frontend/src/i18n/lang/uk-UA.json +++ b/frontend/src/i18n/lang/uk-UA.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "YYYY/MM/DD" }, "timeFormat": "Формат часу", + "timeTrackingDefaultStart": "Початковий час для автоматичного заповнення обліку часу", "timeFormatOptions": { "12h": "12-годинний (AM/PM)", "24h": "24-годинний (HH:mm)" @@ -781,7 +782,10 @@ "closeDialog": "Закрити діалог", "closeQuickActions": "Закрити швидкі дії", "skipToContent": "Перейти до основного вмісту", - "sortBy": "Сортувати за" + "sortBy": "Сортувати за", + "dateRange": "Діапазон дат", + "notSet": "Не встановлено", + "user": "Користувач" }, "input": { "projectColor": "Колір проєкту", @@ -991,6 +995,7 @@ "repeatAfter": "Повторювати", "percentDone": "Встановити прогрес", "attachments": "Вкласти", + "timeTracking": "Відстежити час", "relatedTasks": "Пов'язати", "moveProject": "Перемістити", "duplicate": "Дублювати", @@ -1146,6 +1151,7 @@ "repeat": { "everyDay": "Щодня", "everyWeek": "Щотижня", + "every30d": "Кожні 30 днів", "mode": "Спосіб", "monthly": "Щомісяця", "fromCurrentDate": "З дня закінчення", @@ -1459,6 +1465,24 @@ "frontendVersion": "Версія інтерфейсу: {version}", "apiVersion": "API версія: {version}" }, + "timeTracking": { + "title": "Відстеження часу", + "stop": "Зупинити таймер", + "logTime": "Записати час", + "editEntry": "Редагувати запис", + "form": { + "task": "Завдання", + "taskSearch": "Знайти завдання…", + "commentPlaceholder": "Над чим ви працювали?", + "save": "Зберегти запис", + "startTimer": "Запустити таймер", + "update": "Оновити запис", + "smartFill": "Заповнити з останнього запису" + }, + "list": { + "emptyTask": "Для цього завдання ще немає записів обліку часу." + } + }, "time": { "units": { "seconds": "секунда|секунд(и)", From 8ff46967864f75157a0740b4251021eac5f74483 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 22:31:30 +0200 Subject: [PATCH 002/111] fix(frontend): restore quick actions menu styling and height limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick actions menu (cmd+k) rendered without any background and grew beyond the viewport: - Its card visuals came from the global Bulma .card styles, which were dropped when Card.vue got its own scoped copy — QuickActions is the only place using a bare class="card" div, so it lost background, border and shadow. Give it its own card styles. - Its height limit came from Bulma's .modal-content max-height, lost when the Bulma modal import was dropped in the native-dialog refactor. The :deep(.modal-content) position override in QuickActions never matched (.modal-content is an ancestor of the scoped selector, not a descendant). Replace both with a proper `top` modal variant that anchors the content 3rem below the top edge and caps its height, resolving the FIXME asking for exactly that option. - The dark scrim never showed: Chromium intermittently stops painting a styled ::backdrop (after subtree re-renders, or while display is transitioned) even though getComputedStyle reports the color. Move the scrim onto the viewport-filling dialog element itself — same as the old div-based .modal-mask — and drop the display/allow-discrete transitions, which the JS-timed close fade never needed. --- frontend/src/components/misc/Modal.vue | 55 ++++++++++++++----- .../components/quick-actions/QuickActions.vue | 14 +++-- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/misc/Modal.vue b/frontend/src/components/misc/Modal.vue index 5a3fedc98..b654e0268 100644 --- a/frontend/src/components/misc/Modal.vue +++ b/frontend/src/components/misc/Modal.vue @@ -68,7 +68,7 @@ const props = withDefaults(defineProps<{ enabled?: boolean, overflow?: boolean, wide?: boolean, - variant?: 'default' | 'hint-modal' | 'scrolling', + variant?: 'default' | 'hint-modal' | 'scrolling' | 'top', }>(), { enabled: true, overflow: false, @@ -211,7 +211,13 @@ $modal-width: 1024px; // Reset UA dialog styles padding: 0; border: none; - background: transparent; + // 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); color: #ffffff; // Fill viewport position: fixed; @@ -221,10 +227,12 @@ $modal-width: 1024px; max-inline-size: 100%; max-block-size: 100%; - // Transitions + // 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. opacity: 0; - transition: opacity 150ms ease, - display 150ms ease allow-discrete; + transition: opacity 150ms ease; &[open]:not([data-closing]) { opacity: 1; @@ -236,16 +244,11 @@ $modal-width: 1024px; &::backdrop { background-color: rgba(0, 0, 0, 0); - transition: background-color 150ms ease, - display 150ms ease allow-discrete; } - &[open]:not([data-closing])::backdrop { - background-color: rgba(0, 0, 0, .8); - - @starting-style { - background-color: rgba(0, 0, 0, 0); - } + // in quick-add mode the Electron window itself is the overlay — no scrim + &:has(.is-quick-add-mode) { + background: transparent; } } @@ -261,7 +264,8 @@ $modal-width: 1024px; } .default .modal-content, -.hint-modal .modal-content { +.hint-modal .modal-content, +.top .modal-content { text-align: center; position: absolute; // fine to use top/left since we're only using this to position it centered @@ -289,11 +293,31 @@ $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) { +.hint-modal .modal-content:not(.is-wide), +.top .modal-content:not(.is-wide) { inline-size: calc(100% - 2rem); max-inline-size: 640px; @@ -403,6 +427,7 @@ $modal-width: 1024px; block-size: auto; max-inline-size: none; max-block-size: none; + background: transparent; &::backdrop { display: none; diff --git a/frontend/src/components/quick-actions/QuickActions.vue b/frontend/src/components/quick-actions/QuickActions.vue index 13605fe4f..3656d5e89 100644 --- a/frontend/src/components/quick-actions/QuickActions.vue +++ b/frontend/src/components/quick-actions/QuickActions.vue @@ -2,6 +2,7 @@
.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; From eac1fa272634525cb61203bd29f480a908d56922 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:42:34 +0200 Subject: [PATCH 003/111] refactor(auth): extract shared auth/token business logic for v2 reuse Pull the HTTP-independent core out of the v1 auth handlers so both /api/v1 and the upcoming /api/v2 routes share one implementation: - oauth2server: ExchangeToken and Authorize take plain inputs and return typed responses; HandleToken/HandleAuthorize keep binding + headers. - pkg/routes/api/shared: AuthenticateLinkShare, RegisterUser, ResetPassword (+ session clear), RequestPasswordResetToken and ConfirmEmail, plus the shared UserRegister and LinkShareToken types. v1 handlers now delegate to these; their wire output is unchanged. --- pkg/modules/auth/oauth2server/authorize.go | 60 +++++--- pkg/modules/auth/oauth2server/token.go | 74 +++++---- pkg/routes/api/shared/auth.go | 171 +++++++++++++++++++++ pkg/routes/api/v1/link_sharing_auth.go | 45 +----- pkg/routes/api/v1/user_confirm_email.go | 15 +- pkg/routes/api/v1/user_password_reset.go | 32 +--- pkg/routes/api/v1/user_register.go | 37 +---- 7 files changed, 267 insertions(+), 167 deletions(-) create mode 100644 pkg/routes/api/shared/auth.go diff --git a/pkg/modules/auth/oauth2server/authorize.go b/pkg/modules/auth/oauth2server/authorize.go index 873c00900..96afbbad7 100644 --- a/pkg/modules/auth/oauth2server/authorize.go +++ b/pkg/modules/auth/oauth2server/authorize.go @@ -26,8 +26,8 @@ import ( "github.com/labstack/echo/v5" ) -// authorizeRequest represents the JSON body for the authorize endpoint. -type authorizeRequest struct { +// AuthorizeRequest represents the body for the authorize endpoint. +type AuthorizeRequest struct { ResponseType string `json:"response_type"` ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` @@ -47,54 +47,66 @@ type AuthorizeResponse struct { // It validates the OAuth parameters, creates an authorization code, and // returns it as JSON. Authentication is handled by the token middleware. func HandleAuthorize(c *echo.Context) error { - var req authorizeRequest + var req AuthorizeRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - // Validate response_type - if req.ResponseType != "code" { - return echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") - } - - // Validate redirect_uri - if !ValidateRedirectURI(req.RedirectURI) { - return &models.ErrOAuthInvalidRedirectURI{} - } - - // Validate PKCE (required) - if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { - return &models.ErrOAuthMissingPKCE{} - } - // Get the authenticated user from the middleware u, err := user.GetCurrentUser(c) if err != nil { return err } + resp, err := Authorize(&req, u.ID) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, resp) +} + +// Authorize validates the OAuth authorization parameters for the given +// authenticated user and creates a single-use authorization code, independent +// of the HTTP layer. Callers own request binding and resolving the user. +func Authorize(req *AuthorizeRequest, userID int64) (*AuthorizeResponse, error) { + // Validate response_type + if req.ResponseType != "code" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "response_type must be 'code'") + } + + // Validate redirect_uri + if !ValidateRedirectURI(req.RedirectURI) { + return nil, &models.ErrOAuthInvalidRedirectURI{} + } + + // Validate PKCE (required) + if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" { + return nil, &models.ErrOAuthMissingPKCE{} + } + s := db.NewSession() defer s.Close() - fullUser, err := user.GetUserByID(s, u.ID) + fullUser, err := user.GetUserByID(s, userID) if err != nil { _ = s.Rollback() - return err + return nil, err } code, err := models.CreateOAuthCode(s, fullUser.ID, req.ClientID, req.RedirectURI, req.CodeChallenge, req.CodeChallengeMethod) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - return c.JSON(http.StatusOK, AuthorizeResponse{ + return &AuthorizeResponse{ Code: code, RedirectURI: req.RedirectURI, State: req.State, - }) + }, nil } diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 2725b988d..9d8d33a9a 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -36,35 +36,51 @@ type TokenResponse struct { RefreshToken string `json:"refresh_token"` } -// tokenRequest holds the JSON body of a POST /oauth/token request. -type tokenRequest struct { - GrantType string `json:"grant_type"` - Code string `json:"code"` - ClientID string `json:"client_id"` - RedirectURI string `json:"redirect_uri"` - CodeVerifier string `json:"code_verifier"` - RefreshToken string `json:"refresh_token"` +// TokenRequest holds the parameters of a POST /oauth/token request. v1 binds it +// from JSON; v2 accepts spec-compliant application/x-www-form-urlencoded as well +// (form tags mirror the json names). +type TokenRequest struct { + GrantType string `json:"grant_type" form:"grant_type"` + Code string `json:"code" form:"code"` + ClientID string `json:"client_id" form:"client_id"` + RedirectURI string `json:"redirect_uri" form:"redirect_uri"` + CodeVerifier string `json:"code_verifier" form:"code_verifier"` + RefreshToken string `json:"refresh_token" form:"refresh_token"` } // HandleToken handles POST /oauth/token. // Supports grant_type=authorization_code and grant_type=refresh_token. func HandleToken(c *echo.Context) error { - var req tokenRequest + var req TokenRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } + resp, err := ExchangeToken(&req, c.Request().UserAgent(), c.RealIP()) + if err != nil { + return err + } + + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, resp) +} + +// ExchangeToken runs the grant-type dispatch and token issuance for the OAuth +// token endpoint, independent of the HTTP layer. Callers own request binding and +// the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded +// on the session created for the authorization_code grant. +func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return handleAuthorizationCodeGrant(c, &req) + return exchangeAuthorizationCode(req, deviceInfo, ipAddress) case "refresh_token": - return handleRefreshTokenGrant(c, &req) + return exchangeRefreshToken(req) default: - return &models.ErrOAuthInvalidGrantType{} + return nil, &models.ErrOAuthInvalidGrantType{} } } -func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { +func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -72,73 +88,69 @@ func handleAuthorizationCodeGrant(c *echo.Context, req *tokenRequest) error { oauthCode, err := models.GetAndDeleteOAuthCode(s, req.Code) if err != nil { _ = s.Rollback() - return err + return nil, err } // Validate client_id matches if oauthCode.ClientID != req.ClientID { _ = s.Rollback() - return &models.ErrOAuthClientNotFound{} + return nil, &models.ErrOAuthClientNotFound{} } // Validate redirect_uri matches if oauthCode.RedirectURI != req.RedirectURI { _ = s.Rollback() - return &models.ErrOAuthInvalidRedirectURI{} + return nil, &models.ErrOAuthInvalidRedirectURI{} } // Verify PKCE if !VerifyPKCE(req.CodeVerifier, oauthCode.CodeChallenge, oauthCode.CodeChallengeMethod) { _ = s.Rollback() - return &models.ErrOAuthPKCEVerifyFailed{} + return nil, &models.ErrOAuthPKCEVerifyFailed{} } // Create a session (reuses existing session infrastructure) - deviceInfo := c.Request().UserAgent() - ipAddress := c.RealIP() session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false) if err != nil { _ = s.Rollback() - return err + return nil, err } u, err := user.GetUserByID(s, oauthCode.UserID) if err != nil { _ = s.Rollback() - return err + return nil, err } // Generate JWT accessToken, err := auth.NewUserJWTAuthtoken(u, session.ID) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + return &TokenResponse{ AccessToken: accessToken, TokenType: "bearer", ExpiresIn: config.ServiceJWTTTLShort.GetInt64(), RefreshToken: session.RefreshToken, - }) + }, nil } -func handleRefreshTokenGrant(c *echo.Context, req *tokenRequest) error { +func exchangeRefreshToken(req *TokenRequest) (*TokenResponse, error) { result, err := auth.RefreshSession(req.RefreshToken) if err != nil { - return err + return nil, err } - c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, TokenResponse{ + return &TokenResponse{ AccessToken: result.AccessToken, TokenType: "bearer", ExpiresIn: result.ExpiresIn, RefreshToken: result.NewRefreshToken, - }) + }, nil } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go new file mode 100644 index 000000000..d11a1cdbb --- /dev/null +++ b/pkg/routes/api/shared/auth.go @@ -0,0 +1,171 @@ +// 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 . + +package shared + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/metrics" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" +) + +// UserRegister carries the fields accepted by the public registration endpoint: +// username, password and email (from APIUserPassword) plus the new user's +// preferred language. +type UserRegister struct { + // 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:"The language of the new user as an IETF BCP 47 code (e.g. en, de-DE)."` + user.APIUserPassword +} + +// RegisterUser creates a new local user account from the registration input and +// busts the cached user-count metric so the registration shows up immediately. +// The caller is responsible for the registration-enabled gate and input +// validation; both v1 and v2 share this body. +func RegisterUser(in *UserRegister) (*user.User, error) { + s := db.NewSession() + defer s.Close() + + newUser, err := models.RegisterUser(s, &user.User{ + Username: in.Username, + Password: in.Password, + Email: in.Email, + Language: in.Language, + }) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + // Bust the cached user count so the new registration shows up in metrics + // immediately instead of after the regular cache expiry. + if config.MetricsEnabled.GetBool() { + if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { + log.Errorf("Could not invalidate user count metric: %s", err) + } + } + + return newUser, nil +} + +// ResetPassword resets a user's password from a previously issued reset token +// and invalidates all of that user's sessions, so a leaked password cannot be +// used after a reset. Shared by v1 and v2. +func ResetPassword(reset *user.PasswordReset) error { + s := db.NewSession() + defer s.Close() + + userID, err := user.ResetPassword(s, reset) + if err != nil { + _ = s.Rollback() + return err + } + + if err := models.DeleteAllUserSessions(s, userID); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// RequestPasswordResetToken issues a password-reset token for the account with +// the given email and sends it via email. Shared by v1 and v2. +func RequestPasswordResetToken(req *user.PasswordTokenRequest) error { + s := db.NewSession() + defer s.Close() + + if err := user.RequestUserPasswordResetTokenByEmail(s, req); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// ConfirmEmail confirms a newly registered user's email from the token sent to +// them. Shared by v1 and v2. +func ConfirmEmail(confirm *user.EmailConfirm) error { + s := db.NewSession() + defer s.Close() + + if err := user.ConfirmEmail(s, confirm); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + +// LinkShareToken is the response for the link-share auth endpoint. It embeds the +// authenticated share alongside the issued JWT and re-exposes the project id +// (which LinkSharing hides with json:"-"). The embedded share's write-only +// Password is blanked by AuthenticateLinkShare before this is returned. +type LinkShareToken struct { + auth.Token + *models.LinkSharing + ProjectID int64 `json:"project_id" readOnly:"true" doc:"The id of the project this share grants access to."` +} + +// AuthenticateLinkShare resolves a link share by its public hash, verifies the +// password for password-protected shares, and issues a JWT auth token for it. +// The returned token's embedded share has its password blanked. Shared by v1 +// and v2. +func AuthenticateLinkShare(hash, password string) (*LinkShareToken, error) { + s := db.NewSession() + defer s.Close() + + share, err := models.GetLinkShareByHash(s, hash) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if share.SharingType == models.SharingTypeWithPassword { + if err := models.VerifyLinkSharePassword(share, password); err != nil { + _ = s.Rollback() + return nil, err + } + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + share.Password = "" + + return &LinkShareToken{ + Token: auth.Token{Token: t}, + LinkSharing: share, + ProjectID: share.ProjectID, + }, nil +} diff --git a/pkg/routes/api/v1/link_sharing_auth.go b/pkg/routes/api/v1/link_sharing_auth.go index 9e20a94f8..f4ca79ed0 100644 --- a/pkg/routes/api/v1/link_sharing_auth.go +++ b/pkg/routes/api/v1/link_sharing_auth.go @@ -19,20 +19,11 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/routes/api/shared" - "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/auth" "github.com/labstack/echo/v5" ) -// LinkShareToken represents a link share auth token with extra infos about the actual link share -type LinkShareToken struct { - auth.Token - *models.LinkSharing - ProjectID int64 `json:"project_id"` -} - // LinkShareAuth represents everything required to authenticate a link share type LinkShareAuth struct { Hash string `param:"share" json:"-"` @@ -53,36 +44,14 @@ type LinkShareAuth struct { // @Router /shares/{share}/auth [post] func AuthenticateLinkShare(c *echo.Context) error { sh := &LinkShareAuth{} - err := c.Bind(sh) + if err := c.Bind(sh); err != nil { + return err + } + + token, err := shared.AuthenticateLinkShare(sh.Hash, sh.Password) if err != nil { return err } - s := db.NewSession() - defer s.Close() - - share, err := models.GetLinkShareByHash(s, sh.Hash) - if err != nil { - return err - } - - if share.SharingType == models.SharingTypeWithPassword { - err := models.VerifyLinkSharePassword(share, sh.Password) - if err != nil { - return err - } - } - - t, err := auth.NewLinkShareJWTAuthtoken(share) - if err != nil { - return err - } - - share.Password = "" - - return c.JSON(http.StatusOK, LinkShareToken{ - Token: auth.Token{Token: t}, - LinkSharing: share, - ProjectID: share.ProjectID, - }) + return c.JSON(http.StatusOK, token) } diff --git a/pkg/routes/api/v1/user_confirm_email.go b/pkg/routes/api/v1/user_confirm_email.go index e01865103..254d4142a 100644 --- a/pkg/routes/api/v1/user_confirm_email.go +++ b/pkg/routes/api/v1/user_confirm_email.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -44,17 +43,7 @@ func UserConfirmEmail(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No token provided.").Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.ConfirmEmail(s, &emailConfirm) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ConfirmEmail(&emailConfirm); err != nil { return err } diff --git a/pkg/routes/api/v1/user_password_reset.go b/pkg/routes/api/v1/user_password_reset.go index b91a28a7a..6c8090ba0 100644 --- a/pkg/routes/api/v1/user_password_reset.go +++ b/pkg/routes/api/v1/user_password_reset.go @@ -19,9 +19,8 @@ package v1 import ( "net/http" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" ) @@ -49,22 +48,7 @@ func UserResetPassword(c *echo.Context) error { return err } - s := db.NewSession() - defer s.Close() - - userID, err := user.ResetPassword(s, &pwReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, userID); err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.ResetPassword(&pwReset); err != nil { return err } @@ -93,17 +77,7 @@ func UserRequestResetPasswordToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()).Wrap(err) } - s := db.NewSession() - defer s.Close() - - err := user.RequestUserPasswordResetTokenByEmail(s, &pwTokenReset) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.RequestPasswordResetToken(&pwTokenReset); err != nil { return err } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 9db52c88a..1c70df765 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -21,20 +21,15 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/log" - "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) -type UserRegister struct { - // 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"` - user.APIUserPassword -} +// UserRegister is an alias for the shared registration input, kept so the v1 +// swagger annotation and any existing imports still resolve. +type UserRegister = shared.UserRegister // RegisterUser is the register handler // @Summary Register @@ -68,32 +63,10 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - s := db.NewSession() - defer s.Close() - - newUser, err := models.RegisterUser(s, &user.User{ - Username: userIn.Username, - Password: userIn.Password, - Email: userIn.Email, - Language: userIn.Language, - }) + newUser, err := shared.RegisterUser(userIn) if err != nil { - _ = s.Rollback() return err } - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - // Bust the cached user count so the new registration shows up in metrics - // immediately instead of after the regular cache expiry. - if config.MetricsEnabled.GetBool() { - if err := metrics.InvalidateCount(metrics.UserCountKey); err != nil { - log.Errorf("Could not invalidate user count metric: %s", err) - } - } - return c.JSON(http.StatusOK, newUser) } From 37a174b99e34826f24b8f8919e9e40e8fbaaa072 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:44:03 +0200 Subject: [PATCH 004/111] feat(api/v2): add public auth routes (register, password, confirm, link-share) Port the unauthenticated local-account flows and link-share auth to /api/v2, delegating to the shared business logic: - POST /register (404 when registration is disabled) - POST /user/password/token, POST /user/password/reset - POST /user/confirm - POST /shares/{share}/auth Local-account routes register only when local auth is enabled and the link-share route only when link sharing is enabled, mirroring v1. Each operation opts out of global auth and its path is added to unauthenticatedAPIPaths. --- pkg/routes/api/v2/auth_public.go | 175 +++++++++++++++++++++++++++++++ pkg/routes/routes.go | 7 ++ 2 files changed, 182 insertions(+) create mode 100644 pkg/routes/api/v2/auth_public.go diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go new file mode 100644 index 000000000..13c33523b --- /dev/null +++ b/pkg/routes/api/v2/auth_public.go @@ -0,0 +1,175 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// publicSecurity is the empty security requirement that opts an operation out of +// the globally-applied JWT/API-token auth. The matching Echo path must also be +// listed in unauthenticatedAPIPaths so the token middleware lets it through. +var publicSecurity = []map[string][]string{} + +// registerUserBody is the response wrapper for the registration endpoint. +type registerUserBody struct { + Body *user.User +} + +// messageBody carries a human-readable confirmation for endpoints that report +// success without returning a resource (password reset, email confirm). +type messageBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + } +} + +// linkShareTokenBody wraps the issued link-share auth token and its share. +type linkShareTokenBody struct { + Body *shared.LinkShareToken +} + +func init() { AddRouteRegistrar(RegisterPublicAuthRoutes) } + +// RegisterPublicAuthRoutes wires the unauthenticated local-account flows +// (registration, password reset, email confirmation) and the link-share auth +// endpoint. The local-account flows mirror v1 by only registering when local +// auth is enabled; the link-share endpoint follows ServiceEnableLinkSharing. +func RegisterPublicAuthRoutes(api huma.API) { + if config.AuthLocalEnabled.GetBool() { + registerLocalAuthRoutes(api) + } + + if config.ServiceEnableLinkSharing.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-link-share", + Summary: "Get an auth token for a link share", + Description: "Exchanges a link share's public hash (and password, for password-protected shares) for a JWT auth token scoped to the shared project.", + Method: http.MethodPost, + Path: "/shares/{share}/auth", + DefaultStatus: http.StatusOK, + Tags: []string{"sharing"}, + Security: publicSecurity, + }, authLinkShare) + } +} + +func registerLocalAuthRoutes(api huma.API) { + authTags := []string{"auth"} + + Register(api, huma.Operation{ + OperationID: "auth-register", + Summary: "Register", + Description: "Creates a new local user account. Returns 404 when registration is disabled on this instance.", + Method: http.MethodPost, + Path: "/register", + Tags: authTags, + Security: publicSecurity, + }, authRegister) + + Register(api, huma.Operation{ + OperationID: "auth-password-token", + Summary: "Request a password reset token", + Description: "Requests a token to reset the password for the account with the given email. The token is sent to that email; the response is the same whether or not an account exists.", + Method: http.MethodPost, + Path: "/user/password/token", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authRequestPasswordToken) + + Register(api, huma.Operation{ + OperationID: "auth-password-reset", + Summary: "Reset a password", + Description: "Sets a new password using a previously issued reset token. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password/reset", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authResetPassword) + + Register(api, huma.Operation{ + OperationID: "auth-confirm-email", + Summary: "Confirm an email address", + Description: "Confirms the email address of a newly registered user using the token sent to that email.", + Method: http.MethodPost, + Path: "/user/confirm", + DefaultStatus: http.StatusOK, + Tags: authTags, + Security: publicSecurity, + }, authConfirmEmail) +} + +func authRegister(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { + if !config.ServiceEnableRegistration.GetBool() { + return nil, huma.Error404NotFound("registration is disabled") + } + + newUser, err := shared.RegisterUser(&in.Body) + if err != nil { + return nil, translateDomainError(err) + } + return ®isterUserBody{Body: newUser}, nil +} + +func authRequestPasswordToken(_ context.Context, in *struct{ Body user.PasswordTokenRequest }) (*messageBody, error) { + if err := shared.RequestPasswordResetToken(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "Token was sent." + return out, nil +} + +func authResetPassword(_ context.Context, in *struct{ Body user.PasswordReset }) (*messageBody, error) { + if err := shared.ResetPassword(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The password was updated successfully." + return out, nil +} + +func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) (*messageBody, error) { + if err := shared.ConfirmEmail(&in.Body); err != nil { + return nil, translateDomainError(err) + } + out := &messageBody{} + out.Body.Message = "The email was confirmed successfully." + return out, nil +} + +func authLinkShare(_ context.Context, in *struct { + Share string `path:"share" doc:"The public hash of the link share."` + Body struct { + Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."` + } +}) (*linkShareTokenBody, error) { + token, err := shared.AuthenticateLinkShare(in.Share, in.Body.Password) + if err != nil { + return nil, translateDomainError(err) + } + return &linkShareTokenBody{Body: token}, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 949180b64..4c070fd49 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -344,6 +344,13 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/docs/scalar.standalone.js": true, "/api/v2/schemas/:schema": true, "/api/v2/info": true, + + "/api/v2/register": true, + "/api/v2/user/password/token": true, + "/api/v2/user/password/reset": true, + "/api/v2/user/confirm": true, + "/api/v2/shares/:share/auth": true, + "/api/v2/oauth/token": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. From dc4c3a6a174ea7e8107e7b7f37c2a0053691020d Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:45:35 +0200 Subject: [PATCH 005/111] feat(api/v2): add OAuth 2.0 token and authorize endpoints Port oauth/token and oauth/authorize to /api/v2, delegating to the shared oauth2server.ExchangeToken / Authorize cores. The token endpoint accepts spec-compliant application/x-www-form-urlencoded bodies (RFC 6749) in addition to JSON; a form-urlencoded format is registered on the v2 API that binds into the same json-tagged request struct. The response carries Cache-Control: no-store. The token endpoint is public; authorize inherits the global JWT auth. --- pkg/routes/api/v2/huma.go | 41 ++++++++++++++ pkg/routes/api/v2/oauth.go | 112 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 pkg/routes/api/v2/oauth.go diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 7ad6f18f6..7a7dc3514 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -19,7 +19,10 @@ package apiv2 import ( "context" + "encoding/json" + "io" "net/http" + "net/url" "strings" "code.vikunja.io/api/pkg/config" @@ -31,6 +34,36 @@ import ( "github.com/labstack/echo/v5" ) +// formURLEncodedContentType is the content type the OAuth token endpoint accepts +// in addition to JSON, per RFC 6749. +const formURLEncodedContentType = "application/x-www-form-urlencoded" + +// formURLEncodedFormat lets Huma bind application/x-www-form-urlencoded request +// bodies into the same json-tagged structs it uses for JSON: the form values are +// re-marshaled to JSON and decoded via the standard path. Only string scalars +// are produced, which is all the form-encoded endpoints (OAuth token) need. +var formURLEncodedFormat = huma.Format{ + Marshal: func(io.Writer, any) error { + // Responses are always JSON; this format is request-body only. + return huma.ErrUnknownContentType + }, + Unmarshal: func(data []byte, v any) error { + values, err := url.ParseQuery(string(data)) + if err != nil { + return err + } + flat := make(map[string]string, len(values)) + for key := range values { + flat[key] = values.Get(key) + } + raw, err := json.Marshal(flat) + if err != nil { + return err + } + return json.Unmarshal(raw, v) + }, +} + // GroupPrefix is the URL prefix the Echo group for /api/v2 is mounted at. const GroupPrefix = "/api/v2" @@ -44,6 +77,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { // Real presence/format rules live in `valid:` tags, enforced by govalidator in // the Register wrapper; leave the schema permissive so partial updates match v1. cfg.FieldsOptionalByDefault = true + // Accept application/x-www-form-urlencoded bodies (the OAuth token endpoint) + // alongside JSON. Copy the default map so we don't mutate the package global. + formats := make(map[string]huma.Format, len(cfg.Formats)+1) + for ct, f := range cfg.Formats { + formats[ct] = f + } + formats[formURLEncodedContentType] = formURLEncodedFormat + cfg.Formats = formats api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg) oapi := api.OpenAPI() diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go new file mode 100644 index 000000000..9b13c7654 --- /dev/null +++ b/pkg/routes/api/v2/oauth.go @@ -0,0 +1,112 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// oauthTokenBody wraps the OAuth 2.0 token response. +type oauthTokenBody struct { + // Cache-Control: no-store is required by RFC 6749 §5.1 so tokens are not + // cached. v2 already sets it globally, but declaring it keeps the contract + // explicit in the spec. + CacheControl string `header:"Cache-Control"` + Body *oauth2server.TokenResponse +} + +// oauthAuthorizeBody wraps the OAuth 2.0 authorization response. +type oauthAuthorizeBody struct { + Body *oauth2server.AuthorizeResponse +} + +func init() { AddRouteRegistrar(RegisterOAuthRoutes) } + +// RegisterOAuthRoutes wires the OAuth 2.0 token and authorize endpoints. The +// token endpoint is public (it authenticates the request itself); authorize +// inherits the global JWT auth. +func RegisterOAuthRoutes(api huma.API) { + tags := []string{"auth"} + + Register(api, huma.Operation{ + OperationID: "oauth-token", + Summary: "OAuth 2.0 token endpoint", + Description: "Exchanges an authorization code (grant_type=authorization_code) or a refresh token (grant_type=refresh_token) for an access token. Accepts application/x-www-form-urlencoded per RFC 6749 as well as JSON.", + Method: http.MethodPost, + Path: "/oauth/token", + DefaultStatus: http.StatusOK, + Tags: tags, + Security: publicSecurity, + }, oauthToken) + + Register(api, huma.Operation{ + OperationID: "oauth-authorize", + Summary: "OAuth 2.0 authorize endpoint", + Description: "Creates a single-use authorization code for the authenticated user. PKCE (code_challenge with method S256) and a loopback or vikunja- scheme redirect_uri are required.", + Method: http.MethodPost, + Path: "/oauth/authorize", + DefaultStatus: http.StatusOK, + Tags: tags, + }, oauthAuthorize) +} + +func oauthToken(ctx context.Context, in *struct { + Body oauth2server.TokenRequest `contentType:"application/x-www-form-urlencoded"` +}) (*oauthTokenBody, error) { + deviceInfo, ipAddress := requestClientInfo(ctx) + resp, err := oauth2server.ExchangeToken(&in.Body, deviceInfo, ipAddress) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthTokenBody{CacheControl: "no-store", Body: resp}, nil +} + +func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.AuthorizeRequest }) (*oauthAuthorizeBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + resp, err := oauth2server.Authorize(&in.Body, u.ID) + if err != nil { + return nil, translateDomainError(err) + } + return &oauthAuthorizeBody{Body: resp}, nil +} + +// requestClientInfo pulls the user agent and client IP off the underlying Echo +// request so the authorization_code grant can record them on the session it +// creates, mirroring v1. Both fall back to "" when the context is unavailable. +func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) { + ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok || ec == nil { + return "", "" + } + return (*ec).Request().UserAgent(), (*ec).RealIP() +} From 56a516045bb2efe0388a56fd10a87b6ebd4a7b7a Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:48:08 +0200 Subject: [PATCH 006/111] feat(api/v2): add token-check, token-routes and link-share renew endpoints Port the token introspection helpers and link-share token renewal to /api/v2: - GET/POST /token/test both return a plain 200 "ok"; v1's POST 418 teapot easter egg becomes an ordinary success. - GET /routes lists the scoped-token routes for both API versions (models.GetAPITokenRoutes already merges v1 + v2). - POST /user/token renews a link-share JWT; user tokens are rejected (they must use the refresh-token flow), mirroring v1. The renew response inlines the token field rather than returning auth.Token directly, since Huma names schemas by bare type and a top-level auth.Token body would collide with user.Token. --- pkg/routes/api/v2/token_meta.go | 138 ++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 pkg/routes/api/v2/token_meta.go diff --git a/pkg/routes/api/v2/token_meta.go b/pkg/routes/api/v2/token_meta.go new file mode 100644 index 000000000..120c3c81e --- /dev/null +++ b/pkg/routes/api/v2/token_meta.go @@ -0,0 +1,138 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/danielgtaylor/huma/v2" +) + +// tokenTestBody is the response for the token-check endpoints. +type tokenTestBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A static confirmation message."` + } +} + +// apiRoutesBody is the response for the token-routes endpoint: the available +// API routes grouped by permission, for building API-token scopes. +type apiRoutesBody struct { + Body map[string]models.APITokenRoute +} + +// renewTokenBody wraps a freshly issued link-share JWT. The token field is +// inlined rather than embedding auth.Token because Huma derives schema names +// from the bare Go type name, and a top-level auth.Token body would collide with +// user.Token (the caldav-token schema, also named "Token"). +type renewTokenBody struct { + Body struct { + Token string `json:"token" readOnly:"true" doc:"The renewed JWT auth token."` + } +} + +func init() { AddRouteRegistrar(RegisterTokenMetaRoutes) } + +// RegisterTokenMetaRoutes wires the token introspection helpers and the +// link-share token renewal endpoint. +func RegisterTokenMetaRoutes(api huma.API) { + tags := []string{"auth"} + + // v1 served GET as a 200 "ok" and POST as a 418 teapot easter egg; v2 makes + // both a plain 200 so a token check is an ordinary success. + Register(api, huma.Operation{ + OperationID: "token-test", + Summary: "Test a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodGet, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenTest) + + Register(api, huma.Operation{ + OperationID: "token-check", + Summary: "Check a token", + Description: "Returns 200 if the bearer token (JWT or API token) is valid. Used to check authentication.", + Method: http.MethodPost, + Path: "/token/test", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenCheck) + + Register(api, huma.Operation{ + OperationID: "token-routes", + Summary: "List API token routes", + Description: "Returns every API route available to scope an API token against, grouped by resource and permission. Covers both /api/v1 and /api/v2 routes.", + Method: http.MethodGet, + Path: "/routes", + Tags: []string{"api"}, + }, tokenRoutes) + + Register(api, huma.Operation{ + OperationID: "token-renew", + Summary: "Renew a link-share token", + Description: "Issues a fresh JWT for the current link share. Only link-share tokens can be renewed here; user sessions must use the refresh-token flow.", + Method: http.MethodPost, + Path: "/user/token", + DefaultStatus: http.StatusOK, + Tags: tags, + }, tokenRenew) +} + +func tokenTest(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenCheck(_ context.Context, _ *struct{}) (*tokenTestBody, error) { + out := &tokenTestBody{} + out.Body.Message = "ok" + return out, nil +} + +func tokenRoutes(_ context.Context, _ *struct{}) (*apiRoutesBody, error) { + return &apiRoutesBody{Body: models.GetAPITokenRoutes()}, nil +} + +func tokenRenew(ctx context.Context, _ *struct{}) (*renewTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + // Only link-share tokens are renewable here; a user JWT lands as *user.User + // and must use the refresh-token flow instead. + share, ok := a.(*models.LinkSharing) + if !ok { + return nil, huma.Error400BadRequest("User tokens cannot be renewed via this endpoint. Use the refresh-token flow instead.") + } + + t, err := auth.NewLinkShareJWTAuthtoken(share) + if err != nil { + return nil, translateDomainError(err) + } + + out := &renewTokenBody{} + out.Body.Token = t + return out, nil +} From d8ad9d64f55f5347b61914e1f0c8600b4d56f8d7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:51:40 +0200 Subject: [PATCH 007/111] test(api/v2): cover ported auth/token endpoints Add webtests mirroring the v1 coverage for the v2 auth surface: register (incl. registration-disabled 404), password reset request + reset, email confirm, link-share auth (password matrix), the OAuth token flow in both JSON and form-urlencoded encodings, oauth/authorize, the token-test/check endpoints (200, not 418), /routes and link-share token renewal (incl. user-token rejection). Also make the link-share auth body optional so a passwordless share authenticates with no request body, matching v1. --- pkg/routes/api/v2/auth_public.go | 11 +- pkg/webtests/huma_auth_test.go | 293 +++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 pkg/webtests/huma_auth_test.go diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 13c33523b..689c2c7b7 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -163,11 +163,18 @@ func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) ( func authLinkShare(_ context.Context, in *struct { Share string `path:"share" doc:"The public hash of the link share."` - Body struct { + // Pointer so the body is optional: shares without a password are + // authenticated with no body at all. + Body *struct { Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."` } }) (*linkShareTokenBody, error) { - token, err := shared.AuthenticateLinkShare(in.Share, in.Body.Password) + var password string + if in.Body != nil { + password = in.Body.Password + } + + token, err := shared.AuthenticateLinkShare(in.Share, password) if err != nil { return nil, translateDomainError(err) } diff --git a/pkg/webtests/huma_auth_test.go b/pkg/webtests/huma_auth_test.go new file mode 100644 index 000000000..404f89f00 --- /dev/null +++ b/pkg/webtests/huma_auth_test.go @@ -0,0 +1,293 @@ +// 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 . + +package webtests + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/oauth2server" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaAuthPublic ports the v1 coverage of the public local-account flows +// (register, password reset, email confirm) to /api/v2. These endpoints opt out +// of the global auth, so requests carry no token. +func TestHumaAuthPublic(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(path, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, path, body, "", "") + } + + t.Run("Register", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"newhumauser","password":"12345678","email":"newhuma@example.com"}`) + require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"username":"newhumauser"`) + }) + t.Run("already existing username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"user1","password":"12345678","email":"x@example.com"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + t.Run("empty username", func(t *testing.T) { + rec := post("/api/v2/register", `{"username":"","password":"12345678","email":"x@example.com"}`) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest) + }) + }) + + t.Run("Request password reset token", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1@example.com"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "Token was sent.") + }) + t.Run("no user with that email", func(t *testing.T) { + rec := post("/api/v2/user/password/token", `{"email":"user1000@example.com"}`) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + }) + + t.Run("Reset password", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"passwordresettesttoken","new_password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/password/reset", `{"token":"invalidtoken","new_password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) + + t.Run("Confirm email", func(t *testing.T) { + t.Run("normal", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The email was confirmed successfully.") + }) + t.Run("invalid token", func(t *testing.T) { + rec := post("/api/v2/user/confirm", `{"token":"invalidToken"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + }) + }) +} + +// TestHumaRegisterDisabled proves the registration endpoint 404s when +// registration is disabled, mirroring v1. +func TestHumaRegisterDisabled(t *testing.T) { + config.ServiceEnableRegistration.Set(false) + defer config.ServiceEnableRegistration.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/register", + `{"username":"nope","password":"12345678","email":"nope@example.com"}`, "", "") + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +// TestHumaLinkShareAuth ports the v1 link-share auth coverage to /api/v2. +func TestHumaLinkShareAuth(t *testing.T) { + config.ServiceEnableLinkSharing.Set(true) + + e, err := setupTestEnv() + require.NoError(t, err) + + post := func(share, body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, "/api/v2/shares/"+share+"/auth", body, "", "") + } + + t.Run("without password", func(t *testing.T) { + rec := post("test", ``) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + }) + t.Run("with password, correct", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("with password, missing", func(t *testing.T) { + rec := post("testWithPassword", ``) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordRequired, problemCode(t, rec)) + }) + t.Run("with password, wrong", func(t *testing.T) { + rec := post("testWithPassword", `{"password":"wrong"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, models.ErrCodeLinkSharePasswordInvalid, problemCode(t, rec)) + }) +} + +// TestHumaTokenMeta ports the token-introspection and link-share renew +// endpoints to /api/v2. +func TestHumaTokenMeta(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + userToken := humaTokenFor(t, &testuser1) + + t.Run("token test (GET) returns ok", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check (POST) returns 200, not 418", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/token/test", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"ok"`) + }) + t.Run("token check unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + t.Run("routes lists token routes", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/routes", "", userToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var routes map[string]map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &routes)) + assert.Contains(t, routes, "tasks") + }) + + t.Run("renew link-share token", func(t *testing.T) { + share := &models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + } + shareToken, err := auth.NewLinkShareJWTAuthtoken(share) + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", shareToken, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + }) + t.Run("renew rejects user token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", userToken, "") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +// TestHumaOAuth ports the OAuth 2.0 token and authorize flows to /api/v2 and +// exercises both the JSON and the spec-compliant form-urlencoded encodings of +// the token endpoint. +func TestHumaOAuth(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("authorize requires authentication", func(t *testing.T) { + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc", "S256", "s") + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("full code flow with PKCE (JSON token request)", func(t *testing.T) { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "xyz") + + body, _ := json.Marshal(map[string]string{ //nolint:errchkjson + "grant_type": "authorization_code", + "code": code, + "client_id": "vikunja", + "redirect_uri": "vikunja-flutter://callback", + "code_verifier": verifier, + }) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", string(body), "", "application/json") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.Equal(t, "bearer", resp.TokenType) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("full code flow with PKCE (form-urlencoded token request)", func(t *testing.T) { + verifier := "form-encoded-flow-verifier" + challenge := pkceChallenge(verifier) + code := authorizeV2(t, e, challenge, "") + + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {"vikunja"}, + "redirect_uri": {"vikunja-flutter://callback"}, + "code_verifier": {verifier}, + } + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.TokenResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) + }) + + t.Run("invalid grant type", func(t *testing.T) { + form := url.Values{"grant_type": {"password"}, "client_id": {"vikunja"}} + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// authorizeV2 runs the v2 authorize step for testuser1 and returns the code. +func authorizeV2(t *testing.T, e *echo.Echo, challenge, state string) string { + t.Helper() + token := humaTokenFor(t, &testuser1) + body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", challenge, "S256", state) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp oauth2server.AuthorizeResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.NotEmpty(t, resp.Code) + return resp.Code +} + +// problemCode pulls the Vikunja numeric error code out of an RFC 9457 body. +func problemCode(t *testing.T, rec *httptest.ResponseRecorder) int { + t.Helper() + var body struct { + Code int `json:"code"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + return body.Code +} From 2bbe77c1411f82b43f36dd86b81ae623394652b2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:41:19 +0200 Subject: [PATCH 008/111] fix(api/v2): gate /register at registration time, not per request Per review: when registration is disabled, skip registering the /register route entirely instead of registering it and returning 404 on every request. A request to a disabled instance still 404s (unknown route). ServiceEnableRegistration is static config, so the gate belongs in the registrar. --- pkg/routes/api/v2/auth_public.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 689c2c7b7..5791b7c3f 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -78,15 +78,20 @@ func RegisterPublicAuthRoutes(api huma.API) { func registerLocalAuthRoutes(api huma.API) { authTags := []string{"auth"} - Register(api, huma.Operation{ - OperationID: "auth-register", - Summary: "Register", - Description: "Creates a new local user account. Returns 404 when registration is disabled on this instance.", - Method: http.MethodPost, - Path: "/register", - Tags: authTags, - Security: publicSecurity, - }, authRegister) + // Registration is its own static-config gate on top of local auth: when it + // is disabled the route simply isn't registered (a request then 404s as an + // unknown route), rather than registering it and rejecting per request. + if config.ServiceEnableRegistration.GetBool() { + Register(api, huma.Operation{ + OperationID: "auth-register", + Summary: "Register", + Description: "Creates a new local user account.", + Method: http.MethodPost, + Path: "/register", + Tags: authTags, + Security: publicSecurity, + }, authRegister) + } Register(api, huma.Operation{ OperationID: "auth-password-token", @@ -123,10 +128,6 @@ func registerLocalAuthRoutes(api huma.API) { } func authRegister(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { - if !config.ServiceEnableRegistration.GetBool() { - return nil, huma.Error404NotFound("registration is disabled") - } - newUser, err := shared.RegisterUser(&in.Body) if err != nil { return nil, translateDomainError(err) From 48f7dafce38b695006b45bf22dabf0ee377c1211 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:00:41 +0200 Subject: [PATCH 009/111] 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. --- pkg/events/events.go | 37 ++++++++++++++++++++++++- pkg/events/request_meta.go | 55 ++++++++++++++++++++++++++++++++++++++ pkg/web/handler/core.go | 15 +++++++---- 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 pkg/events/request_meta.go diff --git a/pkg/events/events.go b/pkg/events/events.go index 30c26ea99..882de2bbb 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -201,6 +201,13 @@ func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) { // Dispatch dispatches an event func Dispatch(event Event) error { + return DispatchWithContext(context.Background(), event) +} + +// DispatchWithContext dispatches an event and copies request metadata from the +// context (see WithRequestMeta) onto the message metadata, so listeners can +// attribute the event to the originating HTTP request. +func DispatchWithContext(ctx context.Context, event Event) error { if isUnderTest { dispatchedTestEvents = append(dispatchedTestEvents, event) return nil @@ -216,17 +223,41 @@ func Dispatch(event Event) error { } msg := message.NewMessage(watermill.NewUUID(), content) + if meta := RequestMetaFromContext(ctx); meta != nil { + if meta.IP != "" { + msg.Metadata.Set(MetadataKeyIP, meta.IP) + } + if meta.UserAgent != "" { + msg.Metadata.Set(MetadataKeyUserAgent, meta.UserAgent) + } + if meta.RequestID != "" { + msg.Metadata.Set(MetadataKeyRequestID, meta.RequestID) + } + } return pubsub.Publish(event.Name(), msg) } // pendingEventQueue holds the pending events and a mutex for thread-safe access type pendingEventQueue struct { mu sync.Mutex + ctx context.Context events []Event } var pendingEvents sync.Map // map[any]*pendingEventQueue +// SetContextForKey associates a request context with a transaction key so that +// events queued via DispatchOnCommit for the same key are dispatched with the +// request metadata from that context. The entry is removed by DispatchPending +// or CleanupPending — callers must guarantee one of them runs for the key. +func SetContextForKey(key any, ctx context.Context) { + val, _ := pendingEvents.LoadOrStore(key, &pendingEventQueue{}) + queue := val.(*pendingEventQueue) + queue.mu.Lock() + queue.ctx = ctx + queue.mu.Unlock() +} + // DispatchOnCommit stores an event to be dispatched later, after a transaction commits. // The key should be the *xorm.Session pointer associated with the transaction. // Call DispatchPending(key) after s.Commit() to actually dispatch the events. @@ -250,8 +281,12 @@ func DispatchPending(key any) { queue := val.(*pendingEventQueue) // No need to lock here since we've already removed it from the map // and this key won't receive new events + ctx := queue.ctx + if ctx == nil { + ctx = context.Background() + } for _, event := range queue.events { - if err := Dispatch(event); err != nil { + if err := DispatchWithContext(ctx, event); err != nil { log.Errorf("Failed to dispatch event %s: %v", event.Name(), err) } } diff --git a/pkg/events/request_meta.go b/pkg/events/request_meta.go new file mode 100644 index 000000000..796c7b7e9 --- /dev/null +++ b/pkg/events/request_meta.go @@ -0,0 +1,55 @@ +// 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 . + +package events + +import "context" + +// RequestMeta carries information about the originating HTTP request. It is +// stashed on the request context by a middleware and copied onto message +// metadata at publish time, so listeners (e.g. audit) can attribute an event +// to a request without every dispatch site changing its signature. +type RequestMeta struct { + IP string + UserAgent string + RequestID string +} + +// Message metadata keys holding request information. +const ( + MetadataKeyIP = "request_ip" + MetadataKeyUserAgent = "request_user_agent" + MetadataKeyRequestID = "request_id" +) + +type requestMetaKeyType struct{} + +var requestMetaKey requestMetaKeyType + +// WithRequestMeta returns a context carrying the given request metadata. +func WithRequestMeta(ctx context.Context, meta *RequestMeta) context.Context { + return context.WithValue(ctx, requestMetaKey, meta) +} + +// RequestMetaFromContext returns the request metadata stored on the context, +// or nil if there is none. +func RequestMetaFromContext(ctx context.Context) *RequestMeta { + if ctx == nil { + return nil + } + meta, _ := ctx.Value(requestMetaKey).(*RequestMeta) + return meta +} diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go index 01474b874..fa037794b 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -28,8 +28,9 @@ import ( // DoCreate runs the permission check + model Create + commit pipeline for a // CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma. // Caller is responsible for body/path binding and validation before calling. -func DoCreate(_ context.Context, obj CObject, a web.Auth) error { +func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -68,8 +69,9 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error { // CObject. obj should have its identifying fields set before call. On success, // obj is fully populated. maxPermission is exposed via the x-max-permission // header in the Echo wrapper; Huma wrapper may ignore it. -func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { +func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { s := db.NewSession() + events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -108,8 +110,9 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e // scoping context (e.g., TaskID on LabelTask). Returns the result slice/ // interface, the result count, and total count. Pagination header math and // nil-slice normalization remain the caller's responsibility. -func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { +func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { s := db.NewSession() + events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -135,8 +138,9 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, // DoUpdate runs the permission check + model Update + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for body/path binding // and validation before calling. -func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { +func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -174,8 +178,9 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error { // DoDelete runs the permission check + model Delete + commit pipeline for a // CObject. Framework-agnostic. Caller is responsible for path binding before // calling. -func DoDelete(_ context.Context, obj CObject, a web.Auth) error { +func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() + events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) From 95084087a5fe356b438f50544782dcb6e70060a6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:01:28 +0200 Subject: [PATCH 010/111] feat(config): add audit logging config keys --- config-raw.json | 31 +++++++++++++++++++++++++++++++ pkg/config/config.go | 10 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/config-raw.json b/config-raw.json index dd395b768..641285994 100644 --- a/config-raw.json +++ b/config-raw.json @@ -997,6 +997,37 @@ } ] }, + { + "key": "audit", + "comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.", + "children": [ + { + "key": "enabled", + "default_value": "false", + "comment": "Whether to enable audit logging." + }, + { + "key": "logfile", + "default_value": "", + "comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path." + }, + { + "key": "rotation", + "children": [ + { + "key": "maxsizemb", + "default_value": "100", + "comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation." + }, + { + "key": "maxage", + "default_value": "30", + "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": "outgoingrequests", "children": [ diff --git a/pkg/config/config.go b/pkg/config/config.go index 1941f7f0b..2443cb627 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -220,6 +220,11 @@ const ( WebhooksProxyPassword Key = `webhooks.proxypassword` WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips` + AuditEnabled Key = `audit.enabled` + AuditLogfile Key = `audit.logfile` + AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb` + AuditRotationMaxAge Key = `audit.rotation.maxage` + OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips` OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl` OutgoingRequestsProxyPassword Key = `outgoingrequests.proxypassword` @@ -483,6 +488,11 @@ func InitDefaultConfig() { WebhooksEnabled.setDefault(true) WebhooksTimeoutSeconds.setDefault(30) WebhooksAllowNonRoutableIPs.setDefault(false) + // Audit + AuditEnabled.setDefault(false) + AuditLogfile.setDefault("") // empty means /audit.log, resolved at init + AuditRotationMaxSizeMB.setDefault(100) + AuditRotationMaxAge.setDefault(30) // Outgoing Requests OutgoingRequestsAllowNonRoutableIPs.setDefault(false) OutgoingRequestsTimeoutSeconds.setDefault(30) From f308fd830aad3f323507d9523eda5364791deb47 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:03:49 +0200 Subject: [PATCH 011/111] 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. --- pkg/audit/entry.go | 127 +++++++++++++++++++++++++++ pkg/audit/listener.go | 88 +++++++++++++++++++ pkg/audit/writer.go | 199 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 pkg/audit/entry.go create mode 100644 pkg/audit/listener.go create mode 100644 pkg/audit/writer.go diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go new file mode 100644 index 000000000..e2ed91876 --- /dev/null +++ b/pkg/audit/entry.go @@ -0,0 +1,127 @@ +// 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 . + +package audit + +import "time" + +// Entry is one audit log record. It only references actors and targets by +// opaque ID — no names, emails or content — so GDPR erasure is satisfied by +// deleting the referenced row. +type Entry struct { + EventID string `json:"event_id"` // UUIDv7 + Timestamp time.Time `json:"timestamp"` + Actor Actor `json:"actor"` + Source Source `json:"source"` + Action string `json:"action"` + Target Target `json:"target"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` + RequestID string `json:"request_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type actorType string +type targetType string + +// Actor is the principal which performed the audited action. +type Actor struct { + Type actorType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Source describes where the action originated from. +type Source struct { + Type string `json:"type"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +// Target is the resource the audited action was performed on. +type Target struct { + Type targetType `json:"type"` + ID int64 `json:"id,omitempty"` +} + +// Outcome values for an Entry. +const ( + OutcomeSuccess = "success" + OutcomeFailure = "failure" +) + +// Source types for an Entry. +const ( + SourceHTTP = "http" + SourceSystem = "system" +) + +// The action catalog. Every audited action is listed here. +const ( + ActionLoginSucceeded = "auth.login.succeeded" + ActionLoginFailed = "auth.login.failed" + ActionLogout = "auth.logout" + ActionAPITokenIssued = "auth.api_token.issued" + ActionAPITokenRevoked = "auth.api_token.revoked" + ActionAPITokenUsed = "auth.api_token.used" + + ActionUserCreated = "user.created" + + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskAssigneeAdded = "task.assignee.added" + ActionTaskAssigneeRemoved = "task.assignee.removed" + ActionTaskCommentCreated = "task.comment.created" + ActionTaskCommentUpdated = "task.comment.updated" + ActionTaskCommentDeleted = "task.comment.deleted" + ActionTaskAttachmentCreated = "task.attachment.created" + ActionTaskAttachmentDeleted = "task.attachment.deleted" + ActionTaskRelationCreated = "task.relation.created" + ActionTaskRelationDeleted = "task.relation.deleted" + + ActionProjectCreated = "project.created" + ActionProjectUpdated = "project.updated" + ActionProjectDeleted = "project.deleted" + ActionProjectSharedWithUser = "project.shared.user" + ActionProjectSharedWithTeam = "project.shared.team" + + ActionTeamCreated = "team.created" + ActionTeamDeleted = "team.deleted" + ActionTeamMemberAdded = "team.member.added" + ActionTeamMemberRemoved = "team.member.removed" +) + +// The type strings are unexported; these constructors are the only way to +// build an Actor or Target, so a mismatched type/ID pair can't be expressed. + +func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} } +func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} } +func SystemActor() Actor { return Actor{Type: "system"} } + +// ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as +// users with negative IDs throughout the event payloads. +func ActorFromDoerID(id int64) Actor { + if id < 0 { + return LinkShareActor(-id) + } + return UserActor(id) +} + +func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} } +func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} } +func UserTarget(id int64) Target { return Target{Type: "user", ID: id} } +func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} } +func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} } diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go new file mode 100644 index 000000000..c0454512a --- /dev/null +++ b/pkg/audit/listener.go @@ -0,0 +1,88 @@ +// 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 . + +package audit + +import ( + "encoding/json" + + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + + "github.com/ThreeDotsLabs/watermill/message" +) + +type auditListener struct { + handle func(msg *message.Message) error +} + +func (l *auditListener) Handle(msg *message.Message) error { + return l.handle(msg) +} + +func (l *auditListener) Name() string { + return "audit" +} + +// RegisterEventForAudit opts an event into audit logging. The event→Entry +// mapping is passed at registration, so opting in and defining the mapping +// are one unit and can't drift apart. Returning a nil Entry skips the event. +func RegisterEventForAudit[T any, PT interface { + *T + 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 + } + entry, err := toEntry(msg.Payload) + if err != nil { + return err + } + if entry == nil { + return nil + } + enrichFromMetadata(entry, msg.Metadata) + return WriteAuditEvent(entry) + }}) +} + +func enrichFromMetadata(entry *Entry, meta message.Metadata) { + entry.Source.IP = meta.Get(events.MetadataKeyIP) + entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent) + entry.RequestID = meta.Get(events.MetadataKeyRequestID) + if entry.Source.Type == "" { + if entry.Source.IP != "" || entry.Source.UserAgent != "" { + entry.Source.Type = SourceHTTP + } else { + entry.Source.Type = SourceSystem + } + } +} diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go new file mode 100644 index 000000000..548c380fe --- /dev/null +++ b/pkg/audit/writer.go @@ -0,0 +1,199 @@ +// 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 . + +package audit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + + "github.com/google/uuid" +) + +var ( + mu sync.Mutex + initialized bool + logFile *os.File + logfilePath string + currentSize int64 + maxSizeBytes int64 + maxAge time.Duration + lastSync time.Time +) + +// Init opens the audit log file. +// Safe to call again to re-read the config (used by tests). +func Init() error { + mu.Lock() + defer mu.Unlock() + + closeLocked() + + logfilePath = config.AuditLogfile.GetString() + if logfilePath == "" { + logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log") + } + maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024 + maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour + + if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil { + return fmt.Errorf("could not create audit log directory: %w", err) + } + if err := openLogFileLocked(); err != nil { + return err + } + + initialized = true + return nil +} + +// Close closes the audit log file. Used by tests. +func Close() { + mu.Lock() + defer mu.Unlock() + closeLocked() +} + +func closeLocked() { + if logFile != nil { + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + } + initialized = false +} + +func openLogFileLocked() error { + f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err) + } + logFile = f + currentSize = info.Size() + return nil +} + +// WriteAuditEvent writes one entry to the local audit log. A failed write is +// returned so the event router retries it. +func WriteAuditEvent(entry *Entry) error { + if entry.EventID == "" { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("could not generate audit event id: %w", err) + } + entry.EventID = id.String() + } + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + if entry.Outcome == "" { + entry.Outcome = OutcomeSuccess + } + + line, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("could not marshal audit entry: %w", err) + } + + mu.Lock() + if !initialized { + mu.Unlock() + return fmt.Errorf("audit log not initialized") + } + + if err := rotateIfNeededLocked(int64(len(line)) + 1); 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() + } + mu.Unlock() + + if err != nil { + return fmt.Errorf("could not write audit entry: %w", err) + } + + return nil +} + +func rotateIfNeededLocked(addition int64) error { + if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes { + return nil + } + + _ = logFile.Sync() + _ = logFile.Close() + logFile = nil + + rotatedPath := rotatedFileName(logfilePath, time.Now().UTC()) + if err := os.Rename(logfilePath, rotatedPath); err != nil { + // Reopen the original so logging continues even if rotation failed. + _ = openLogFileLocked() + return fmt.Errorf("could not rotate audit log: %w", err) + } + + cleanupRotatedFiles() + + return openLogFileLocked() +} + +func rotatedFileName(path string, now time.Time) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext +} + +func cleanupRotatedFiles() { + if maxAge <= 0 { + return + } + + ext := filepath.Ext(logfilePath) + pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext + matches, err := filepath.Glob(pattern) + if err != nil { + log.Errorf("Could not list rotated audit log files: %s", err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(match); err != nil { + log.Errorf("Could not remove old audit log file %s: %s", match, err) + } + } +} From eea2ecbc7294cb2bd6108b78ea62bc7c8e958f0a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:05:07 +0200 Subject: [PATCH 012/111] feat(audit): wire request-meta middleware and writer initialization --- pkg/initialize/init.go | 7 +++++ pkg/routes/middleware/request_meta.go | 45 +++++++++++++++++++++++++++ pkg/routes/routes.go | 4 +++ 3 files changed, 56 insertions(+) create mode 100644 pkg/routes/middleware/request_meta.go diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index dca17cb60..7210feb1e 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -19,6 +19,7 @@ package initialize import ( "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/db" @@ -98,6 +99,12 @@ func FullInitWithoutAsync() { // See the package comment in pkg/license/license.go before removing. license.Init() + if config.AuditEnabled.GetBool() { + if err := audit.Init(); err != nil { + log.Fatalf("Could not initialize audit logging: %s", err) + } + } + // Start the mail daemon mail.StartMailDaemon() diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go new file mode 100644 index 000000000..747a37826 --- /dev/null +++ b/pkg/routes/middleware/request_meta.go @@ -0,0 +1,45 @@ +// 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 . + +package middleware + +import ( + "code.vikunja.io/api/pkg/events" + + "github.com/labstack/echo/v5" +) + +// RequestMeta stashes IP, User-Agent and X-Request-ID on the request context +// so events dispatched while handling the request carry them as message +// metadata (consumed by the audit listeners). +func RequestMeta() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + req := c.Request() + requestID := req.Header.Get(echo.HeaderXRequestID) + if requestID == "" { + requestID = c.Response().Header().Get(echo.HeaderXRequestID) + } + ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ + IP: c.RealIP(), + UserAgent: req.UserAgent(), + RequestID: requestID, + }) + c.SetRequest(req.WithContext(ctx)) + return next(c) + } + } +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 4c070fd49..f8fc1609f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -199,6 +199,10 @@ func NewEcho() *echo.Echo { // handler binds them. Runs globally so both /api/v1 and /api/v2 benefit. e.Use(vmiddleware.NormalizeArrayParams()) + if config.AuditEnabled.GetBool() { + e.Use(vmiddleware.RequestMeta()) + } + setupSentry(e) // Validation From 5f4a21a4c57458d59a0f5c29770132e00aed872a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:08:35 +0200 Subject: [PATCH 013/111] feat(events): add auth boundary events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/models/api_tokens.go | 26 +++++++++++++++++++++--- pkg/models/events.go | 41 ++++++++++++++++++++++++++++++++++++++ pkg/modules/auth/auth.go | 6 ++++++ pkg/routes/api/v1/login.go | 12 +++++++++++ pkg/routes/api_tokens.go | 13 ++++++++++++ pkg/user/events.go | 31 ++++++++++++++++++++++++++++ pkg/user/user.go | 5 +++++ 7 files changed, 131 insertions(+), 3 deletions(-) diff --git a/pkg/models/api_tokens.go b/pkg/models/api_tokens.go index 410c4ac96..7739184fb 100644 --- a/pkg/models/api_tokens.go +++ b/pkg/models/api_tokens.go @@ -24,6 +24,7 @@ import ( "time" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/web" @@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { } _, err = s.Insert(t) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenIssuedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + OwnerID: t.OwnerID, + }) + + return nil } func HashToken(token, salt string) string { @@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int, // @Failure 404 {object} web.HTTPError "The token does not exist." // @Failure 500 {object} models.Message "Internal error" // @Router /tokens/{tokenID} [delete] -func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) { +func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) { // Ownership is verified in CanDelete; delete by ID only. _, err = s.Where("id = ?", t.ID).Delete(&APIToken{}) - return err + if err != nil { + return err + } + + events.DispatchOnCommit(s, &APITokenRevokedEvent{ + TokenID: t.ID, + DoerID: a.GetID(), + }) + + return nil } // HasCaldavAccess checks whether the token has the caldav access permission. diff --git a/pkg/models/events.go b/pkg/models/events.go index fca768388..1996f54b8 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -395,3 +395,44 @@ type TimeEntryDeletedEvent struct { func (e *TimeEntryDeletedEvent) Name() string { return "time-entry.deleted" } + +//////////////////// +// API Token Events + +// API token events carry IDs only: the freshly created token struct holds the +// raw token string, which must never end up in a message payload (the poison +// queue logs payloads on handler failure). + +// APITokenIssuedEvent represents an API token being created +type APITokenIssuedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenIssuedEvent +func (e *APITokenIssuedEvent) Name() string { + return "api-token.issued" +} + +// APITokenRevokedEvent represents an API token being deleted +type APITokenRevokedEvent struct { + TokenID int64 `json:"token_id"` + DoerID int64 `json:"doer_id"` +} + +// Name defines the name for APITokenRevokedEvent +func (e *APITokenRevokedEvent) Name() string { + return "api-token.revoked" +} + +// APITokenUsedEvent represents an API token authenticating a request +type APITokenUsedEvent struct { + TokenID int64 `json:"token_id"` + OwnerID int64 `json:"owner_id"` +} + +// Name defines the name for APITokenUsedEvent +func (e *APITokenUsedEvent) Name() string { + return "api-token.used" +} diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 61a5f9b13..97429aa13 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -26,6 +26,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/humaecho5" "code.vikunja.io/api/pkg/user" @@ -123,6 +125,10 @@ func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { return err } + if err := events.DispatchWithContext(c.Request().Context(), &user.LoginSucceededEvent{User: u}); err != nil { + log.Errorf("Could not dispatch login succeeded event: %s", err) + } + // Set the refresh token as an HttpOnly cookie. The cookie is path-scoped // to the refresh endpoint, so the browser only sends it there. JavaScript // never sees the refresh token — this protects it from XSS. diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index eb92945d1..6c7eb686a 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -21,6 +21,8 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth/ldap" @@ -231,10 +233,14 @@ func Logout(c *echo.Context) (err error) { auth.ClearRefreshTokenCookie(c) var sid string + var userID int64 if raw := c.Get("user"); raw != nil { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) + if id, ok := claims["id"].(float64); ok { + userID = int64(id) + } } } } @@ -257,5 +263,11 @@ func Logout(c *echo.Context) (err error) { return err } + if userID != 0 { + if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil { + log.Errorf("Could not dispatch logout event: %s", err) + } + } + return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) } diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index 0c9708849..4f146e5a7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,6 +21,7 @@ import ( "strings" "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk c.Set("api_token", token) c.Set("api_user", u) + // Guarded by config: this fires on every token-authenticated request and + // only the audit listener consumes it. + if config.AuditEnabled.GetBool() { + err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{ + TokenID: token.ID, + OwnerID: token.OwnerID, + }) + if err != nil { + log.Errorf("Could not dispatch api token used event: %s", err) + } + } + return nil } diff --git a/pkg/user/events.go b/pkg/user/events.go index ff7866149..12b17a957 100644 --- a/pkg/user/events.go +++ b/pkg/user/events.go @@ -25,3 +25,34 @@ type CreatedEvent struct { func (t *CreatedEvent) Name() string { return "user.created" } + +// LoginSucceededEvent is fired after a user successfully authenticated, +// regardless of the auth provider (local, LDAP, OpenID). +type LoginSucceededEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginSucceededEvent +func (t *LoginSucceededEvent) Name() string { + return "user.login.succeeded" +} + +// LoginFailedEvent is fired for every failed password check of a known user. +type LoginFailedEvent struct { + User *User `json:"user"` +} + +// Name defines the name for LoginFailedEvent +func (t *LoginFailedEvent) Name() string { + return "user.login.failed" +} + +// LogoutEvent is fired when a user destroys their session. +type LogoutEvent struct { + UserID int64 `json:"user_id"` +} + +// Name defines the name for LogoutEvent +func (t *LogoutEvent) Name() string { + return "user.logout" +} diff --git a/pkg/user/user.go b/pkg/user/user.go index 1aec85853..ab2912982 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -27,6 +27,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/notifications" @@ -411,6 +412,10 @@ func (u *User) IsLocalUser() bool { } func handleFailedPassword(user *User) { + if err := events.Dispatch(&LoginFailedEvent{User: user}); err != nil { + log.Errorf("Could not dispatch login failed event: %s", err) + } + key := user.GetFailedPasswordAttemptsKey() err := keyvalue.IncrBy(key, 1) if err != nil { From 869bec38b5a689a96e74f5801637cfb3be5c2c5c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:10:13 +0200 Subject: [PATCH 014/111] 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. --- pkg/models/listeners.go | 305 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index 83ec34c9b..e29bb2369 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -22,6 +22,7 @@ import ( "strconv" "time" + "code.vikunja.io/api/pkg/audit" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" @@ -82,6 +83,310 @@ func RegisterListeners() { // Internal delivery listener — one message per webhook with its own retry lifecycle events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{}) } + if config.AuditEnabled.GetBool() { + registerEventsForAuditLogging() + } +} + +// auditDoerRef decodes the doer of events whose Doer field is an interface +// and thus can't be unmarshaled into the event struct directly. +type auditDoerRef struct { + ID int64 `json:"id"` + Hash string `json:"hash"` // only set when the doer is a link share +} + +func auditActorFromDoerRef(d *auditDoerRef) audit.Actor { + if d == nil { + return audit.SystemActor() + } + if d.Hash != "" { + return audit.LinkShareActor(d.ID) + } + return audit.ActorFromDoerID(d.ID) +} + +func auditActorFromUser(u *user.User) audit.Actor { + if u == nil { + return audit.SystemActor() + } + return audit.ActorFromDoerID(u.ID) +} + +// registerEventsForAuditLogging opts events into audit logging. This block is +// the catalog of the entire audited surface — an event without a registration +// here is not audited. +func registerEventsForAuditLogging() { + // Auth boundary + audit.RegisterEventForAudit(func(e *user.LoginSucceededEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginSucceeded, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + audit.RegisterEventForAudit(func(e *user.LoginFailedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLoginFailed, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + Outcome: audit.OutcomeFailure, + Reason: "wrong password", + } + }) + audit.RegisterEventForAudit(func(e *user.LogoutEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionLogout, + Actor: audit.UserActor(e.UserID), + Target: audit.UserTarget(e.UserID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenIssuedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenIssued, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + Metadata: map[string]any{"owner_id": e.OwnerID}, + } + }) + audit.RegisterEventForAudit(func(e *APITokenRevokedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenRevoked, + Actor: audit.UserActor(e.DoerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + audit.RegisterEventForAudit(func(e *APITokenUsedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionAPITokenUsed, + Actor: audit.UserActor(e.OwnerID), + Target: audit.APITokenTarget(e.TokenID), + } + }) + + // Users + audit.RegisterEventForAudit(func(e *user.CreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionUserCreated, + Actor: audit.UserActor(e.User.ID), + Target: audit.UserTarget(e.User.ID), + } + }) + + // Tasks + audit.RegisterEventForAudit(func(e *TaskCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAssigneeDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAssigneeRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"assignee_id": e.Assignee.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentUpdatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentUpdated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskCommentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskCommentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"comment_id": e.Comment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskAttachmentDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskAttachmentDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{"attachment_id": e.Attachment.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + audit.RegisterEventForAudit(func(e *TaskRelationDeletedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTaskRelationDeleted, + Actor: auditActorFromUser(e.Doer), + Target: audit.TaskTarget(e.Task.ID), + Metadata: map[string]any{ + "other_task_id": e.Relation.OtherTaskID, + "relation_kind": e.Relation.RelationKind, + }, + } + }) + + // Projects + audit.RegisterEventForAudit(func(e *ProjectCreatedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionProjectCreated, + Actor: auditActorFromUser(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + } + }) + audit.RegisterEventNameForAudit((&ProjectUpdatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectUpdated, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectDeleted, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectSharedWithUserEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + User *user.User `json:"user"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectSharedWithUser, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"user_id": e.User.ID}, + }, nil + }) + audit.RegisterEventNameForAudit((&ProjectSharedWithTeamEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionProjectSharedWithTeam, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.ProjectTarget(e.Project.ID), + Metadata: map[string]any{"team_id": e.Team.ID}, + }, nil + }) + + // Teams + audit.RegisterEventNameForAudit((&TeamCreatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionTeamCreated, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + }, nil + }) + audit.RegisterEventNameForAudit((&TeamDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { + e := &struct { + Team *Team `json:"team"` + Doer *auditDoerRef `json:"doer"` + }{} + if err := json.Unmarshal(payload, e); err != nil { + return nil, err + } + return &audit.Entry{ + Action: audit.ActionTeamDeleted, + Actor: auditActorFromDoerRef(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + }, nil + }) + audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberAdded, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) + audit.RegisterEventForAudit(func(e *TeamMemberRemovedEvent) *audit.Entry { + return &audit.Entry{ + Action: audit.ActionTeamMemberRemoved, + Actor: auditActorFromUser(e.Doer), + Target: audit.TeamTarget(e.Team.ID), + Metadata: map[string]any{"member_id": e.Member.ID}, + } + }) } ////// From dbdf4a04cb24741a3def312beb7b942e03c47a4a Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:16:21 +0200 Subject: [PATCH 015/111] test(audit): cover listener pipeline, license gating and rotation --- pkg/audit/audit_test.go | 254 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 pkg/audit/audit_test.go diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go new file mode 100644 index 000000000..ef6ddc219 --- /dev/null +++ b/pkg/audit/audit_test.go @@ -0,0 +1,254 @@ +// 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 . + +package audit_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "code.vikunja.io/api/pkg/audit" + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/keyvalue" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + log.InitLogger() + config.InitDefaultConfig() + keyvalue.InitStorage() // license.SetForTests persists state through keyvalue + os.Exit(m.Run()) +} + +// One event type per test so each topic has exactly the listeners the test registered. +type pipelineEvent struct { + TaskID int64 `json:"task_id"` + DoerID int64 `json:"doer_id"` +} + +func (e *pipelineEvent) Name() string { return "test.audit.pipeline" } + +type licenseGateEvent struct { + Marker string `json:"marker"` +} + +func (e *licenseGateEvent) Name() string { return "test.audit.licensegate" } + +type rotationEvent struct { + Filler string `json:"filler"` +} + +func (e *rotationEvent) Name() string { return "test.audit.rotation" } + +// otherListener is a second, non-audit listener on the same topic. +type otherListener struct { + called chan struct{} +} + +func (l *otherListener) Handle(_ *message.Message) error { + select { + case l.called <- struct{}{}: + default: + } + return nil +} + +func (l *otherListener) Name() string { return "other" } + +var ( + registerTestEventsOnce sync.Once + other = &otherListener{called: make(chan struct{}, 16)} +) + +// The listener registry is global and watermill rejects duplicate handler +// names, so register once per process (relevant for -count > 1). +func registerTestEvents() { + registerTestEventsOnce.Do(func() { + audit.RegisterEventForAudit(func(e *pipelineEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.UserActor(e.DoerID), + Target: audit.TaskTarget(e.TaskID), + } + }) + events.RegisterListener((&pipelineEvent{}).Name(), other) + + audit.RegisterEventForAudit(func(e *licenseGateEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"marker": e.Marker}, + } + }) + + audit.RegisterEventForAudit(func(e *rotationEvent) *audit.Entry { + return &audit.Entry{ + Action: "task.created", + Actor: audit.SystemActor(), + Target: audit.TaskTarget(1), + Metadata: map[string]any{"filler": e.Filler}, + } + }) + }) +} + +func setupAuditFile(t *testing.T) string { + t.Helper() + logfile := filepath.Join(t.TempDir(), "audit.log") + config.AuditLogfile.Set(logfile) + require.NoError(t, audit.Init()) + t.Cleanup(audit.Close) + return logfile +} + +func startEventRouter(t *testing.T) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + ready, err := events.InitEventsForTesting(ctx) + require.NoError(t, err) + <-ready +} + +func waitForLines(t *testing.T, logfile string, count int) []string { + t.Helper() + var lines []string + require.Eventuallyf(t, func() bool { + content, err := os.ReadFile(logfile) + if err != nil { + return false + } + lines = strings.Split(strings.TrimSpace(string(content)), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + return len(lines) >= count + }, 5*time.Second, 10*time.Millisecond, "expected %d audit log lines", count) + return lines +} + +func TestAuditPipeline(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + ctx := events.WithRequestMeta(context.Background(), &events.RequestMeta{ + IP: "192.0.2.42", + UserAgent: "test-agent/1.0", + RequestID: "req-123", + }) + require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7})) + + lines := waitForLines(t, logfile, 1) + select { + case <-other.called: + case <-time.After(5 * time.Second): + t.Fatal("other listener on the same topic was not called") + } + // A topic with multiple listeners must produce exactly one audit entry. + events.WaitForPendingHandlers() + lines = waitForLines(t, logfile, 1) + require.Len(t, lines, 1) + + var entry audit.Entry + require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry)) + assert.NotEmpty(t, entry.EventID) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "task.created", entry.Action) + assert.Equal(t, audit.UserActor(7), entry.Actor) + assert.Equal(t, audit.TaskTarget(99), entry.Target) + assert.Equal(t, audit.OutcomeSuccess, entry.Outcome) + assert.Equal(t, "192.0.2.42", entry.Source.IP) + assert.Equal(t, "test-agent/1.0", entry.Source.UserAgent) + assert.Equal(t, audit.SourceHTTP, entry.Source.Type) + assert.Equal(t, "req-123", entry.RequestID) +} + +func TestAuditLicenseGating(t *testing.T) { + logfile := setupAuditFile(t) + + registerTestEvents() + startEventRouter(t) + + // Without the licensed feature nothing must be written. The license check + // happens per event at handle time, so give the async handler a settle + // window before flipping the license back on. + license.ResetForTests() + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "unlicensed"})) + require.Never(t, func() bool { + content, err := os.ReadFile(logfile) + return err == nil && len(content) > 0 + }, 500*time.Millisecond, 10*time.Millisecond, "unlicensed event must not be written") + events.WaitForPendingHandlers() + + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"})) + + lines := waitForLines(t, logfile, 1) + require.Len(t, lines, 1) + assert.Contains(t, lines[0], `"marker":"licensed"`) + assert.NotContains(t, lines[0], "unlicensed") + assert.Contains(t, lines[0], `"type":"system"`) +} + +func TestAuditRotation(t *testing.T) { + logfile := setupAuditFile(t) + license.SetForTests([]license.Feature{license.FeatureAuditLogs}) + t.Cleanup(license.ResetForTests) + + registerTestEvents() + startEventRouter(t) + + // Default max size is 100MB and config values are MB-granular, so two + // entries of ~600KB cross the limit with maxsizemb set to 1. + config.AuditRotationMaxSizeMB.Set("1") + t.Cleanup(func() { config.AuditRotationMaxSizeMB.Set("100") }) + require.NoError(t, audit.Init()) + + filler := strings.Repeat("x", 600*1024) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile, 1) + require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) + waitForLines(t, logfile, 1) + + require.Eventually(t, func() bool { + rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log") + return err == nil && len(rotated) == 1 + }, 5*time.Second, 10*time.Millisecond, "expected one rotated audit log file") +} + +func TestWriteAuditEventNotInitialized(t *testing.T) { + audit.Close() + err := audit.WriteAuditEvent(&audit.Entry{Action: "task.created"}) + require.Error(t, err) +} From fc831719cd3e4ab1c63305f8442c1e7c5b882d3f Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:18:34 +0200 Subject: [PATCH 016/111] docs(audit): add package documentation --- pkg/audit/doc.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 pkg/audit/doc.go diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go new file mode 100644 index 000000000..f6d04d64d --- /dev/null +++ b/pkg/audit/doc.go @@ -0,0 +1,44 @@ +// 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 . + +// 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 From 9da51f50960218b264f1e2fefa6e0a8b3a5df888 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:23:29 +0200 Subject: [PATCH 017/111] 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. --- pkg/audit/audit_test.go | 18 ++++++++--------- pkg/audit/entry.go | 6 +++--- pkg/events/events.go | 20 ++----------------- pkg/events/events_test.go | 9 +++++---- pkg/models/task_comments_test.go | 3 ++- pkg/models/tasks_test.go | 5 +++-- pkg/models/time_tracking_test.go | 13 ++++++------ .../migration/create_from_structure.go | 3 ++- pkg/routes/api/v1/user_export.go | 2 +- pkg/routes/api/v2/time_entries.go | 2 +- pkg/routes/caldav/listStorageProvider.go | 7 ++++--- pkg/web/handler/core.go | 15 +++++--------- 12 files changed, 44 insertions(+), 59 deletions(-) diff --git a/pkg/audit/audit_test.go b/pkg/audit/audit_test.go index ef6ddc219..897ebe93c 100644 --- a/pkg/audit/audit_test.go +++ b/pkg/audit/audit_test.go @@ -136,10 +136,10 @@ func startEventRouter(t *testing.T) { <-ready } -func waitForLines(t *testing.T, logfile string, count int) []string { +func waitForLines(t *testing.T, logfile string) []string { t.Helper() var lines []string - require.Eventuallyf(t, func() bool { + require.Eventually(t, func() bool { content, err := os.ReadFile(logfile) if err != nil { return false @@ -148,8 +148,8 @@ func waitForLines(t *testing.T, logfile string, count int) []string { if len(lines) == 1 && lines[0] == "" { lines = nil } - return len(lines) >= count - }, 5*time.Second, 10*time.Millisecond, "expected %d audit log lines", count) + return len(lines) >= 1 + }, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line") return lines } @@ -168,7 +168,7 @@ func TestAuditPipeline(t *testing.T) { }) require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7})) - lines := waitForLines(t, logfile, 1) + waitForLines(t, logfile) select { case <-other.called: case <-time.After(5 * time.Second): @@ -176,7 +176,7 @@ func TestAuditPipeline(t *testing.T) { } // A topic with multiple listeners must produce exactly one audit entry. events.WaitForPendingHandlers() - lines = waitForLines(t, logfile, 1) + lines := waitForLines(t, logfile) require.Len(t, lines, 1) var entry audit.Entry @@ -214,7 +214,7 @@ func TestAuditLicenseGating(t *testing.T) { t.Cleanup(license.ResetForTests) require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"})) - lines := waitForLines(t, logfile, 1) + lines := waitForLines(t, logfile) require.Len(t, lines, 1) assert.Contains(t, lines[0], `"marker":"licensed"`) assert.NotContains(t, lines[0], "unlicensed") @@ -237,9 +237,9 @@ func TestAuditRotation(t *testing.T) { filler := strings.Repeat("x", 600*1024) require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) - waitForLines(t, logfile, 1) + waitForLines(t, logfile) require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler})) - waitForLines(t, logfile, 1) + waitForLines(t, logfile) require.Eventually(t, func() bool { rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log") diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go index e2ed91876..079629727 100644 --- a/pkg/audit/entry.go +++ b/pkg/audit/entry.go @@ -73,9 +73,9 @@ const ( ActionLoginSucceeded = "auth.login.succeeded" ActionLoginFailed = "auth.login.failed" ActionLogout = "auth.logout" - ActionAPITokenIssued = "auth.api_token.issued" - ActionAPITokenRevoked = "auth.api_token.revoked" - ActionAPITokenUsed = "auth.api_token.used" + ActionAPITokenIssued = "auth.api_token.issued" // #nosec G101 -- action identifier, not a credential + ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101 + ActionAPITokenUsed = "auth.api_token.used" // #nosec G101 ActionUserCreated = "user.created" diff --git a/pkg/events/events.go b/pkg/events/events.go index 882de2bbb..5973b132d 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -240,24 +240,11 @@ func DispatchWithContext(ctx context.Context, event Event) error { // pendingEventQueue holds the pending events and a mutex for thread-safe access type pendingEventQueue struct { mu sync.Mutex - ctx context.Context events []Event } var pendingEvents sync.Map // map[any]*pendingEventQueue -// SetContextForKey associates a request context with a transaction key so that -// events queued via DispatchOnCommit for the same key are dispatched with the -// request metadata from that context. The entry is removed by DispatchPending -// or CleanupPending — callers must guarantee one of them runs for the key. -func SetContextForKey(key any, ctx context.Context) { - val, _ := pendingEvents.LoadOrStore(key, &pendingEventQueue{}) - queue := val.(*pendingEventQueue) - queue.mu.Lock() - queue.ctx = ctx - queue.mu.Unlock() -} - // DispatchOnCommit stores an event to be dispatched later, after a transaction commits. // The key should be the *xorm.Session pointer associated with the transaction. // Call DispatchPending(key) after s.Commit() to actually dispatch the events. @@ -272,8 +259,9 @@ func DispatchOnCommit(key any, event Event) { // DispatchPending dispatches all events accumulated for the given key and removes them. // Call this after s.Commit(). Safe to call even if no events were registered. +// Request metadata on the context (see WithRequestMeta) is copied onto each message. // If any event fails to dispatch, the error is logged but remaining events are still dispatched. -func DispatchPending(key any) { +func DispatchPending(ctx context.Context, key any) { val, ok := pendingEvents.LoadAndDelete(key) if !ok { return @@ -281,10 +269,6 @@ func DispatchPending(key any) { queue := val.(*pendingEventQueue) // No need to lock here since we've already removed it from the map // and this key won't receive new events - ctx := queue.ctx - if ctx == nil { - ctx = context.Background() - } for _, event := range queue.events { if err := DispatchWithContext(ctx, event); err != nil { log.Errorf("Failed to dispatch event %s: %v", event.Name(), err) diff --git a/pkg/events/events_test.go b/pkg/events/events_test.go index f78396a50..186d12f4a 100644 --- a/pkg/events/events_test.go +++ b/pkg/events/events_test.go @@ -17,6 +17,7 @@ package events import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -40,7 +41,7 @@ func TestDispatchOnCommit(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) // Simulate post-commit dispatch - DispatchPending(key) + DispatchPending(context.Background(), key) // Now it should be dispatched assert.Equal(t, 1, CountDispatchedEvents("test.event")) @@ -57,7 +58,7 @@ func TestDispatchOnCommitMultipleEvents(t *testing.T) { assert.Equal(t, 0, CountDispatchedEvents("test.event")) - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 3, CountDispatchedEvents("test.event")) } @@ -74,7 +75,7 @@ func TestCleanupPending(t *testing.T) { CleanupPending(key) // Dispatching after cleanup should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) assert.Equal(t, 0, CountDispatchedEvents("test.event")) } @@ -85,7 +86,7 @@ func TestDispatchPendingNoEvents(t *testing.T) { key := new(int) // Should be a no-op - DispatchPending(key) + DispatchPending(context.Background(), key) // Verify no events were dispatched assert.Equal(t, 0, CountDispatchedEvents("test.event")) diff --git a/pkg/models/task_comments_test.go b/pkg/models/task_comments_test.go index 61f8f6dc4..988dc4f27 100644 --- a/pkg/models/task_comments_test.go +++ b/pkg/models/task_comments_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "fmt" "testing" @@ -45,7 +46,7 @@ func TestTaskComment_Create(t *testing.T) { assert.Equal(t, int64(1), tc.Author.ID) err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCommentCreatedEvent{}) db.AssertExists(t, "task_comments", map[string]interface{}{ diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index caa897740..7219b5ab8 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "testing" "time" @@ -70,7 +71,7 @@ func TestTask_Create(t *testing.T) { "bucket_id": 1, }, false) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TaskCreatedEvent{}) }) t.Run("with reminders", func(t *testing.T) { @@ -280,7 +281,7 @@ func TestTask_Update(t *testing.T) { err = s.Commit() require.NoError(t, err) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Verify exactly ONE task.updated event was dispatched count := events.CountDispatchedEvents("task.updated") assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count) diff --git a/pkg/models/time_tracking_test.go b/pkg/models/time_tracking_test.go index 91dc90ee6..6e5391d51 100644 --- a/pkg/models/time_tracking_test.go +++ b/pkg/models/time_tracking_test.go @@ -17,6 +17,7 @@ package models import ( + "context" "encoding/json" "testing" "time" @@ -596,7 +597,7 @@ func TestTimeEntry_Events(t *testing.T) { te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) }) @@ -612,7 +613,7 @@ func TestTimeEntry_Events(t *testing.T) { require.True(t, can) require.NoError(t, te.Update(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) @@ -624,7 +625,7 @@ func TestTimeEntry_Events(t *testing.T) { require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryDeletedEvent{}) }) @@ -637,7 +638,7 @@ func TestTimeEntry_Events(t *testing.T) { // entry 4 is user1's running timer; a new running timer auto-stops it require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryCreatedEvent{}) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) @@ -651,7 +652,7 @@ func TestTimeEntry_Events(t *testing.T) { te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd} require.NoError(t, te.Create(s, u)) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name())) assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop") }) @@ -665,7 +666,7 @@ func TestTimeEntry_Events(t *testing.T) { _, err := StopRunningTimer(s, u) require.NoError(t, err) require.NoError(t, s.Commit()) - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) events.AssertDispatched(t, &TimeEntryUpdatedEvent{}) }) } diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 0e9c9b942..d59dd6946 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -18,6 +18,7 @@ package migration import ( "bytes" + "context" "xorm.io/xorm" @@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) return nil } diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 3c07c9ebc..6efc311c0 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -97,7 +97,7 @@ func RequestUserDataExport(c *echo.Context) error { return err } - events.DispatchPending(s) + events.DispatchPending(c.Request().Context(), s) return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested data export. We will send you an email when it's ready."}) } diff --git a/pkg/routes/api/v2/time_entries.go b/pkg/routes/api/v2/time_entries.go index 3500677f7..a58ee8b92 100644 --- a/pkg/routes/api/v2/time_entries.go +++ b/pkg/routes/api/v2/time_entries.go @@ -155,7 +155,7 @@ func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models. events.CleanupPending(s) return nil, translateDomainError(err) } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return &singleBody[models.TimeEntry]{Body: entry}, nil } diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 5544d3ec7..60a151e2d 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -17,6 +17,7 @@ package caldav import ( + "context" "slices" "strconv" "strings" @@ -396,7 +397,7 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -473,7 +474,7 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( return nil, err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) // Build up the proper response rr := VikunjaProjectResourceAdapter{ @@ -516,7 +517,7 @@ func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error { return err } - events.DispatchPending(s) + events.DispatchPending(context.Background(), s) } return nil diff --git a/pkg/web/handler/core.go b/pkg/web/handler/core.go index fa037794b..25c91c069 100644 --- a/pkg/web/handler/core.go +++ b/pkg/web/handler/core.go @@ -30,7 +30,6 @@ import ( // Caller is responsible for body/path binding and validation before calling. func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -61,7 +60,7 @@ func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -71,7 +70,6 @@ func DoCreate(ctx context.Context, obj CObject, a web.Auth) error { // header in the Echo wrapper; Huma wrapper may ignore it. func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -102,7 +100,7 @@ func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, return 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return maxPermission, nil } @@ -112,7 +110,6 @@ func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, // nil-slice normalization remain the caller's responsibility. func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if cerr := s.Close(); cerr != nil { log.Errorf("Could not close session: %s", cerr) @@ -131,7 +128,7 @@ func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page return nil, 0, 0, err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return result, resultCount, total, nil } @@ -140,7 +137,6 @@ func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page // and validation before calling. func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -171,7 +167,7 @@ func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } @@ -180,7 +176,6 @@ func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error { // calling. func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { s := db.NewSession() - events.SetContextForKey(s, ctx) defer func() { if err := s.Close(); err != nil { log.Errorf("Could not close session: %s", err) @@ -211,6 +206,6 @@ func DoDelete(ctx context.Context, obj CObject, a web.Auth) error { return err } - events.DispatchPending(s) + events.DispatchPending(ctx, s) return nil } From b86710903b89548d0b7f8a739360a56706ea2784 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 21:32:14 +0200 Subject: [PATCH 018/111] 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. --- pkg/modules/auth/openid/openid.go | 9 +++++++++ pkg/routes/api/shared/auth.go | 10 +++++++++- pkg/routes/api/v1/login.go | 5 +++++ pkg/routes/api/v1/user_register.go | 2 +- pkg/routes/api/v2/auth_public.go | 4 ++-- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 381570f42..b1fa3961a 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -27,6 +27,7 @@ import ( "strings" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" @@ -187,6 +188,9 @@ func HandleCallback(c *echo.Context) error { s := db.NewSession() defer s.Close() + // Discards events queued during a rolled-back transaction (e.g. user + // creation); a no-op once DispatchPending has run. + defer events.CleanupPending(s) // Check if we have seen this user before u, err := getOrCreateUser(s, cl, provider, idToken) @@ -212,6 +216,9 @@ func HandleCallback(c *echo.Context) error { if err := enforceTOTPIfRequired(s, u, cb.TOTPPasscode); err != nil { if commitErr := s.Commit(); commitErr != nil { log.Errorf("Error committing session after failed OIDC TOTP attempt for user %d: %v", u.ID, commitErr) + } else { + // The user creation above was committed, so its events are real. + events.DispatchPending(c.Request().Context(), s) } if user.IsErrInvalidTOTPPasscode(err) { user.HandleFailedTOTPAuth(u) @@ -233,6 +240,8 @@ func HandleCallback(c *echo.Context) error { return err } + events.DispatchPending(c.Request().Context(), s) + // Create token return auth.NewUserAuthTokenResponse(u, c, false) } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index d11a1cdbb..925a533d8 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -17,8 +17,11 @@ package shared import ( + "context" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" @@ -39,9 +42,12 @@ type UserRegister struct { // busts the cached user-count metric so the registration shows up immediately. // The caller is responsible for the registration-enabled gate and input // validation; both v1 and v2 share this body. -func RegisterUser(in *UserRegister) (*user.User, error) { +func RegisterUser(ctx context.Context, in *UserRegister) (*user.User, error) { s := db.NewSession() defer s.Close() + // Discards events queued during a rolled-back transaction; a no-op once + // DispatchPending has run. + defer events.CleanupPending(s) newUser, err := models.RegisterUser(s, &user.User{ Username: in.Username, @@ -59,6 +65,8 @@ func RegisterUser(in *UserRegister) (*user.User, error) { return nil, err } + events.DispatchPending(ctx, s) + // Bust the cached user count so the new registration shows up in metrics // immediately instead of after the regular cache expiry. if config.MetricsEnabled.GetBool() { diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 6c7eb686a..7385ed1f6 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -53,6 +53,9 @@ func Login(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() + // Discards events queued during a rolled-back transaction (e.g. LDAP user + // creation); a no-op once DispatchPending has run. + defer events.CleanupPending(s) var user *user2.User if config.AuthLdapEnabled.GetBool() { @@ -127,6 +130,8 @@ func Login(c *echo.Context) (err error) { return err } + events.DispatchPending(c.Request().Context(), s) + // Create token return auth.NewUserAuthTokenResponse(user, c, u.LongToken) } diff --git a/pkg/routes/api/v1/user_register.go b/pkg/routes/api/v1/user_register.go index 1c70df765..e9a90dc2f 100644 --- a/pkg/routes/api/v1/user_register.go +++ b/pkg/routes/api/v1/user_register.go @@ -63,7 +63,7 @@ func RegisterUser(c *echo.Context) error { return c.JSON(http.StatusBadRequest, models.Message{Message: "No or invalid user model provided."}) } - newUser, err := shared.RegisterUser(userIn) + newUser, err := shared.RegisterUser(c.Request().Context(), userIn) if err != nil { return err } diff --git a/pkg/routes/api/v2/auth_public.go b/pkg/routes/api/v2/auth_public.go index 5791b7c3f..c41fb162d 100644 --- a/pkg/routes/api/v2/auth_public.go +++ b/pkg/routes/api/v2/auth_public.go @@ -127,8 +127,8 @@ func registerLocalAuthRoutes(api huma.API) { }, authConfirmEmail) } -func authRegister(_ context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { - newUser, err := shared.RegisterUser(&in.Body) +func authRegister(ctx context.Context, in *struct{ Body shared.UserRegister }) (*registerUserBody, error) { + newUser, err := shared.RegisterUser(ctx, &in.Body) if err != nil { return nil, translateDomainError(err) } From 2e0e8e9582f284fd125f87fc3be4618c69b4ca9a Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:30:39 +0200 Subject: [PATCH 019/111] refactor(audit): move package docs into entry.go --- pkg/audit/doc.go | 44 -------------------------------------------- pkg/audit/entry.go | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 44 deletions(-) delete mode 100644 pkg/audit/doc.go diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go deleted file mode 100644 index f6d04d64d..000000000 --- a/pkg/audit/doc.go +++ /dev/null @@ -1,44 +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 . - -// 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 diff --git a/pkg/audit/entry.go b/pkg/audit/entry.go index 079629727..bb7f98493 100644 --- a/pkg/audit/entry.go +++ b/pkg/audit/entry.go @@ -14,6 +14,33 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// 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" From 10717556253c5d1ff1a383c7d80f2006fbba651e Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:32:25 +0200 Subject: [PATCH 020/111] fix(routes): generate request IDs at the start of the middleware chain Echo's RequestID middleware reuses the X-Request-Id header from a proxy or generates one, so logging and audit all see the same ID. RequestMeta previously read the request header before any later middleware could have set one, leaving the audit request_id mostly empty. --- pkg/routes/middleware/request_meta.go | 13 +++++-------- pkg/routes/routes.go | 5 +++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/routes/middleware/request_meta.go b/pkg/routes/middleware/request_meta.go index 747a37826..865cd5cde 100644 --- a/pkg/routes/middleware/request_meta.go +++ b/pkg/routes/middleware/request_meta.go @@ -22,21 +22,18 @@ import ( "github.com/labstack/echo/v5" ) -// RequestMeta stashes IP, User-Agent and X-Request-ID on the request context -// so events dispatched while handling the request carry them as message -// metadata (consumed by the audit listeners). +// RequestMeta stashes IP, User-Agent and the request ID on the request +// context so events dispatched while handling the request carry them as +// message metadata (consumed by the audit listeners). Must run after the +// RequestID middleware, which guarantees the response header is populated. func RequestMeta() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c *echo.Context) error { req := c.Request() - requestID := req.Header.Get(echo.HeaderXRequestID) - if requestID == "" { - requestID = c.Response().Header().Get(echo.HeaderXRequestID) - } ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{ IP: c.RealIP(), UserAgent: req.UserAgent(), - RequestID: requestID, + RequestID: c.Response().Header().Get(echo.HeaderXRequestID), }) c.SetRequest(req.WithContext(ctx)) return next(c) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index f8fc1609f..9f6af5af3 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -155,6 +155,11 @@ func NewEcho() *echo.Echo { e.Logger = log.NewEchoLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString()) + // First middleware in the chain so every request has an ID — reuses the + // X-Request-Id header from a proxy or generates one — and everything + // downstream (logging, audit) sees the same value. + e.Use(middleware.RequestID()) + // Logger if config.LogEnabled.GetBool() && config.LogHTTP.GetString() != "off" { httpLogger := log.NewHTTPLogger(config.LogEnabled.GetBool(), config.LogHTTP.GetString(), config.LogFormat.GetString()) From 5d7812a093f717d28b7f09c50438d5f06b814f20 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:33:08 +0200 Subject: [PATCH 021/111] fix(audit): handle reopen failure after a failed rotation If both the rename and the reopen fail, logFile stayed nil while initialized was still true, panicking on the next write. Propagate the reopen error and retry the open on the next write so it self-heals. --- pkg/audit/writer.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go index 548c380fe..feccdb6f3 100644 --- a/pkg/audit/writer.go +++ b/pkg/audit/writer.go @@ -18,6 +18,7 @@ package audit import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -132,6 +133,15 @@ 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 { @@ -159,7 +169,9 @@ 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. - _ = openLogFileLocked() + if openErr := openLogFileLocked(); openErr != nil { + return errors.Join(fmt.Errorf("could not rotate audit log: %w", err), openErr) + } return fmt.Errorf("could not rotate audit log: %w", err) } From 3291556821f778f711d05267cb2a8fe52bc2c578 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:33:43 +0200 Subject: [PATCH 022/111] fix(audit): only attribute the logout event to user tokens Link share JWTs carry no sid claim so they returned before the event fired, but the id claim was read without checking the token type. Make the guard explicit so a link share id can never appear as a user id. --- pkg/routes/api/v1/login.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 7385ed1f6..8bcde0dcf 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -243,8 +243,12 @@ func Logout(c *echo.Context) (err error) { if jwtinf, ok := raw.(*jwt.Token); ok { if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok { sid, _ = claims["sid"].(string) - if id, ok := claims["id"].(float64); ok { - userID = int64(id) + // Only user tokens carry a sid, but check the type explicitly + // so a link share id can never be logged as a user id. + if typ, ok := claims["type"].(float64); ok && int(typ) == auth.AuthTypeUser { + if id, ok := claims["id"].(float64); ok { + userID = int64(id) + } } } } From f33cde82e2a637a98df577442fe22c00738725e3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:34:45 +0200 Subject: [PATCH 023/111] feat(audit): attribute failed logins to the originating request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the request context through CheckUserCredentials so the LoginFailedEvent carries IP, user agent and request id — without it, failed logins were the one auth event useless for brute-force tracing. All four callers have the request at hand. --- pkg/models/user_settings.go | 6 ++++-- pkg/routes/api/v1/login.go | 2 +- pkg/routes/api/v1/user_update_email.go | 2 +- pkg/routes/api/v1/user_update_password.go | 2 +- pkg/routes/api/v2/user_settings.go | 4 ++-- pkg/routes/caldav/auth.go | 2 +- pkg/user/update_email.go | 6 ++++-- pkg/user/user.go | 12 +++++++----- pkg/user/user_test.go | 19 ++++++++++--------- 9 files changed, 31 insertions(+), 24 deletions(-) diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go index cba87cdb5..0d905cd1a 100644 --- a/pkg/models/user_settings.go +++ b/pkg/models/user_settings.go @@ -17,6 +17,8 @@ package models import ( + "context" + "code.vikunja.io/api/pkg/modules/avatar" "code.vikunja.io/api/pkg/user" @@ -66,12 +68,12 @@ func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { // ChangeUserPassword verifies the old password, sets the new one, and // invalidates all of the user's sessions. Lives here (not in pkg/user) because // it needs DeleteAllUserSessions, which pkg/user cannot import. -func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error { +func ChangeUserPassword(ctx context.Context, s *xorm.Session, u *user.User, oldPassword, newPassword string) error { if oldPassword == "" { return user.ErrEmptyOldPassword{} } - if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + if _, err := user.CheckUserCredentials(ctx, s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { return err } diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 8bcde0dcf..ae92e1d72 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -77,7 +77,7 @@ func Login(c *echo.Context) (err error) { } // This allows us to still have local users while ldap is enabled - user, err = user2.CheckUserCredentials(s, &u) + user, err = user2.CheckUserCredentials(c.Request().Context(), s, &u) if err != nil { _ = s.Rollback() return err diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index e73ba6f89..7e03b250a 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,7 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { + if err := user.ChangeUserEmail(c.Request().Context(), s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go index 52941a48a..87b372aff 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -66,7 +66,7 @@ func UserChangePassword(c *echo.Context) error { s := db.NewSession() defer s.Close() - if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { + if err := models.ChangeUserPassword(c.Request().Context(), s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go index a1f5bbee4..35a366644 100644 --- a/pkg/routes/api/v2/user_settings.go +++ b/pkg/routes/api/v2/user_settings.go @@ -176,7 +176,7 @@ func userChangePassword(ctx context.Context, in *struct { s := db.NewSession() defer s.Close() - if err := models.ChangeUserPassword(s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + if err := models.ChangeUserPassword(ctx, s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { _ = s.Rollback() return nil, translateDomainError(err) } @@ -206,7 +206,7 @@ func userUpdateEmail(ctx context.Context, in *struct { s := db.NewSession() defer s.Close() - if err := user.ChangeUserEmail(s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + if err := user.ChangeUserEmail(ctx, s, doer, in.Body.Password, in.Body.NewEmail); err != nil { _ = s.Rollback() return nil, translateDomainError(err) } diff --git a/pkg/routes/caldav/auth.go b/pkg/routes/caldav/auth.go index 930b8f013..fc89d7555 100644 --- a/pkg/routes/caldav/auth.go +++ b/pkg/routes/caldav/auth.go @@ -88,7 +88,7 @@ func BasicAuth(c *echo.Context, username, password string) (bool, error) { return false, nil } if u == nil { - u, err = user.CheckUserCredentials(s, credentials) + u, err = user.CheckUserCredentials(c.Request().Context(), s, credentials) if err != nil { log.Errorf("Error during basic auth for caldav: %v", err) return false, nil diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index b721ba518..26606c152 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -17,6 +17,8 @@ package user import ( + "context" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/notifications" "xorm.io/xorm" @@ -34,8 +36,8 @@ type EmailUpdate struct { // ChangeUserEmail verifies the user's password, then sets a new email address // (kicking off confirmation when the mailer is enabled). Shared by the v1 and // v2 email-update handlers; only HTTP input binding stays in the handlers. -func ChangeUserEmail(s *xorm.Session, u *User, password, newEmail string) error { - verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password}) +func ChangeUserEmail(ctx context.Context, s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(ctx, s, &Login{Username: u.Username, Password: password}) if err != nil { return err } diff --git a/pkg/user/user.go b/pkg/user/user.go index ab2912982..09fef2565 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -17,6 +17,7 @@ package user import ( + "context" "encoding/json" "errors" "fmt" @@ -363,8 +364,9 @@ func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User, return } -// CheckUserCredentials checks user credentials -func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { +// CheckUserCredentials checks user credentials. The context carries request +// metadata for the audit trail of failed attempts. +func CheckUserCredentials(ctx context.Context, s *xorm.Session, u *Login) (*User, error) { // Check if we have any credentials if u.Password == "" || u.Username == "" { return nil, ErrNoUsernamePassword{} @@ -391,7 +393,7 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) { err = CheckUserPassword(user, u.Password) if err != nil { if IsErrWrongUsernameOrPassword(err) { - handleFailedPassword(user) + handleFailedPassword(ctx, user) } return user, err } @@ -411,8 +413,8 @@ func (u *User) IsLocalUser() bool { return u.Issuer == IssuerLocal } -func handleFailedPassword(user *User) { - if err := events.Dispatch(&LoginFailedEvent{User: user}); err != nil { +func handleFailedPassword(ctx context.Context, user *User) { + if err := events.DispatchWithContext(ctx, &LoginFailedEvent{User: user}); err != nil { log.Errorf("Could not dispatch login failed event: %s", err) } diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index 776a60b5d..38287c6e0 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -17,6 +17,7 @@ package user import ( + "context" "testing" "code.vikunja.io/api/pkg/db" @@ -357,7 +358,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345678"}) require.NoError(t, err) }) t.Run("unverified email", func(t *testing.T) { @@ -365,7 +366,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user5", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user5", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrEmailNotConfirmed(err)) }) @@ -374,7 +375,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1", Password: "12345"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1", Password: "12345"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -383,7 +384,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "dfstestuu", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "dfstestuu", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrWrongUsernameOrPassword(err)) }) @@ -392,7 +393,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -401,7 +402,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrNoUsernamePassword(err)) }) @@ -410,7 +411,7 @@ func TestCheckUserCredentials(t *testing.T) { s := db.NewSession() defer s.Close() - _, err := CheckUserCredentials(s, &Login{Username: "user1@example.com", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user1@example.com", Password: "12345678"}) require.NoError(t, err) }) t.Run("disabled user", func(t *testing.T) { @@ -419,7 +420,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user17 is disabled (status=2), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user17", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user17", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountDisabled(err)) }) @@ -429,7 +430,7 @@ func TestCheckUserCredentials(t *testing.T) { defer s.Close() // user18 is locked (status=3), password is "12345678" - _, err := CheckUserCredentials(s, &Login{Username: "user18", Password: "12345678"}) + _, err := CheckUserCredentials(context.Background(), s, &Login{Username: "user18", Password: "12345678"}) require.Error(t, err) assert.True(t, IsErrAccountLocked(err)) }) From b3bcab1f729145bb6fbd98a215dbcf8d20b36475 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 21:39:08 +0200 Subject: [PATCH 024/111] refactor(events): use a concrete doer on project and team events ProjectUpdated/Deleted, ProjectSharedWith* and TeamCreated/Deleted carried an interface-typed Doer that could not be unmarshaled, forcing the audit registrations to decode anonymous mirror structs. Hydrate the doer via GetUserOrLinkShareUser at the dispatch sites like the task events already do, register the events directly and drop the untyped audit registration path. Webhook payloads for these events now serialize link share doers as their pseudo-user (negative id) instead of the raw link share object, consistent with task events. --- pkg/audit/listener.go | 18 ++----- pkg/models/events.go | 25 +++++----- pkg/models/listeners.go | 97 +++++++------------------------------ pkg/models/project.go | 12 ++++- pkg/models/project_team.go | 6 ++- pkg/models/project_users.go | 6 ++- pkg/models/teams.go | 8 ++- 7 files changed, 59 insertions(+), 113 deletions(-) diff --git a/pkg/audit/listener.go b/pkg/audit/listener.go index c0454512a..599a9b385 100644 --- a/pkg/audit/listener.go +++ b/pkg/audit/listener.go @@ -45,27 +45,15 @@ 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 } - entry, err := toEntry(msg.Payload) - if err != nil { + e := PT(new(T)) // fresh instance per message — handlers run concurrently + if err := json.Unmarshal(msg.Payload, e); err != nil { return err } + entry := toEntry(e) if entry == nil { return nil } diff --git a/pkg/models/events.go b/pkg/models/events.go index 1996f54b8..b938345f4 100644 --- a/pkg/models/events.go +++ b/pkg/models/events.go @@ -18,7 +18,6 @@ package models import ( "code.vikunja.io/api/pkg/user" - "code.vikunja.io/api/pkg/web" ) ///////////////// @@ -230,8 +229,8 @@ func (l *ProjectCreatedEvent) Name() string { // ProjectUpdatedEvent represents an event where a project has been updated type ProjectUpdatedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectUpdatedEvent @@ -241,8 +240,8 @@ func (p *ProjectUpdatedEvent) Name() string { // ProjectDeletedEvent represents an event where a project has been deleted type ProjectDeletedEvent struct { - Project *Project `json:"project"` - Doer web.Auth `json:"doer"` + Project *Project `json:"project"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectDeletedEvent @@ -258,7 +257,7 @@ func (p *ProjectDeletedEvent) Name() string { type ProjectSharedWithUserEvent struct { Project *Project `json:"project"` User *user.User `json:"user"` - Doer web.Auth `json:"doer"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithUserEvent @@ -268,9 +267,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 web.Auth `json:"doer"` + Project *Project `json:"project"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for ProjectSharedWithTeamEvent @@ -308,8 +307,8 @@ func (t *TeamMemberRemovedEvent) Name() string { // TeamCreatedEvent represents a TeamCreatedEvent event type TeamCreatedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamCreatedEvent @@ -319,8 +318,8 @@ func (t *TeamCreatedEvent) Name() string { // TeamDeletedEvent represents a TeamDeletedEvent event type TeamDeletedEvent struct { - Team *Team `json:"team"` - Doer web.Auth `json:"doer"` + Team *Team `json:"team"` + Doer *user.User `json:"doer"` } // Name defines the name for TeamDeletedEvent diff --git a/pkg/models/listeners.go b/pkg/models/listeners.go index e29bb2369..e50631ae1 100644 --- a/pkg/models/listeners.go +++ b/pkg/models/listeners.go @@ -88,23 +88,6 @@ func RegisterListeners() { } } -// auditDoerRef decodes the doer of events whose Doer field is an interface -// and thus can't be unmarshaled into the event struct directly. -type auditDoerRef struct { - ID int64 `json:"id"` - Hash string `json:"hash"` // only set when the doer is a link share -} - -func auditActorFromDoerRef(d *auditDoerRef) audit.Actor { - if d == nil { - return audit.SystemActor() - } - if d.Hash != "" { - return audit.LinkShareActor(d.ID) - } - return audit.ActorFromDoerID(d.ID) -} - func auditActorFromUser(u *user.User) audit.Actor { if u == nil { return audit.SystemActor() @@ -281,95 +264,51 @@ func registerEventsForAuditLogging() { Target: audit.ProjectTarget(e.Project.ID), } }) - audit.RegisterEventNameForAudit((&ProjectUpdatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err - } + audit.RegisterEventForAudit(func(e *ProjectUpdatedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectUpdated, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectDeletedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectDeleted, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectSharedWithUserEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - User *user.User `json:"user"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithUserEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectSharedWithUser, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), Metadata: map[string]any{"user_id": e.User.ID}, - }, nil - }) - audit.RegisterEventNameForAudit((&ProjectSharedWithTeamEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Project *Project `json:"project"` - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *ProjectSharedWithTeamEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionProjectSharedWithTeam, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.ProjectTarget(e.Project.ID), Metadata: map[string]any{"team_id": e.Team.ID}, - }, nil + } }) // Teams - audit.RegisterEventNameForAudit((&TeamCreatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err - } + audit.RegisterEventForAudit(func(e *TeamCreatedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionTeamCreated, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.TeamTarget(e.Team.ID), - }, nil - }) - audit.RegisterEventNameForAudit((&TeamDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) { - e := &struct { - Team *Team `json:"team"` - Doer *auditDoerRef `json:"doer"` - }{} - if err := json.Unmarshal(payload, e); err != nil { - return nil, err } + }) + audit.RegisterEventForAudit(func(e *TeamDeletedEvent) *audit.Entry { return &audit.Entry{ Action: audit.ActionTeamDeleted, - Actor: auditActorFromDoerRef(e.Doer), + Actor: auditActorFromUser(e.Doer), Target: audit.TeamTarget(e.Team.ID), - }, nil + } }) audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry { return &audit.Entry{ diff --git a/pkg/models/project.go b/pkg/models/project.go index 23fc9f6ca..019afe792 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1217,9 +1217,13 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje return err } + doer, err := GetUserOrLinkShareUser(s, auth) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: auth, + Doer: doer, }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1448,9 +1452,13 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: a, + Doer: doer, }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 0c9fb6908..4f3ed9c42 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -109,10 +109,14 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { return err } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: a, + Doer: doer, }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 58ef71c38..1470dd1bb 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -115,10 +115,14 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { return err } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: a, + Doer: doer, }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 98c87161c..e1ac8887c 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -222,7 +222,7 @@ func (t *Team) CreateNewTeam(s *xorm.Session, a web.Auth, firstUserShouldBeAdmin events.DispatchOnCommit(s, &TeamCreatedEvent{ Team: t, - Doer: a, + Doer: doer, }) return nil } @@ -360,9 +360,13 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { return } + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil { + return err + } events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: a, + Doer: doer, }) return nil } From f0eff5294936c3617f459ecb9b7c77c0be49a696 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:36:07 +0200 Subject: [PATCH 025/111] fix(events): build event doers without re-fetching the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetUserOrLinkShareUser re-fetches the account and fails its status check, which broke deleting a disabled user's projects (the deletion runs with the disabled account as doer). Convert the authenticated principal directly instead — it also matches what the events serialized before the doer became concrete, and drops a query per event. --- pkg/models/project.go | 12 ++---------- pkg/models/project_team.go | 6 +----- pkg/models/project_users.go | 6 +----- pkg/models/teams.go | 6 +----- pkg/models/users.go | 14 ++++++++++++++ 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 019afe792..a799d1815 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1217,13 +1217,9 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje return err } - doer, err := GetUserOrLinkShareUser(s, auth) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: doer, + Doer: doerFromAuth(auth), }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1452,13 +1448,9 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { return } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: doer, + Doer: doerFromAuth(a), }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index 4f3ed9c42..e3571906c 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -109,14 +109,10 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { return err } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: doer, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 1470dd1bb..41254ac1d 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -115,14 +115,10 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { return err } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: doer, + Doer: doerFromAuth(a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/teams.go b/pkg/models/teams.go index e1ac8887c..6f73dc3ae 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -360,13 +360,9 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { return } - doer, err := GetUserOrLinkShareUser(s, a) - if err != nil { - return err - } events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: doer, + Doer: doerFromAuth(a), }) return nil } diff --git a/pkg/models/users.go b/pkg/models/users.go index da2b7af97..51b6ede2a 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -22,6 +22,20 @@ import ( "xorm.io/xorm" ) +// doerFromAuth converts the authenticated principal into a user for event +// payloads without re-fetching it. A re-fetch would fail its status check in +// flows acting on behalf of disabled accounts (e.g. user deletion), and the +// event only needs the principal as it authenticated. +func doerFromAuth(a web.Auth) *user.User { + if u, is := a.(*user.User); is { + return u + } + if share, is := a.(*LinkSharing); is { + return share.toUser() + } + return &user.User{ID: a.GetID()} +} + // GetUserOrLinkShareUser returns either a user or a link share disguised as a user. func GetUserOrLinkShareUser(s *xorm.Session, a web.Auth) (uu *user.User, err error) { if u, is := a.(*user.User); is { From 0eb39fae9a4f11cff08fff72f6fc752689c43792 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 09:44:43 +0200 Subject: [PATCH 026/111] fix(events): handle nil auth when building event doers ProjectUser.Create and friends are called with a nil auth in tests; the old interface-typed Doer just serialized as null, so a nil doer keeps that behavior (and maps to the system actor in the audit entry). --- pkg/models/users.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/models/users.go b/pkg/models/users.go index 51b6ede2a..84a7101da 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -27,6 +27,9 @@ import ( // flows acting on behalf of disabled accounts (e.g. user deletion), and the // event only needs the principal as it authenticated. func doerFromAuth(a web.Auth) *user.User { + if a == nil { + return nil + } if u, is := a.(*user.User); is { return u } From acdc2a07f26b8fff74e1ef2bba71a20ff5e6f2cc Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:43:38 +0200 Subject: [PATCH 027/111] feat(audit): emit the login event for the OAuth code exchange The new v2 OAuth token endpoint mints a fresh session without going through NewUserAuthTokenResponse, so those logins were missing from the audit trail. The refresh grant stays unaudited like the v1 refresh. --- pkg/modules/auth/oauth2server/token.go | 18 ++++++++++++++---- pkg/routes/api/v2/oauth.go | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 9d8d33a9a..11f85772e 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -17,10 +17,14 @@ package oauth2server import ( + "context" + "net/http" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/user" @@ -56,7 +60,7 @@ func HandleToken(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body") } - resp, err := ExchangeToken(&req, c.Request().UserAgent(), c.RealIP()) + resp, err := ExchangeToken(c.Request().Context(), &req, c.Request().UserAgent(), c.RealIP()) if err != nil { return err } @@ -69,10 +73,10 @@ func HandleToken(c *echo.Context) error { // token endpoint, independent of the HTTP layer. Callers own request binding and // the Cache-Control: no-store response header. deviceInfo/ipAddress are recorded // on the session created for the authorization_code grant. -func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func ExchangeToken(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { switch req.GrantType { case "authorization_code": - return exchangeAuthorizationCode(req, deviceInfo, ipAddress) + return exchangeAuthorizationCode(ctx, req, deviceInfo, ipAddress) case "refresh_token": return exchangeRefreshToken(req) default: @@ -80,7 +84,7 @@ func ExchangeToken(req *TokenRequest, deviceInfo, ipAddress string) (*TokenRespo } } -func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { +func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInfo, ipAddress string) (*TokenResponse, error) { s := db.NewSession() defer s.Close() @@ -133,6 +137,12 @@ func exchangeAuthorizationCode(req *TokenRequest, deviceInfo, ipAddress string) return nil, err } + // The code exchange mints a fresh session, so it is a login for the + // audit trail, same as NewUserAuthTokenResponse. + if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil { + log.Errorf("Could not dispatch login succeeded event: %s", err) + } + return &TokenResponse{ AccessToken: accessToken, TokenType: "bearer", diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go index 9b13c7654..45d1efe57 100644 --- a/pkg/routes/api/v2/oauth.go +++ b/pkg/routes/api/v2/oauth.go @@ -76,7 +76,7 @@ func oauthToken(ctx context.Context, in *struct { Body oauth2server.TokenRequest `contentType:"application/x-www-form-urlencoded"` }) (*oauthTokenBody, error) { deviceInfo, ipAddress := requestClientInfo(ctx) - resp, err := oauth2server.ExchangeToken(&in.Body, deviceInfo, ipAddress) + resp, err := oauth2server.ExchangeToken(ctx, &in.Body, deviceInfo, ipAddress) if err != nil { return nil, translateDomainError(err) } From 5e00fcbbb827bec84dd07539335e6622acdc982e Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:43:38 +0200 Subject: [PATCH 028/111] chore(lint): suppress contextcheck on OIDC provider init call sites Adding a context parameter to the shared package put its call chains in contextcheck's scope; the flagged background context in the provider setup is deliberate since provider lifetime exceeds any request. --- pkg/routes/api/v2/admin_users.go | 2 +- pkg/routes/api/v2/info.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go index 1588b1643..2724e433c 100644 --- a/pkg/routes/api/v2/admin_users.go +++ b/pkg/routes/api/v2/admin_users.go @@ -127,7 +127,7 @@ func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody if err != nil { return nil, translateDomainError(err) } - return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil + return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request } func adminUsersPatchAdmin(_ context.Context, in *struct { diff --git a/pkg/routes/api/v2/info.go b/pkg/routes/api/v2/info.go index 483abf027..3b4256363 100644 --- a/pkg/routes/api/v2/info.go +++ b/pkg/routes/api/v2/info.go @@ -47,5 +47,5 @@ func RegisterInfoRoutes(api huma.API) { func init() { AddRouteRegistrar(RegisterInfoRoutes) } func info(_ context.Context, _ *struct{}) (*infoBody, error) { - return &infoBody{Body: shared.BuildInfo()}, nil + return &infoBody{Body: shared.BuildInfo()}, nil //nolint:contextcheck // OIDC provider init deliberately uses a background context — provider lifetime exceeds the request } From 8381f7543f2051c5a1b0d612bdec94958d5bafb3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:00:42 +0200 Subject: [PATCH 029/111] refactor(background): share upload validation between v1 and v2 handlers Extract the MIME validation, file storage and project reload from the v1 UploadBackground handler into ValidateAndSaveBackgroundUpload so the upcoming v2 handler can reuse it instead of duplicating the logic. The v1 handler keeps its exact wire behaviour; the inline "not an image" check now returns a typed ErrFileIsNoImage that the handler maps to the same message. --- pkg/modules/background/handler/background.go | 68 +++++++++++--------- pkg/modules/background/handler/errors.go | 19 ++++++ 2 files changed, 57 insertions(+), 30 deletions(-) diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index a89784ee9..afe7901e5 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -204,44 +204,17 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error { } defer srcf.Close() - // Validate we're dealing with an image - mime, err := mimetype.DetectReader(srcf) - if err != nil { + if err := ValidateAndSaveBackgroundUpload(s, auth, project, srcf, file.Filename, uint64(file.Size)); err != nil { _ = s.Rollback() - return err - } - if !strings.HasPrefix(mime.String(), "image") { - _ = s.Rollback() - return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."}) - } - supported := false - for _, m := range allowedImageMimes { - if mime.Is(m) { - supported = true - break + if IsErrFileIsNoImage(err) { + return c.JSON(http.StatusBadRequest, models.Message{Message: "Uploaded file is no image."}) } - } - if !supported { - _ = s.Rollback() - return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")}) - } - - err = SaveBackgroundFile(s, auth, project, srcf, file.Filename, uint64(file.Size)) - if err != nil { - _ = s.Rollback() if files.IsErrFileIsTooLarge(err) { return echo.ErrBadRequest } if IsErrFileUnsupportedImageFormat(err) { return c.JSON(http.StatusBadRequest, models.Message{Message: "Unsupported image format. Allowed: " + strings.Join(allowedImageMimes, ",")}) } - - return err - } - - err = project.ReadOne(s, auth) - if err != nil { - _ = s.Rollback() return err } @@ -253,6 +226,41 @@ func (bp *BackgroundProvider) UploadBackground(c *echo.Context) error { return c.JSON(http.StatusOK, project) } +// ValidateAndSaveBackgroundUpload validates that srcf is a decodable image of an +// allowed type, stores it as the project's background and reloads the project so +// callers get the updated background metadata. It is the shared body of the v1 and +// v2 upload handlers; the multipart parsing and error-to-HTTP mapping stay in each +// handler. project must already be loaded and the caller must have verified write +// permission. On a non-image it returns ErrFileIsNoImage; on a recognized but +// undecodable format ErrFileUnsupportedImageFormat. +func ValidateAndSaveBackgroundUpload(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) error { + mime, err := mimetype.DetectReader(srcf) + if err != nil { + return err + } + if !strings.HasPrefix(mime.String(), "image") { + return ErrFileIsNoImage{Mime: mime.String()} + } + supported := false + for _, m := range allowedImageMimes { + if mime.Is(m) { + supported = true + break + } + } + if !supported { + return ErrFileUnsupportedImageFormat{Mime: mime.String()} + } + + // DetectReader consumed the head of the reader; SaveBackgroundFile seeks back to + // the start itself, so no rewind is needed here. + if err := SaveBackgroundFile(s, auth, project, srcf, filename, filesize); err != nil { + return err + } + + return project.ReadOne(s, auth) +} + func SaveBackgroundFile(s *xorm.Session, auth web.Auth, project *models.Project, srcf io.ReadSeeker, filename string, filesize uint64) (err error) { mime, _ := mimetype.DetectReader(srcf) _, _ = srcf.Seek(0, io.SeekStart) diff --git a/pkg/modules/background/handler/errors.go b/pkg/modules/background/handler/errors.go index beaf46657..dcddf1687 100644 --- a/pkg/modules/background/handler/errors.go +++ b/pkg/modules/background/handler/errors.go @@ -40,3 +40,22 @@ func IsErrFileUnsupportedImageFormat(err error) bool { ok := errors.As(err, &errFileUnsupportedImageFormat) return ok } + +// ErrFileIsNoImage is returned when an uploaded background does not sniff as an +// image at all (its detected mime type does not start with "image"). It is +// distinct from ErrFileUnsupportedImageFormat, which is a recognized image type +// the imaging library can't decode. +type ErrFileIsNoImage struct { + Mime string +} + +// Error is the error implementation of ErrFileIsNoImage +func (err ErrFileIsNoImage) Error() string { + return fmt.Sprintf("uploaded file is not an image [Mime: %s]", err.Mime) +} + +// IsErrFileIsNoImage checks if an error is ErrFileIsNoImage +func IsErrFileIsNoImage(err error) bool { + var errFileIsNoImage ErrFileIsNoImage + return errors.As(err, &errFileIsNoImage) +} From 3af5eb8208591123b4501abe153b1cca51cd67e2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:00:49 +0200 Subject: [PATCH 030/111] feat(api/v2): add project background upload on /api/v2 Port PUT /projects/{project}/backgrounds/upload to the Huma-backed v2 API. The multipart handler reuses handler.ValidateAndSaveBackgroundUpload (shared with v1), checks project write access explicitly, and is gated on the upload provider config flag. Adds webtests covering the happy path, auth/permission failures, non-image rejection, the disabled-provider case and the multipart spec shape. --- pkg/routes/api/v2/backgrounds.go | 75 ++++++++++ pkg/webtests/huma_background_upload_test.go | 151 ++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 pkg/webtests/huma_background_upload_test.go diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go index c56d1acce..f01fcb4e3 100644 --- a/pkg/routes/api/v2/backgrounds.go +++ b/pkg/routes/api/v2/backgrounds.go @@ -24,6 +24,7 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/background" + backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" "code.vikunja.io/api/pkg/modules/background/unsplash" "github.com/danielgtaylor/huma/v2" @@ -54,6 +55,22 @@ func RegisterBackgroundRoutes(api huma.API) { Tags: tags, }, backgroundRemove) + if config.BackgroundsUploadEnabled.GetBool() { + Register(api, huma.Operation{ + OperationID: "projects-background-upload", + Summary: "Upload a project background", + Description: "Uploads an image via multipart/form-data under the \"background\" field and sets it as the project's background. Requires write access to the project. The image is resized server-side and stored as JPEG; it replaces any previous background (idempotent replace, hence PUT). Returns the updated project.", + Method: http.MethodPut, + Path: "/projects/{project}/backgrounds/upload", + // Return the updated project with 200, the natural code for an idempotent PUT. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, backgroundUpload) + } + if config.BackgroundsUnsplashEnabled.GetBool() { Register(api, huma.Operation{ OperationID: "backgrounds-unsplash-search", @@ -152,6 +169,64 @@ func backgroundUnsplashSet(ctx context.Context, in *struct { return &singleBody[models.Project]{Body: project}, nil } +type backgroundUploadInput struct { + ProjectID int64 `path:"project" doc:"The id of the project to set the background on."` + // Allow-list mirrors the formats background uploads can actually be decoded as + // (handler.ValidateAndSaveBackgroundUpload's allowedImageMimes); octet-stream covers + // programmatic clients. Huma's MimeTypeValidator rejects the part pre-handler, so the + // byte-level image check in the shared function is the real gate. + RawBody huma.MultipartFormFiles[struct { + Background huma.FormFile `form:"background" contentType:"image/jpeg,image/png,image/gif,image/bmp,image/tiff,image/webp,application/octet-stream" required:"true" doc:"The background image to upload. Must be a decodable raster image (JPEG, PNG, GIF, BMP, TIFF or WebP); it is resized server-side and re-encoded as JPEG."` + }] +} + +// backgroundUpload owns auth, the session and the permission check because there is +// no handler.Do* for multipart uploads (see the api-v2-routes skill's "Non-CRUDable +// / custom routes" section). It shares its body with v1 via +// handler.ValidateAndSaveBackgroundUpload. +func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBody[models.Project], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, err := project.CanUpdate(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + project, err = models.GetProjectSimpleByID(s, in.ProjectID) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + file := in.RawBody.Data().Background + defer func() { _ = file.Close() }() + + if err := backgroundHandler.ValidateAndSaveBackgroundUpload(s, a, project, file, file.Filename, uint64(file.Size)); err != nil { + _ = s.Rollback() + if backgroundHandler.IsErrFileIsNoImage(err) || backgroundHandler.IsErrFileUnsupportedImageFormat(err) { + return nil, huma.Error400BadRequest(err.Error()) + } + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[models.Project]{Body: project}, nil +} + func backgroundRemove(ctx context.Context, in *struct { ProjectID int64 `path:"project"` }) (*singleBody[models.Project], error) { diff --git a/pkg/webtests/huma_background_upload_test.go b/pkg/webtests/huma_background_upload_test.go new file mode 100644 index 000000000..68755f53a --- /dev/null +++ b/pkg/webtests/huma_background_upload_test.go @@ -0,0 +1,151 @@ +// 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 . + +package webtests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartFileBody builds a multipart body with a single file part under the +// given field name. CreateFormFile sets the part Content-Type to +// application/octet-stream, mirroring how many programmatic clients upload. +func multipartFileBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile(fieldName, filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func uploadBackgroundRequest(t *testing.T, e *echo.Echo, project, token string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPut, "/api/v2/projects/"+project+"/backgrounds/upload", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestHumaProjectBackgroundUpload(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner uploads a background", func(t *testing.T) { + // testuser1 owns project 1, which starts without a background. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + s := db.NewSession() + defer s.Close() + project := models.Project{ID: 1} + has, err := s.Get(&project) + require.NoError(t, err) + require.True(t, has) + assert.NotZero(t, project.BackgroundFileID, "the upload must set a background file id") + assert.NotEmpty(t, project.BackgroundBlurHash, "the upload must compute a blur hash") + }) + + t.Run("Non-image rejected with 400", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "not-an-image.txt", []byte("this is plain text, not an image")) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Read-only user is forbidden", func(t *testing.T) { + // testuser15 has read-only access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", "", body, contentType) + require.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Renders as multipart in the OpenAPI spec", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var spec map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + paths, _ := spec["paths"].(map[string]any) + op, _ := paths["/projects/{project}/backgrounds/upload"].(map[string]any) + put, ok := op["put"].(map[string]any) + require.True(t, ok, "PUT /projects/{project}/backgrounds/upload must be in the spec") + content, _ := put["requestBody"].(map[string]any) + contentMap, _ := content["content"].(map[string]any) + mp, ok := contentMap["multipart/form-data"].(map[string]any) + require.True(t, ok, "background upload must be modeled as multipart/form-data") + schema, _ := mp["schema"].(map[string]any) + props, _ := schema["properties"].(map[string]any) + bgProp, ok := props["background"].(map[string]any) + require.True(t, ok, "the background field must appear in the multipart schema") + assert.Equal(t, "binary", bgProp["format"], "background field must be a binary file in the spec") + }) +} + +// TestHumaProjectBackgroundUploadDisabledByConfig verifies the upload route is +// absent (404) when the upload provider is disabled, even though backgrounds +// themselves are enabled. +func TestHumaProjectBackgroundUploadDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsUploadEnabled.Set(false) + defer config.BackgroundsUploadEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + rec := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when background upload is disabled; body: %s", rec.Body.String()) +} From a881246e802d41401d9c869e243a9b9b1c972cfd Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:28 +0200 Subject: [PATCH 031/111] refactor(migration): extract file/CSV migrate orchestration into shared funcs Pull the StartMigration -> Migrate -> FinishMigration orchestration out of the v1 echo handlers into handler.RunFileMigration and csv.RunMigration so the v2 API can reuse the exact same business logic. v1 is refactored onto them and stays byte-identical on the wire. Also tag the CSV detect/preview/config DTOs with doc:/enum: so they carry descriptions in the v2 OpenAPI schema (ignored by v1 swaggo/xorm). --- pkg/modules/migration/csv/csv.go | 48 ++++++++++++------- pkg/modules/migration/csv/handler.go | 14 +----- pkg/modules/migration/handler/handler_file.go | 31 +++++++----- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go index a0bee4f08..6d9ded38d 100644 --- a/pkg/modules/migration/csv/csv.go +++ b/pkg/modules/migration/csv/csv.go @@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{ // ColumnMapping represents a mapping from a CSV column to a task attribute type ColumnMapping struct { - ColumnIndex int `json:"column_index"` - ColumnName string `json:"column_name"` - Attribute TaskAttribute `json:"attribute"` + ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."` + ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."` + Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."` } // DetectionResult contains the auto-detected CSV structure type DetectionResult struct { - Columns []string `json:"columns"` - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SuggestedMapping []ColumnMapping `json:"suggested_mapping"` - PreviewRows [][]string `json:"preview_rows"` + Columns []string `json:"columns" doc:"The detected column header names, in order."` + Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."` + QuoteChar string `json:"quote_char" doc:"The detected quote character."` + DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."` + SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."` + PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."` } // ImportConfig contains the configuration for CSV import type ImportConfig struct { - Delimiter string `json:"delimiter"` - QuoteChar string `json:"quote_char"` - DateFormat string `json:"date_format"` - SkipRows int `json:"skip_rows"` - Mapping []ColumnMapping `json:"mapping"` + Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."` + QuoteChar string `json:"quote_char" doc:"The quote character to parse with."` + DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."` + SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."` + Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."` } // PreviewTask represents a task preview before import @@ -146,8 +146,8 @@ type PreviewTask struct { // PreviewResult contains preview data before import type PreviewResult struct { - Tasks []PreviewTask `json:"tasks"` - TotalRows int `json:"total_rows"` + Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."` + TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."` } // stripBOM removes the UTF-8 BOM from the beginning of a reader @@ -557,6 +557,22 @@ func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error { return &migration.ErrCSVConfigRequired{} } +// RunMigration records the migration's start, imports the CSV with the given +// config and records its finish. Shared by the v1 and v2 HTTP layers so the +// status bookkeeping around MigrateWithConfig lives in one place. +func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { + status, err := migration.StartMigration(&Migrator{}, u) + if err != nil { + return err + } + + if err := MigrateWithConfig(u, file, size, config); err != nil { + return err + } + + return migration.FinishMigration(status) +} + // MigrateWithConfig imports CSV data into Vikunja with the provided configuration func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { if size == 0 { diff --git a/pkg/modules/migration/csv/handler.go b/pkg/modules/migration/csv/handler.go index 389c13573..1a99c342d 100644 --- a/pkg/modules/migration/csv/handler.go +++ b/pkg/modules/migration/csv/handler.go @@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error { } defer src.Close() - m := &Migrator{} - status, err := migration.StartMigration(m, u) - if err != nil { - return err - } - - err = MigrateWithConfig(u, src, file.Size, &config) - if err != nil { - return err - } - - err = migration.FinishMigration(status) - if err != nil { + if err := RunMigration(u, src, file.Size, &config); err != nil { return err } diff --git a/pkg/modules/migration/handler/handler_file.go b/pkg/modules/migration/handler/handler_file.go index 8fae1d775..76b7f4d13 100644 --- a/pkg/modules/migration/handler/handler_file.go +++ b/pkg/modules/migration/handler/handler_file.go @@ -17,6 +17,7 @@ package handler import ( + "io" "net/http" "code.vikunja.io/api/pkg/models" @@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) { g.PUT("/"+ms.Name()+"/migrate", fw.Migrate) } +// RunFileMigration records the migration's start, runs the file migrator and +// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration +// lives in one place; the caller supplies the already-opened upload. +func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error { + m, err := migration.StartMigration(ms, u) + if err != nil { + return err + } + + if err := ms.Migrate(u, file, size); err != nil { + return err + } + + return migration.FinishMigration(m) +} + // Migrate calls the migration method func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { ms := fw.MigrationStruct() @@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error { } defer src.Close() - m, err := migration.StartMigration(ms, user) - if err != nil { - return err - } - - // Do the migration - err = ms.Migrate(user, src, file.Size) - if err != nil { - return err - } - - err = migration.FinishMigration(m) - if err != nil { + if err := RunFileMigration(ms, user, src, file.Size); err != nil { return err } From a21822fcec721f71596320cd3b2293ce036a3f0a Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:42 +0200 Subject: [PATCH 032/111] feat(api/v2): add file migrators (vikunja-file, ticktick, wekan) on /api/v2 Port the file-based migrators' status + migrate endpoints to the Huma API. A single registerFileMigrator helper wires all three (mirroring the OAuth migrator registrar); the migrate endpoint takes a multipart upload under the "import" field and reuses handler.RunFileMigration. POST migrate returns 200 since it runs an import rather than creating a REST resource. --- pkg/routes/api/v2/migration_file.go | 126 ++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 pkg/routes/api/v2/migration_file.go diff --git a/pkg/routes/api/v2/migration_file.go b/pkg/routes/api/v2/migration_file.go new file mode 100644 index 000000000..d02db596e --- /dev/null +++ b/pkg/routes/api/v2/migration_file.go @@ -0,0 +1,126 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/modules/migration/ticktick" + vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file" + "code.vikunja.io/api/pkg/modules/migration/wekan" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// fileMigrateInput is the multipart upload body shared by every file migrator's +// migrate endpoint. +type fileMigrateInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The export file to import. Its expected format depends on the migrator (e.g. a Vikunja export zip, a TickTick CSV, a WeKan JSON export)."` + }] +} + +// RegisterMigrationFileRoutes wires the file-based migrators (Vikunja export, +// TickTick, WeKan) onto the Huma API. Unlike the OAuth migrators these have no +// config flag in v1, so they are always registered. +func RegisterMigrationFileRoutes(api huma.API) { + registerFileMigrator(api, func() migration.FileMigrator { return &vikunja_file.FileMigrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &ticktick.Migrator{} }) + registerFileMigrator(api, func() migration.FileMigrator { return &wekan.Migrator{} }) +} + +func init() { AddRouteRegistrar(RegisterMigrationFileRoutes) } + +// registerFileMigrator registers status + migrate for a single file migrator. +// factory produces a fresh migrator instance per request, matching v1's +// MigrationStruct func so concurrent requests never share mutable state. +func registerFileMigrator(api huma.API, factory func() migration.FileMigrator) { + name := factory().Name() + tags := []string{"migration"} + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-status", + Summary: "Get the migration status for " + name, + Description: "Returns the migration status of the authenticated user for this service, i.e. whether and when they last migrated.", + Method: http.MethodGet, + Path: "/migration/" + name + "/status", + Tags: tags, + }, func(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + return migrationFileStatus(ctx, factory) + }) + + Register(api, huma.Operation{ + OperationID: "migration-" + name + "-migrate", + Summary: "Migrate from " + name, + Description: "Imports the authenticated user's data from an uploaded export file into Vikunja. Send the file under the multipart \"import\" field. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/" + name + "/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, func(ctx context.Context, in *fileMigrateInput) (*migrationStartedBody, error) { + return migrationFileMigrate(ctx, factory, in) + }) +} + +func migrationFileStatus(ctx context.Context, factory func() migration.FileMigrator) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(factory(), u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func migrationFileMigrate(ctx context.Context, factory func() migration.FileMigrator, in *fileMigrateInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := migrationHandler.RunFileMigration(factory(), u, src, src.Size); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} From 77416d32e428ff054f4d623ec7eac3d4cc5eaf87 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:42 +0200 Subject: [PATCH 033/111] feat(api/v2): add the generic CSV importer on /api/v2 Port the CSV importer's status/detect/preview/migrate endpoints to the Huma API. detect/preview/migrate take a multipart upload; preview and migrate also carry the import config as a JSON form value (modeled as a typed multipart form field), unmarshaled in one shared place and reused via csv.RunMigration. --- pkg/routes/api/v2/migration_csv.go | 200 +++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 pkg/routes/api/v2/migration_csv.go diff --git a/pkg/routes/api/v2/migration_csv.go b/pkg/routes/api/v2/migration_csv.go new file mode 100644 index 000000000..9f1922671 --- /dev/null +++ b/pkg/routes/api/v2/migration_csv.go @@ -0,0 +1,200 @@ +// 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 . + +package apiv2 + +import ( + "context" + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/modules/migration/csv" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// csvDetectInput is the detect upload: just the file. +type csvDetectInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to analyze."` + }] +} + +// csvImportInput is the preview/migrate upload: the file plus a JSON config +// blob carried as a multipart form value (mirrors v1's FormValue(\"config\")). +type csvImportInput struct { + RawBody huma.MultipartFormFiles[struct { + Import huma.FormFile `form:"import" required:"true" doc:"The CSV file to import."` + Config string `form:"config" required:"true" doc:"The import configuration as a JSON object (see the ImportConfig schema), passed as a multipart form value. Obtain a starting config from the detect endpoint."` + }] +} + +type csvDetectBody struct { + Body *csv.DetectionResult +} + +type csvPreviewBody struct { + Body *csv.PreviewResult +} + +// RegisterMigrationCSVRoutes wires the generic CSV importer onto the Huma API. +// Like the other file migrators it has no config flag in v1, so it is always +// registered. +func RegisterMigrationCSVRoutes(api huma.API) { + tags := []string{"migration"} + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + maxBody := (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024 + + Register(api, huma.Operation{ + OperationID: "migration-csv-status", + Summary: "Get the CSV migration status", + Description: "Returns the migration status of the authenticated user for the CSV importer, i.e. whether and when they last imported a CSV.", + Method: http.MethodGet, + Path: "/migration/csv/status", + Tags: tags, + }, csvStatus) + + Register(api, huma.Operation{ + OperationID: "migration-csv-detect", + Summary: "Detect a CSV file's structure", + Description: "Analyzes an uploaded CSV file and returns its detected columns, delimiter, quote character and date format, plus a suggested column-to-attribute mapping the client can edit before previewing or migrating. Read-only: nothing is imported.", + Method: http.MethodPost, + Path: "/migration/csv/detect", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvDetect) + + Register(api, huma.Operation{ + OperationID: "migration-csv-preview", + Summary: "Preview a CSV import", + Description: "Returns the first few tasks that would be imported from the uploaded CSV file with the given config, without importing anything. Read-only.", + Method: http.MethodPost, + Path: "/migration/csv/preview", + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvPreview) + + Register(api, huma.Operation{ + OperationID: "migration-csv-migrate", + Summary: "Import a CSV file", + Description: "Imports the tasks from the uploaded CSV file into Vikunja using the given config. The import runs synchronously and returns once it has finished.", + Method: http.MethodPost, + Path: "/migration/csv/migrate", + // POST runs an import rather than creating a REST resource, so it + // returns 200 with a confirmation, not the wrapper's 201. + DefaultStatus: http.StatusOK, + Tags: tags, + MaxBodyBytes: maxBody, + }, csvMigrate) +} + +func init() { AddRouteRegistrar(RegisterMigrationCSVRoutes) } + +func csvStatus(ctx context.Context, _ *struct{}) (*migrationStatusBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + status, err := migration.GetMigrationStatus(&csv.Migrator{}, u) + if err != nil { + return nil, translateDomainError(err) + } + return &migrationStatusBody{Body: status}, nil +} + +func csvDetect(ctx context.Context, in *csvDetectInput) (*csvDetectBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.DetectCSVStructure(src, src.Size) + if err != nil { + return nil, translateDomainError(err) + } + return &csvDetectBody{Body: result}, nil +} + +func csvPreview(ctx context.Context, in *csvImportInput) (*csvPreviewBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + result, err := csv.PreviewImport(src, src.Size, cfg) + if err != nil { + return nil, translateDomainError(err) + } + return &csvPreviewBody{Body: result}, nil +} + +func csvMigrate(ctx context.Context, in *csvImportInput) (*migrationStartedBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + cfg, err := parseCSVImportConfig(in.RawBody.Data().Config) + if err != nil { + return nil, err + } + + src := in.RawBody.Data().Import + defer func() { _ = src.Close() }() + + if err := csv.RunMigration(u, src, src.Size, cfg); err != nil { + return nil, translateDomainError(err) + } + + out := &migrationStartedBody{} + out.Body.Message = "Everything was migrated successfully." + return out, nil +} + +// parseCSVImportConfig unmarshals the JSON config form value, mirroring v1's +// json.Unmarshal of FormValue("config"). required:"true" guarantees presence, +// so only a malformed body needs guarding here. +func parseCSVImportConfig(raw string) (*csv.ImportConfig, error) { + var cfg csv.ImportConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return nil, huma.Error400BadRequest("Invalid configuration: " + err.Error()) + } + return &cfg, nil +} From a8a53c9581a0c86ba24d9833a0fb6b593eac2d89 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:52 +0200 Subject: [PATCH 034/111] test(api/v2): cover the v2 file and CSV migrator endpoints Webtests for the file migrators (status, migrate, auth, missing-file) and the CSV importer (status, detect, preview, migrate happy path, missing/malformed config, empty file, auth). Each rejected upload is asserted to map to a 4xx domain error rather than a 500. --- pkg/webtests/huma_migration_csv_test.go | 125 ++++++++++++++++++++++ pkg/webtests/huma_migration_file_test.go | 128 +++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 pkg/webtests/huma_migration_csv_test.go create mode 100644 pkg/webtests/huma_migration_file_test.go diff --git a/pkg/webtests/huma_migration_csv_test.go b/pkg/webtests/huma_migration_csv_test.go new file mode 100644 index 000000000..ff269f46e --- /dev/null +++ b/pkg/webtests/huma_migration_csv_test.go @@ -0,0 +1,125 @@ +// 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 . + +package webtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const csvTestFile = `Title,Description,Done,Priority +Task 1,Description 1,true,high +Task 2,Description 2,false,low` + +const csvTestConfig = `{"delimiter":",","quote_char":"\"","date_format":"2006-01-02","mapping":[` + + `{"column_index":0,"column_name":"Title","attribute":"title"},` + + `{"column_index":1,"column_name":"Description","attribute":"description"},` + + `{"column_index":2,"column_name":"Done","attribute":"done"},` + + `{"column_index":3,"column_name":"Priority","attribute":"priority"}]}` + +// TestHumaMigrationCSV covers the generic CSV importer's v2 endpoints: +// status, detect, preview and migrate. No v1 webtest exists to mirror. +func TestHumaMigrationCSV(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run("detect returns columns and a suggested mapping", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"columns"`) + assert.Contains(t, rec.Body.String(), `"suggested_mapping"`) + assert.Contains(t, rec.Body.String(), "Title") + }) + + t.Run("preview returns tasks without importing", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/preview", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"tasks"`) + assert.Contains(t, rec.Body.String(), "Task 1") + }) + + t.Run("migrate imports the file", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Everything was migrated successfully."`) + + // The status now reflects a finished migration. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, + "after migrating, the status must carry a real started_at; body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_BadInput covers the negative paths: missing config, +// malformed config JSON, and an empty file. +func TestHumaMigrationCSV_BadInput(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("missing config is rejected with 422", func(t *testing.T) { + // The config form value is required:"true", so Huma's multipart + // validation refuses the request before the handler runs. + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("malformed config JSON is rejected with 400", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": "{not json"}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("empty file is rejected with a domain error", func(t *testing.T) { + body, contentType := multipartImportBody(t, "empty.csv", []byte{}, map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_Unauthenticated proves all CSV ops require auth. +func TestHumaMigrationCSV_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("detect", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_migration_file_test.go b/pkg/webtests/huma_migration_file_test.go new file mode 100644 index 000000000..9430127aa --- /dev/null +++ b/pkg/webtests/huma_migration_file_test.go @@ -0,0 +1,128 @@ +// 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 . + +package webtests + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartImportBody builds a multipart/form-data body with the file under the +// "import" field plus any extra string form values (e.g. the CSV "config"), +// matching the v2 file/CSV migrator form schemas. +func multipartImportBody(t *testing.T, filename string, content []byte, values map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("import", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + for k, v := range values { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func migrationUploadRequest(t *testing.T, e *echo.Echo, path string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaMigrationFile covers the always-registered file migrators +// (vikunja-file, ticktick, wekan) status + migrate endpoints. There is no v1 +// webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // payload is shaped per migrator to hit a *domain* rejection (4xx) rather + // than a raw parse error: a wekan board with no title/cards is "empty", a + // ticktick CSV with no data rows is "empty", and a vikunja-file that isn't + // a zip is rejected as such. (Syntactically-malformed input would surface a + // raw json/zip error that maps to 500 in both v1 and v2 alike.) + migrators := map[string][]byte{ + "vikunja-file": []byte("not a zip archive"), + "ticktick": []byte("Title,Content\n"), + "wekan": []byte(`{"title":"","cards":[]}`), + } + + for name, payload := range migrators { + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run(name+" migrate maps a rejected file to a 4xx domain error", func(t *testing.T) { + // Drives the request through the multipart binding and into the + // migrator, which rejects it with a domain error that + // translateDomainError turns into a 4xx — proving the v2 plumbing + // (bind, run, error bridge) is wired, not the parsing itself. + body, contentType := multipartImportBody(t, "bad."+name, payload, nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/"+name+"/migrate", body, contentType, token) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest, "body: %s", rec.Body.String()) + assert.Less(t, rec.Code, http.StatusInternalServerError, + "a rejected upload must map to a 4xx domain error, not a 500; body: %s", rec.Body.String()) + }) + } +} + +// TestHumaMigrationFile_Unauthenticated proves the file migrator ops require auth. +func TestHumaMigrationFile_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/ticktick/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "x.csv", []byte("x"), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationFile_MissingFile proves the required "import" form field is +// enforced by Huma's multipart validation (422), not a 500. +func TestHumaMigrationFile_MissingFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + require.NoError(t, w.Close()) + + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", buf, w.FormDataContentType(), token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) +} From 35bcb7ed261bf6e3b3a58db631446fd141539c90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:01:51 +0000 Subject: [PATCH 035/111] chore(deps-dev): bump esbuild from 0.28.0 to 0.28.1 in /frontend Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1. - [Release notes](https://github.com/evanw/esbuild/releases) - [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md) - [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1) --- updated-dependencies: - dependency-name: esbuild dependency-version: 0.28.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 469 +++++++++++++++++++++------------------- 2 files changed, 253 insertions(+), 218 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b5243e44f..9331c6032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -128,7 +128,7 @@ "browserslist": "4.28.2", "caniuse-lite": "1.0.30001799", "csstype": "3.2.3", - "esbuild": "0.28.0", + "esbuild": "0.28.1", "eslint": "9.39.4", "eslint-plugin-depend": "1.5.0", "eslint-plugin-vue": "10.9.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2369c33e4..5ddc180be 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -245,8 +245,8 @@ importers: specifier: 3.2.3 version: 3.2.3 esbuild: - specifier: 0.28.0 - version: 0.28.0 + specifier: 0.28.1 + version: 0.28.1 eslint: specifier: 9.39.4 version: 9.39.4(jiti@2.6.1) @@ -1341,14 +1341,14 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.5': - resolution: {integrity: sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -1359,14 +1359,14 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.5': - resolution: {integrity: sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -1377,14 +1377,14 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.5': - resolution: {integrity: sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] @@ -1395,14 +1395,14 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.5': - resolution: {integrity: sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] @@ -1413,14 +1413,14 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.5': - resolution: {integrity: sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -1431,14 +1431,14 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.5': - resolution: {integrity: sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -1449,14 +1449,14 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.5': - resolution: {integrity: sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -1467,14 +1467,14 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.5': - resolution: {integrity: sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -1485,14 +1485,14 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.5': - resolution: {integrity: sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -1503,14 +1503,14 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.5': - resolution: {integrity: sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -1521,14 +1521,14 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.5': - resolution: {integrity: sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -1539,14 +1539,14 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.5': - resolution: {integrity: sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -1557,14 +1557,14 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.5': - resolution: {integrity: sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -1575,14 +1575,14 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.5': - resolution: {integrity: sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -1593,14 +1593,14 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.5': - resolution: {integrity: sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -1611,14 +1611,14 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.5': - resolution: {integrity: sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -1629,14 +1629,14 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.5': - resolution: {integrity: sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -1647,14 +1647,14 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.5': - resolution: {integrity: sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] @@ -1665,14 +1665,14 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.5': - resolution: {integrity: sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -1683,14 +1683,14 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.5': - resolution: {integrity: sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] @@ -1701,14 +1701,14 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.5': - resolution: {integrity: sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -1719,14 +1719,14 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.5': - resolution: {integrity: sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] @@ -1737,14 +1737,14 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.5': - resolution: {integrity: sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -1755,14 +1755,14 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.5': - resolution: {integrity: sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -1773,14 +1773,14 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.5': - resolution: {integrity: sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -1791,14 +1791,14 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.5': - resolution: {integrity: sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2147,36 +2147,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2336,66 +2342,79 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -2598,24 +2617,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -4014,13 +4037,13 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.5: - resolution: {integrity: sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -4955,24 +4978,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5977,48 +6004,56 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: glibc sass-embedded-linux-arm@1.100.0: resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: glibc sass-embedded-linux-musl-arm64@1.100.0: resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: musl sass-embedded-linux-musl-arm@1.100.0: resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: musl sass-embedded-linux-musl-riscv64@1.100.0: resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: musl sass-embedded-linux-musl-x64@1.100.0: resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: musl sass-embedded-linux-riscv64@1.100.0: resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: glibc sass-embedded-linux-x64@1.100.0: resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: glibc sass-embedded-unknown-all@1.100.0: resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} @@ -8392,235 +8427,235 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.5': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.1': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.5': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.1': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.5': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.1': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.5': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.1': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.5': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.1': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.5': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.1': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.5': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.1': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.5': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.1': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.5': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.1': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.5': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.1': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.5': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.1': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.5': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.1': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.5': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.1': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.5': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.1': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.5': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.1': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.5': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.1': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.5': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.27.5': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.1': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.5': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.27.5': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.1': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.5': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.27.5': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.1': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.5': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.1': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.5': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.1': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.5': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.1': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.5': + '@esbuild/win32-x64@0.27.7': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': @@ -11006,63 +11041,63 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.5: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.5 - '@esbuild/android-arm': 0.27.5 - '@esbuild/android-arm64': 0.27.5 - '@esbuild/android-x64': 0.27.5 - '@esbuild/darwin-arm64': 0.27.5 - '@esbuild/darwin-x64': 0.27.5 - '@esbuild/freebsd-arm64': 0.27.5 - '@esbuild/freebsd-x64': 0.27.5 - '@esbuild/linux-arm': 0.27.5 - '@esbuild/linux-arm64': 0.27.5 - '@esbuild/linux-ia32': 0.27.5 - '@esbuild/linux-loong64': 0.27.5 - '@esbuild/linux-mips64el': 0.27.5 - '@esbuild/linux-ppc64': 0.27.5 - '@esbuild/linux-riscv64': 0.27.5 - '@esbuild/linux-s390x': 0.27.5 - '@esbuild/linux-x64': 0.27.5 - '@esbuild/netbsd-arm64': 0.27.5 - '@esbuild/netbsd-x64': 0.27.5 - '@esbuild/openbsd-arm64': 0.27.5 - '@esbuild/openbsd-x64': 0.27.5 - '@esbuild/openharmony-arm64': 0.27.5 - '@esbuild/sunos-x64': 0.27.5 - '@esbuild/win32-arm64': 0.27.5 - '@esbuild/win32-ia32': 0.27.5 - '@esbuild/win32-x64': 0.27.5 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.0: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -14134,7 +14169,7 @@ snapshots: vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: - esbuild: 0.27.5 + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 From 85b820fa7ca08d9c8a8569372f3576d2f28aa98c Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Tue, 16 Jun 2026 00:40:29 +0000 Subject: [PATCH 036/111] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/el-GR.json | 33 ++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/lang/el-GR.json b/frontend/src/i18n/lang/el-GR.json index 4848afad7..14837735b 100644 --- a/frontend/src/i18n/lang/el-GR.json +++ b/frontend/src/i18n/lang/el-GR.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "ΕΕΕΕ/ΜΜ/ΗΗ" }, "timeFormat": "Μορφή ώρας", + "timeTrackingDefaultStart": "Ώρα έναρξης παρακολούθησης χρόνου με έξυπνο-γέμισμα", "timeFormatOptions": { "12h": "12 ώρες (ΠΜ/ΜΜ)", "24h": "24 ώρες (ΩΩ:ΛΛ)" @@ -781,7 +782,10 @@ "closeDialog": "Κλείσμο του διαλόγου", "closeQuickActions": "Κλείσιμο των γρήγορων ενεργειών", "skipToContent": "Μετάβαση στο κύριο περιεχόμενο", - "sortBy": "Ταξινόμηση ανά" + "sortBy": "Ταξινόμηση ανά", + "dateRange": "Εύρος ημερομηνιών", + "notSet": "Μη ορισμένο", + "user": "Χρήστης" }, "input": { "projectColor": "Χρώμα έργου", @@ -991,6 +995,7 @@ "repeatAfter": "Ορισμός Επαναλαμβανόμενου Διαστήματος", "percentDone": "Ορισμός Προόδου", "attachments": "Προσθήκη Συνημμένων", + "timeTracking": "Χρόνος ίχνους", "relatedTasks": "Προσθήκη Συσχέτισης", "moveProject": "Μετακίνηση", "duplicate": "Αντιγραφή", @@ -1460,6 +1465,32 @@ "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": "δευτερόλεπτο|δευτερόλεπτα", From 1d6d332c189047ce598a9b45b99c5964a23690f3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:17:51 +0200 Subject: [PATCH 037/111] fix(deps): bump tmp to >=0.2.7 to fix path traversal advisory Resolves GHSA-7c78-jf6q-g5cm (type-confusion bypass of _assertPath allowing path traversal). tmp was pinned to >=0.2.6 via pnpm overrides in both the frontend and desktop workspaces, which resolved to the vulnerable 0.2.6. Dependabot alerts #243 (desktop) and #244 (frontend). --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 10 ++++----- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 45 +++++------------------------------------ 4 files changed, 12 insertions(+), 47 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 765d91054..8a85d63df 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -77,7 +77,7 @@ "tar": "^7.5.11", "@tootallnate/once": "^3.0.1", "picomatch": ">=4.0.4", - "tmp": ">=0.2.6", + "tmp": ">=0.2.7", "ip-address": ">=10.1.1" } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 77a74681b..6a15d26ef 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: tar: ^7.5.11 '@tootallnate/once': ^3.0.1 picomatch: '>=4.0.4' - tmp: '>=0.2.6' + tmp: '>=0.2.7' ip-address: '>=10.1.1' importers: @@ -1315,8 +1315,8 @@ packages: tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - tmp@0.2.6: - resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} toidentifier@1.0.1: @@ -3032,9 +3032,9 @@ snapshots: tmp-promise@3.0.3: dependencies: - tmp: 0.2.6 + tmp: 0.2.7 - tmp@0.2.6: {} + tmp@0.2.7: {} toidentifier@1.0.1: {} diff --git a/frontend/package.json b/frontend/package.json index 9331c6032..7ed2c55d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -176,7 +176,7 @@ "flatted": "^3.4.1", "ip-address": ">=10.1.1", "postcss": ">=8.5.10", - "tmp": ">=0.2.6" + "tmp": ">=0.2.7" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5ddc180be..548944d60 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: flatted: ^3.4.1 ip-address: '>=10.1.1' postcss: '>=8.5.10' - tmp: '>=0.2.6' + tmp: '>=0.2.7' importers: @@ -2147,42 +2147,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2342,79 +2336,66 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -2617,28 +2598,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -4978,28 +4955,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -6004,56 +5977,48 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: glibc sass-embedded-linux-arm@1.100.0: resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: glibc sass-embedded-linux-musl-arm64@1.100.0: resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: musl sass-embedded-linux-musl-arm@1.100.0: resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: musl sass-embedded-linux-musl-riscv64@1.100.0: resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: musl sass-embedded-linux-musl-x64@1.100.0: resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: musl sass-embedded-linux-riscv64@1.100.0: resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: glibc sass-embedded-linux-x64@1.100.0: resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: glibc sass-embedded-unknown-all@1.100.0: resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} @@ -6523,8 +6488,8 @@ packages: resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} hasBin: true - tmp@0.2.6: - resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} to-regex-range@5.0.1: @@ -11233,7 +11198,7 @@ snapshots: dependencies: chardet: 0.7.0 iconv-lite: 0.4.24 - tmp: 0.2.6 + tmp: 0.2.7 extract-zip@2.0.1: dependencies: @@ -13829,7 +13794,7 @@ snapshots: dependencies: tldts-core: 7.0.19 - tmp@0.2.6: {} + tmp@0.2.7: {} to-regex-range@5.0.1: dependencies: From b42a7fdcc45c7406eb5785a7e7f80e6ba1b9b207 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:18:18 +0200 Subject: [PATCH 038/111] fix(deps): force esbuild >=0.28.1 to fix transitive advisories The frontend pins esbuild 0.28.1 directly, but vite/histoire and @intlify/bundle-utils pulled in transitive copies (0.27.7 and 0.25.12) still affected by GHSA-gv7w-rqvm-qjhr (RCE via missing binary integrity verification) and GHSA-g7r4-m6w7-qqqr (dev-server file read on Windows). A pnpm override forces all copies to the patched 0.28.1. Dependabot alerts #239 and #241. --- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 543 +--------------------------------------- 2 files changed, 6 insertions(+), 540 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 7ed2c55d4..dfc9386cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -176,7 +176,8 @@ "flatted": "^3.4.1", "ip-address": ">=10.1.1", "postcss": ">=8.5.10", - "tmp": ">=0.2.7" + "tmp": ">=0.2.7", + "esbuild": ">=0.28.1" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 548944d60..3c17e6cd5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: ip-address: '>=10.1.1' postcss: '>=8.5.10' tmp: '>=0.2.7' + esbuild: '>=0.28.1' importers: @@ -245,7 +246,7 @@ importers: specifier: 3.2.3 version: 3.2.3 esbuild: - specifier: 0.28.1 + specifier: '>=0.28.1' version: 0.28.1 eslint: specifier: 9.39.4 @@ -1335,468 +1336,156 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -4009,16 +3698,6 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -8389,237 +8068,81 @@ snapshots: dependencies: postcss: 8.5.14 - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.12': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -8827,7 +8350,7 @@ snapshots: '@intlify/message-compiler': 11.2.8 '@intlify/shared': 11.2.8 acorn: 8.15.0 - esbuild: 0.25.12 + esbuild: 0.28.1 escodegen: 2.1.0 estree-walker: 2.0.2 jsonc-eslint-parser: 2.4.0 @@ -10977,64 +10500,6 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -14134,7 +13599,7 @@ snapshots: vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 From 652f61da50ce7caa8e89db1dac3cbe70eeb750b2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:30:00 +0200 Subject: [PATCH 039/111] fix(deps): bump dompurify to 3.4.9 to fix XSS advisories dompurify 3.4.0 was affected by several stacked advisories (mXSS / sanitizer bypasses). 3.4.9 is past all vulnerable ranges. Resolves Dependabot alerts #248-#254 (package.json) and #259-#265 (lockfile). --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index dfc9386cd..2add08f14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,7 +82,7 @@ "bulma-css-variables": "0.9.33", "change-case": "5.4.4", "dayjs": "1.11.19", - "dompurify": "3.4.0", + "dompurify": "3.4.9", "fast-deep-equal": "3.1.3", "flatpickr": "4.6.13", "floating-vue": "5.2.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3c17e6cd5..09db0f9f9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -113,8 +113,8 @@ importers: specifier: 1.11.19 version: 1.11.19 dompurify: - specifier: 3.4.0 - version: 3.4.0 + specifier: 3.4.9 + version: 3.4.9 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -3569,8 +3569,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.0: - resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + dompurify@3.4.9: + resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -10334,7 +10334,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.0: + dompurify@3.4.9: optionalDependencies: '@types/trusted-types': 2.0.7 From 460e8f3ab16aca9c08aba9c51caa619d652ab876 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:30:33 +0200 Subject: [PATCH 040/111] fix(deps): force form-data >=4.0.6 to fix unsafe boundary advisory Resolves the form-data <4.0.6 advisory (predictable multipart boundary). Transitive in both workspaces; pinned via pnpm overrides. Dependabot alerts #247 (desktop) and #258 (frontend). --- desktop/package.json | 3 ++- desktop/pnpm-lock.yaml | 23 ++++++++++++++++------- frontend/package.json | 3 ++- frontend/pnpm-lock.yaml | 9 +++++---- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 8a85d63df..a0f2a6de9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -78,7 +78,8 @@ "@tootallnate/once": "^3.0.1", "picomatch": ">=4.0.4", "tmp": ">=0.2.7", - "ip-address": ">=10.1.1" + "ip-address": ">=10.1.1", + "form-data": ">=4.0.6" } } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 6a15d26ef..24c81fb9c 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -11,6 +11,7 @@ overrides: picomatch: '>=4.0.4' tmp: '>=0.2.7' ip-address: '>=10.1.1' + form-data: '>=4.0.6' importers: @@ -632,8 +633,8 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: @@ -736,6 +737,10 @@ 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'} @@ -1729,7 +1734,7 @@ snapshots: ejs: 3.1.10 electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2) electron-publish: 24.13.1 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 hosted-git-info: 4.1.0 is-ci: 3.0.1 @@ -2171,7 +2176,7 @@ snapshots: builder-util: 26.15.0 builder-util-runtime: 9.7.0 chalk: 4.1.2 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 lazy-val: 1.0.5 mime: 2.6.0 @@ -2215,7 +2220,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es6-error@4.1.1: optional: true @@ -2294,12 +2299,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 forwarded@0.2.0: {} @@ -2436,6 +2441,10 @@ 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 diff --git a/frontend/package.json b/frontend/package.json index 2add08f14..fce48f874 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -177,7 +177,8 @@ "ip-address": ">=10.1.1", "postcss": ">=8.5.10", "tmp": ">=0.2.7", - "esbuild": ">=0.28.1" + "esbuild": ">=0.28.1", + "form-data": ">=4.0.6" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 09db0f9f9..25fd9b891 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: postcss: '>=8.5.10' tmp: '>=0.2.7' esbuild: '>=0.28.1' + form-data: '>=4.0.6' importers: @@ -3942,8 +3943,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} fraction.js@5.3.4: @@ -9830,7 +9831,7 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -10787,7 +10788,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 From 340be305f8b1f5776f14fe86d5f93fbb6575a8cf Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:31:02 +0200 Subject: [PATCH 041/111] fix(deps): tighten tar override to >=7.5.16 The ^7.5.11 override resolved to the vulnerable 7.5.15. Pin to >=7.5.16. Resolves Dependabot alert #246 (desktop). --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index a0f2a6de9..73e1c510a 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -74,7 +74,7 @@ ], "overrides": { "minimatch": "^10.2.3", - "tar": "^7.5.11", + "tar": ">=7.5.16", "@tootallnate/once": "^3.0.1", "picomatch": ">=4.0.4", "tmp": ">=0.2.7", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 24c81fb9c..866cbbb3a 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: minimatch: ^10.2.3 - tar: ^7.5.11 + tar: '>=7.5.16' '@tootallnate/once': ^3.0.1 picomatch: '>=4.0.4' tmp: '>=0.2.7' @@ -1303,8 +1303,8 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@7.5.15: - resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + tar@7.5.16: + resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} temp-file@3.4.0: @@ -1745,7 +1745,7 @@ snapshots: read-config-file: 6.3.2 sanitize-filename: 1.6.4 semver: 7.8.1 - tar: 7.5.15 + tar: 7.5.16 temp-file: 3.4.0 transitivePeerDependencies: - supports-color @@ -1790,7 +1790,7 @@ snapshots: proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.15 + tar: 7.5.16 temp-file: 3.4.0 tiny-async-pool: 1.3.0 unzipper: 0.12.3 @@ -2665,7 +2665,7 @@ snapshots: nopt: 9.0.0 proc-log: 6.1.0 semver: 7.8.1 - tar: 7.5.15 + tar: 7.5.16 tinyglobby: 0.2.15 undici: 6.26.0 which: 6.0.1 @@ -3017,7 +3017,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@7.5.15: + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 From be5858aafe4cb0258ea5ed8bfbd44177792c0518 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:31:46 +0200 Subject: [PATCH 042/111] fix(deps): force markdown-it >=14.2.0 to fix ReDoS advisory Resolves the markdown-it <=14.1.1 advisory. Transitive; pinned via pnpm override. Dependabot alert #266 (frontend). --- frontend/package.json | 3 ++- frontend/pnpm-lock.yaml | 35 ++++++++++++++++++----------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index fce48f874..bfcd786e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -178,7 +178,8 @@ "postcss": ">=8.5.10", "tmp": ">=0.2.7", "esbuild": ">=0.28.1", - "form-data": ">=4.0.6" + "form-data": ">=4.0.6", + "markdown-it": ">=14.2.0" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 25fd9b891..94aea2a04 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -15,6 +15,7 @@ overrides: tmp: '>=0.2.7' esbuild: '>=0.28.1' form-data: '>=4.0.6' + markdown-it: '>=14.2.0' importers: @@ -4673,8 +4674,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.1: + resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} @@ -4742,19 +4743,19 @@ packages: resolution: {integrity: sha512-sa2ErMQ6kKOA4l31gLGYliFQrMKkqSO0ZJgGhDHKijPf0pNFM9vghjAh3gn26pS4JDRs7Iwa9S36gxm3vgZTzg==} peerDependencies: '@types/markdown-it': '*' - markdown-it: '*' + markdown-it: '>=14.2.0' markdown-it-attrs@4.3.1: resolution: {integrity: sha512-/ko6cba+H6gdZ0DOw7BbNMZtfuJTRp9g/IrGIuz8lYc/EfnmWRpaR3CFPnNbVz0LDvF8Gf1hFGPqrQqq7De0rg==} engines: {node: '>=6'} peerDependencies: - markdown-it: '>= 9.0.0' + markdown-it: '>=14.2.0' markdown-it-emoji@3.0.0: resolution: {integrity: sha512-+rUD93bXHubA4arpEZO3q80so0qgoFJEKRkRbjKX8RTdca89v2kfyF+xR3i2sQTwql9tpPZPOQN5B+PunspXRg==} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.2.0: + resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==} hasBin: true marked@17.0.1: @@ -11051,9 +11052,9 @@ snapshots: gray-matter: 4.0.3 jiti: 2.6.1 jsdom: 27.4.0 - markdown-it: 14.1.1 - markdown-it-anchor: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.1) - markdown-it-attrs: 4.3.1(markdown-it@14.1.1) + markdown-it: 14.2.0 + markdown-it-anchor: 9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0) + markdown-it-attrs: 4.3.1(markdown-it@14.2.0) markdown-it-emoji: 3.0.0 micromatch: 4.0.8 mrmime: 2.0.0 @@ -11561,7 +11562,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: + linkify-it@5.0.1: dependencies: uc.micro: 2.1.0 @@ -11618,22 +11619,22 @@ snapshots: map-obj@4.3.0: {} - markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.1.1): + markdown-it-anchor@9.2.0(@types/markdown-it@14.1.2)(markdown-it@14.2.0): dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 + markdown-it: 14.2.0 - markdown-it-attrs@4.3.1(markdown-it@14.1.1): + markdown-it-attrs@4.3.1(markdown-it@14.2.0): dependencies: - markdown-it: 14.1.1 + markdown-it: 14.2.0 markdown-it-emoji@3.0.0: {} - markdown-it@14.1.1: + markdown-it@14.2.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.1 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -12311,7 +12312,7 @@ snapshots: prosemirror-markdown@1.13.1: dependencies: '@types/markdown-it': 14.1.2 - markdown-it: 14.1.1 + markdown-it: 14.2.0 prosemirror-model: 1.25.0 prosemirror-menu@1.2.4: From d054fb7a5babb0b5956f1cff8a393a473e33b5e9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:32:20 +0200 Subject: [PATCH 043/111] fix(deps): force launch-editor >=2.14.1 Resolves the launch-editor <=2.14.0 advisory. Transitive (via vite-plugin-vue-devtools); pinned via pnpm override. Dependabot alert #257 (frontend). --- frontend/package.json | 3 ++- frontend/pnpm-lock.yaml | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index bfcd786e5..0fd71fc1f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -179,7 +179,8 @@ "tmp": ">=0.2.7", "esbuild": ">=0.28.1", "form-data": ">=4.0.6", - "markdown-it": ">=14.2.0" + "markdown-it": ">=14.2.0", + "launch-editor": ">=2.14.1" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 94aea2a04..d0401d76d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -16,6 +16,7 @@ overrides: esbuild: '>=0.28.1' form-data: '>=4.0.6' markdown-it: '>=14.2.0' + launch-editor: '>=2.14.1' importers: @@ -4590,8 +4591,8 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} - launch-editor@2.10.0: - resolution: {integrity: sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==} + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -8316,7 +8317,7 @@ snapshots: change-case: 5.4.4 globby: 14.1.0 histoire: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) - launch-editor: 2.10.0 + launch-editor: 2.14.1 pathe: 1.1.2 vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: @@ -11499,7 +11500,7 @@ snapshots: dependencies: package-json: 10.0.1 - launch-editor@2.10.0: + launch-editor@2.14.1: dependencies: picocolors: 1.1.1 shell-quote: 1.8.4 From 9cc47a3da43a454551fe8b75d159387e1f2ba25a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:32:36 +0200 Subject: [PATCH 044/111] fix(deps): force @babel/core >=7.29.6 Resolves the @babel/core <=7.29.0 advisory. Transitive; pinned via pnpm override. Dependabot alert #255 (frontend). --- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 880 +++++++++++++++++++++++----------------- 2 files changed, 500 insertions(+), 383 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 0fd71fc1f..7a70177a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -180,7 +180,8 @@ "esbuild": ">=0.28.1", "form-data": ">=4.0.6", "markdown-it": ">=14.2.0", - "launch-editor": ">=2.14.1" + "launch-editor": ">=2.14.1", + "@babel/core": ">=7.29.6" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d0401d76d..1dc79da14 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -17,6 +17,7 @@ overrides: form-data: '>=4.0.6' markdown-it: '>=14.2.0' launch-editor: '>=2.14.1' + '@babel/core': '>=7.29.6' importers: @@ -351,10 +352,6 @@ packages: resolution: {integrity: sha512-nznEC1ZA/m3hQDEnrGQ4c5gkaa9pcaVnw4LFJyzBAaR7E3nfiAPEHS3otnSafpZouVnoKeITl5D+2LsnwlnK8g==} engines: {node: '>=14.0.0'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@apideck/better-ajv-errors@0.3.6': resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -370,20 +367,24 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.26.0': resolution: {integrity: sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==} engines: {node: '>=6.9.0'} - '@babel/core@7.26.0': - resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.26.0': @@ -394,6 +395,10 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -406,27 +411,35 @@ packages: resolution: {integrity: sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.25.9': resolution: {integrity: sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-create-regexp-features-plugin@7.25.9': resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-define-polyfill-provider@0.6.2': resolution: {integrity: sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.25.9': resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} engines: {node: '>=6.9.0'} @@ -439,17 +452,27 @@ packages: resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.26.0': resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': '>=7.29.6' '@babel/helper-optimise-call-expression@7.25.9': resolution: {integrity: sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==} @@ -467,13 +490,13 @@ packages: resolution: {integrity: sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-replace-supers@7.25.9': resolution: {integrity: sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/helper-simple-access@7.25.9': resolution: {integrity: sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==} @@ -487,20 +510,32 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.25.9': resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + '@babel/helper-wrap-function@7.25.9': resolution: {integrity: sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.26.10': - resolution: {integrity: sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.28.5': @@ -513,405 +548,410 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9': resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9': resolution: {integrity: sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9': resolution: {integrity: sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9': resolution: {integrity: sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.13.0 + '@babel/core': '>=7.29.6' '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9': resolution: {integrity: sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-proposal-decorators@7.25.9': resolution: {integrity: sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-decorators@7.25.9': resolution: {integrity: sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-assertions@7.26.0': resolution: {integrity: sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-attributes@7.26.0': resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-import-meta@7.10.4': resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-jsx@7.25.9': resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-typescript@7.25.9': resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-arrow-functions@7.25.9': resolution: {integrity: sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-async-generator-functions@7.25.9': resolution: {integrity: sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-async-to-generator@7.25.9': resolution: {integrity: sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-block-scoped-functions@7.25.9': resolution: {integrity: sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-block-scoping@7.25.9': resolution: {integrity: sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-class-properties@7.25.9': resolution: {integrity: sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-class-static-block@7.26.0': resolution: {integrity: sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.12.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-classes@7.25.9': resolution: {integrity: sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-computed-properties@7.25.9': resolution: {integrity: sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-destructuring@7.25.9': resolution: {integrity: sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-dotall-regex@7.25.9': resolution: {integrity: sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-duplicate-keys@7.25.9': resolution: {integrity: sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9': resolution: {integrity: sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-dynamic-import@7.25.9': resolution: {integrity: sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-exponentiation-operator@7.25.9': resolution: {integrity: sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-export-namespace-from@7.25.9': resolution: {integrity: sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-for-of@7.25.9': resolution: {integrity: sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-function-name@7.25.9': resolution: {integrity: sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-json-strings@7.25.9': resolution: {integrity: sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-literals@7.25.9': resolution: {integrity: sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-logical-assignment-operators@7.25.9': resolution: {integrity: sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-member-expression-literals@7.25.9': resolution: {integrity: sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-amd@7.25.9': resolution: {integrity: sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-commonjs@7.25.9': resolution: {integrity: sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-systemjs@7.29.4': resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-modules-umd@7.25.9': resolution: {integrity: sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-named-capturing-groups-regex@7.25.9': resolution: {integrity: sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-new-target@7.25.9': resolution: {integrity: sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-nullish-coalescing-operator@7.25.9': resolution: {integrity: sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-numeric-separator@7.25.9': resolution: {integrity: sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-object-rest-spread@7.25.9': resolution: {integrity: sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-object-super@7.25.9': resolution: {integrity: sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-optional-catch-binding@7.25.9': resolution: {integrity: sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-optional-chaining@7.25.9': resolution: {integrity: sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-parameters@7.25.9': resolution: {integrity: sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-private-methods@7.25.9': resolution: {integrity: sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-private-property-in-object@7.25.9': resolution: {integrity: sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-property-literals@7.25.9': resolution: {integrity: sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-regenerator@7.25.9': resolution: {integrity: sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-regexp-modifiers@7.26.0': resolution: {integrity: sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-reserved-words@7.25.9': resolution: {integrity: sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-shorthand-properties@7.25.9': resolution: {integrity: sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-spread@7.25.9': resolution: {integrity: sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-sticky-regex@7.25.9': resolution: {integrity: sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-template-literals@7.25.9': resolution: {integrity: sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typeof-symbol@7.25.9': resolution: {integrity: sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-typescript@7.25.9': resolution: {integrity: sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-escapes@7.25.9': resolution: {integrity: sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-property-regex@7.25.9': resolution: {integrity: sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-regex@7.25.9': resolution: {integrity: sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/plugin-transform-unicode-sets-regex@7.25.9': resolution: {integrity: sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@babel/preset-env@7.26.0': resolution: {integrity: sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@babel/preset-modules@0.1.6-no-external-plugins': resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} peerDependencies: - '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' '@babel/runtime@7.25.4': resolution: {integrity: sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==} @@ -925,6 +965,10 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.25.9': resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} engines: {node: '>=6.9.0'} @@ -933,6 +977,10 @@ packages: resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -941,6 +989,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bufbuild/protobuf@2.5.2': resolution: {integrity: sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg==} @@ -1949,7 +2001,7 @@ packages: resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} engines: {node: '>=14.0.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': '>=7.29.6' '@types/babel__core': ^7.1.9 rollup: 4.61.1 peerDependenciesMeta: @@ -2818,7 +2870,7 @@ packages: '@vue/babel-plugin-jsx@1.2.5': resolution: {integrity: sha512-zTrNmOd4939H9KsRIGmmzn3q2zvv1mjxkYZHgqHZgDrXz5B1Q3WyGEjO2f+JrmKghvl1JIRcvo63LgM1kH5zFg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' peerDependenciesMeta: '@babel/core': optional: true @@ -2826,7 +2878,7 @@ packages: '@vue/babel-plugin-resolve-type@1.2.5': resolution: {integrity: sha512-U/ibkQrf5sx0XXRnUZD1mo5F7PkpKyTbfXM3a3rC4YnUz6crHEz9Jg09jzzL6QYlXNto/9CePdOg/c87O4Nlfg==} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': '>=7.29.6' '@vue/compiler-core@3.5.27': resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} @@ -3064,17 +3116,17 @@ packages: babel-plugin-polyfill-corejs2@0.4.11: resolution: {integrity: sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' babel-plugin-polyfill-corejs3@0.10.6: resolution: {integrity: sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' babel-plugin-polyfill-regenerator@0.6.2: resolution: {integrity: sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==} peerDependencies: - '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/core': '>=7.29.6' balanced-match@4.0.3: resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} @@ -6863,11 +6915,6 @@ snapshots: '@akryum/tinypool@0.3.1': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': dependencies: ajv: 8.18.0 @@ -6893,32 +6940,34 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.26.0': {} - '@babel/core@7.26.0': + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.0 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) - '@babel/helpers': 7.26.10 - '@babel/parser': 7.28.5 - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 gensync: 1.0.0-beta.2 @@ -6943,6 +6992,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.25.9': dependencies: '@babel/types': 7.28.5 @@ -6962,29 +7019,37 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.26.0)': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/core': 7.26.0 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.25.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 '@babel/traverse': 7.25.9 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.26.0)': + '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 regexpu-core: 6.1.1 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.26.0)': + '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 debug: 4.4.3 @@ -6995,6 +7060,8 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-member-expression-to-functions@7.25.9': dependencies: '@babel/traverse': 7.25.9 @@ -7016,24 +7083,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.26.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.26.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/helper-optimise-call-expression@7.25.9': dependencies: '@babel/types': 7.28.5 @@ -7042,18 +7125,18 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.26.0)': + '@babel/helper-remap-async-to-generator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-wrap-function': 7.25.9 '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.25.9(@babel/core@7.26.0)': + '@babel/helper-replace-supers@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 '@babel/traverse': 7.25.9 @@ -7076,10 +7159,16 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.25.9': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-wrap-function@7.25.9': dependencies: '@babel/template': 7.26.9 @@ -7088,10 +7177,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helpers@7.26.10': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.26.9 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.28.5': dependencies: @@ -7101,502 +7190,506 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': + '@babel/parser@7.29.7': dependencies: - '@babel/core': 7.26.0 + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-proposal-decorators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 - '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.26.0)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0) + '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-transform-class-static-block@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-classes@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) '@babel/traverse': 7.25.9 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/template': 7.26.9 - '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 '@babel/traverse': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-simple-access': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-named-capturing-groups-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.29.7) - '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/helper-replace-supers': 7.25.9(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-private-property-in-object@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 regenerator-transform: 0.15.2 - '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.26.0)': + '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-spread@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-spread@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.26.0)': + '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 - '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.25.9 - '@babel/preset-env@7.26.0(@babel/core@7.26.0)': + '@babel/preset-env@7.26.0(@babel/core@7.29.7)': dependencies: '@babel/compat-data': 7.26.0 - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 '@babel/helper-validator-option': 7.25.9 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.26.0) - '@babel/plugin-syntax-import-assertions': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.26.0) - '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-block-scoped-functions': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-class-static-block': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-dotall-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-json-strings': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.26.0) - '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-new-target': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-object-super': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-property-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-regexp-modifiers': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-transform-reserved-words': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-template-literals': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-typeof-symbol': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-escapes': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-property-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-transform-unicode-sets-regex': 7.25.9(@babel/core@7.26.0) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.26.0) - babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.26.0) - babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.26.0) - babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.26.0) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.25.9(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.11(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.10.6(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.2(@babel/core@7.29.7) core-js-compat: 3.38.1 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.26.0)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.25.9 '@babel/types': 7.28.5 esutils: 2.0.3 @@ -7617,6 +7710,12 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.25.9': dependencies: '@babel/code-frame': 7.29.0 @@ -7641,6 +7740,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -7651,6 +7762,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bufbuild/protobuf@2.5.2': {} '@cacheable/memory@2.0.9': @@ -8637,9 +8753,9 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.26.0)(rollup@4.61.1)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.61.1)': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 '@rollup/pluginutils': 5.1.3(rollup@4.61.1) optionalDependencies: @@ -8785,7 +8901,7 @@ snapshots: '@sentry/bundler-plugin-core@3.6.1': dependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@sentry/babel-plugin-component-annotate': 3.6.1 '@sentry/cli': 2.58.5 dotenv: 16.6.1 @@ -9541,27 +9657,27 @@ snapshots: '@vue/babel-helper-vue-transform-on@1.2.5': {} - '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.26.0)': + '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.29.7)': dependencies: '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.29.7) '@babel/template': 7.26.9 '@babel/traverse': 7.25.9 '@babel/types': 7.28.5 '@vue/babel-helper-vue-transform-on': 1.2.5 - '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.26.0) + '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.29.7) html-tags: 3.3.1 svg-tags: 1.0.0 optionalDependencies: - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.26.0)': + '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.29.7)': dependencies: '@babel/code-frame': 7.29.0 - '@babel/core': 7.26.0 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 '@babel/parser': 7.28.5 @@ -9840,27 +9956,27 @@ snapshots: b4a@1.6.7: {} - babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.26.0): + babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.26.0 - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.10.6(@babel/core@7.26.0): + babel-plugin-polyfill-corejs3@0.10.6(@babel/core@7.29.7): dependencies: - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) core-js-compat: 3.38.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.26.0): + babel-plugin-polyfill-regenerator@0.6.2(@babel/core@7.29.7): dependencies: - '@babel/core': 7.26.0 - '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -13579,12 +13695,12 @@ snapshots: vite-plugin-vue-inspector@6.0.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - '@babel/core': 7.26.0 - '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.26.0) - '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.26.0) - '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.0) - '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/plugin-proposal-decorators': 7.25.9(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.29.7) '@vue/compiler-dom': 3.5.27 kolorist: 1.8.0 magic-string: 0.30.21 @@ -13843,10 +13959,10 @@ snapshots: workbox-build@7.4.1: dependencies: '@apideck/better-ajv-errors': 0.3.6(ajv@8.18.0) - '@babel/core': 7.26.0 - '@babel/preset-env': 7.26.0(@babel/core@7.26.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.26.0(@babel/core@7.29.7) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.26.0)(rollup@4.61.1) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.61.1) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.61.1) '@rollup/plugin-replace': 6.0.3(rollup@4.61.1) '@rollup/plugin-terser': 1.0.0(rollup@4.61.1) From e13d3f537c61902037651177ff883a3ade16134a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 16 Jun 2026 08:33:16 +0200 Subject: [PATCH 045/111] fix(deps): bump js-yaml to >=4.2.0 where possible Desktop only has the v4 copy, so a plain override pins it to >=4.2.0 (resolves alert #245). The frontend also pulls js-yaml v3 via gray-matter (histoire story tooling), which has no v4-compatible release, so a scoped 'js-yaml@4' override bumps only the v4 copies (eslint/cosmiconfig) and leaves gray-matter on 3.14.2. Alert #256 stays open for that dev-only, trusted-input path. --- desktop/package.json | 3 ++- desktop/pnpm-lock.yaml | 19 ++++++++++--------- frontend/package.json | 3 ++- frontend/pnpm-lock.yaml | 11 ++++++----- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 73e1c510a..20465374b 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -79,7 +79,8 @@ "picomatch": ">=4.0.4", "tmp": ">=0.2.7", "ip-address": ">=10.1.1", - "form-data": ">=4.0.6" + "form-data": ">=4.0.6", + "js-yaml": ">=4.2.0" } } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 866cbbb3a..4f2e92832 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: tmp: '>=0.2.7' ip-address: '>=10.1.1' form-data: '>=4.0.6' + js-yaml: '>=4.2.0' importers: @@ -835,8 +836,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true json-buffer@3.0.1: @@ -1739,7 +1740,7 @@ snapshots: hosted-git-info: 4.1.0 is-ci: 3.0.1 isbinaryfile: 5.0.7 - js-yaml: 4.1.1 + js-yaml: 4.2.0 lazy-val: 1.0.5 minimatch: 10.2.5 read-config-file: 6.3.2 @@ -1781,7 +1782,7 @@ snapshots: hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.6.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 @@ -1928,7 +1929,7 @@ snapshots: http-proxy-agent: 5.0.0 https-proxy-agent: 5.0.1 is-ci: 3.0.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 source-map-support: 0.5.21 stat-mode: 1.0.0 temp-file: 3.4.0 @@ -1945,7 +1946,7 @@ snapshots: fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.5 - js-yaml: 4.1.1 + js-yaml: 4.2.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -2098,7 +2099,7 @@ snapshots: 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: 4.1.1 + js-yaml: 4.2.0 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color @@ -2543,7 +2544,7 @@ snapshots: jiti@2.6.1: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -2800,7 +2801,7 @@ snapshots: config-file-ts: 0.2.6 dotenv: 9.0.2 dotenv-expand: 5.1.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 json5: 2.2.3 lazy-val: 1.0.5 diff --git a/frontend/package.json b/frontend/package.json index 7a70177a5..ce84aa7fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -181,7 +181,8 @@ "form-data": ">=4.0.6", "markdown-it": ">=14.2.0", "launch-editor": ">=2.14.1", - "@babel/core": ">=7.29.6" + "@babel/core": ">=7.29.6", + "js-yaml@4": ">=4.2.0" } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1dc79da14..21158fb5e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -18,6 +18,7 @@ overrides: markdown-it: '>=14.2.0' launch-editor: '>=2.14.1' '@babel/core': '>=7.29.6' + js-yaml@4: '>=4.2.0' importers: @@ -4558,8 +4559,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsdom@27.4.0: @@ -8296,7 +8297,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 10.2.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -10260,7 +10261,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -11529,7 +11530,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 From f851e6f9590b56c8f3f95db742f51b71e2daf05a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:37:55 +0000 Subject: [PATCH 046/111] chore(deps): update dev-dependencies --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 68 ++++++------ frontend/package.json | 8 +- frontend/pnpm-lock.yaml | 222 ++++++++++++++++++---------------------- 4 files changed, 141 insertions(+), 159 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 20465374b..415238608 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -62,7 +62,7 @@ }, "devDependencies": { "electron": "40.10.3", - "electron-builder": "26.15.2", + "electron-builder": "26.15.3", "unzipper": "0.12.3" }, "dependencies": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 4f2e92832..0eaa08028 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 40.10.3 version: 40.10.3 electron-builder: - specifier: 26.15.2 - version: 26.15.2(electron-builder-squirrel-windows@24.13.3) + specifier: 26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@24.13.3) unzipper: specifier: 0.12.3 version: 0.12.3 @@ -236,12 +236,12 @@ packages: dmg-builder: 24.13.3 electron-builder-squirrel-windows: 24.13.3 - app-builder-lib@26.15.2: - resolution: {integrity: sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.15.2 - electron-builder-squirrel-windows: 26.15.2 + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} @@ -331,8 +331,8 @@ packages: builder-util@24.13.1: resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==} - builder-util@26.15.0: - resolution: {integrity: sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA==} + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} engines: {node: '>=14.0.0'} bytes@3.1.2: @@ -485,8 +485,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.15.2: - resolution: {integrity: sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA==} + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dotenv-expand@11.0.6: resolution: {integrity: sha512-8NHi73otpWsZGBSZwwknTXS5pqMOrk9+Ssrna8xCaxkzEpU9OTf9R5ArQGVw03//Zmk9MOwLPng9WwndvpAJ5g==} @@ -524,16 +524,16 @@ packages: electron-builder-squirrel-windows@24.13.3: resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==} - electron-builder@26.15.2: - resolution: {integrity: sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A==} + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} 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.1: - resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==} + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} electron@40.10.3: resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==} @@ -1717,7 +1717,7 @@ snapshots: app-builder-bin@4.0.0: {} - app-builder-lib@24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3): + app-builder-lib@24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3): dependencies: '@develar/schema-utils': 2.6.5 '@electron/notarize': 2.2.1 @@ -1731,9 +1731,9 @@ snapshots: builder-util-runtime: 9.2.4 chromium-pickle-js: 0.2.0 debug: 4.4.3 - dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3) ejs: 3.1.10 - electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.2) + electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3) electron-publish: 24.13.1 form-data: 4.0.6 fs-extra: 10.1.0 @@ -1751,7 +1751,7 @@ snapshots: transitivePeerDependencies: - supports-color - app-builder-lib@26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3): + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -1767,17 +1767,17 @@ snapshots: ajv: 8.20.0 asn1js: 3.0.10 async-exit-hook: 2.0.1 - builder-util: 26.15.0 + builder-util: 26.15.3 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.2(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.3(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.2) - electron-publish: 26.15.1 + electron-builder-squirrel-windows: 24.13.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 @@ -1936,7 +1936,7 @@ snapshots: transitivePeerDependencies: - supports-color - builder-util@26.15.0: + builder-util@26.15.3: dependencies: '@types/debug': 4.1.13 builder-util-runtime: 9.7.0 @@ -2094,10 +2094,10 @@ snapshots: minimatch: 10.2.5 p-limit: 3.1.0 - dmg-builder@26.15.2(electron-builder-squirrel-windows@24.13.3): + dmg-builder@26.15.3(electron-builder-squirrel-windows@24.13.3): dependencies: - app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) - builder-util: 26.15.0 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3) + builder-util: 26.15.3 fs-extra: 10.1.0 js-yaml: 4.2.0 transitivePeerDependencies: @@ -2132,9 +2132,9 @@ snapshots: dependencies: jake: 10.8.7 - electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.2): + electron-builder-squirrel-windows@24.13.3(dmg-builder@26.15.3): dependencies: - app-builder-lib: 24.13.3(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) + app-builder-lib: 24.13.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3) archiver: 5.3.2 builder-util: 24.13.1 fs-extra: 10.1.0 @@ -2142,14 +2142,14 @@ snapshots: - dmg-builder - supports-color - electron-builder@26.15.2(electron-builder-squirrel-windows@24.13.3): + electron-builder@26.15.3(electron-builder-squirrel-windows@24.13.3): dependencies: - app-builder-lib: 26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@24.13.3) - builder-util: 26.15.0 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@24.13.3) + builder-util: 26.15.3 builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.3.1 - dmg-builder: 26.15.2(electron-builder-squirrel-windows@24.13.3) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@24.13.3) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -2170,11 +2170,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron-publish@26.15.1: + electron-publish@26.15.3: dependencies: '@types/fs-extra': 9.0.13 aws4: 1.13.2 - builder-util: 26.15.0 + builder-util: 26.15.3 builder-util-runtime: 9.7.0 chalk: 4.1.2 form-data: 4.0.6 diff --git a/frontend/package.json b/frontend/package.json index ce84aa7fd..9425d7be2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -110,7 +110,7 @@ "@histoire/plugin-vue": "1.0.0-beta.1", "@playwright/test": "1.58.2", "@sentry/vite-plugin": "3.6.1", - "@tailwindcss/vite": "4.3.0", + "@tailwindcss/vite": "4.3.1", "@tsconfig/node24": "24.0.4", "@types/codemirror": "5.60.17", "@types/is-touch-device": "1.0.3", @@ -132,7 +132,7 @@ "eslint": "9.39.4", "eslint-plugin-depend": "1.5.0", "eslint-plugin-vue": "10.9.2", - "happy-dom": "20.10.2", + "happy-dom": "20.10.3", "histoire": "1.0.0-beta.1", "otplib": "12.0.1", "postcss": "8.5.15", @@ -147,7 +147,7 @@ "stylelint-config-recommended-vue": "1.6.1", "stylelint-config-standard-scss": "17.0.0", "stylelint-use-logical": "2.1.3", - "tailwindcss": "4.3.0", + "tailwindcss": "4.3.1", "typescript": "5.9.3", "unplugin-inject-preload": "3.0.0", "vite": "7.3.5", @@ -155,7 +155,7 @@ "vite-plugin-vue-devtools": "8.1.2", "vite-svg-loader": "5.1.1", "vitest": "4.1.8", - "vue-tsc": "3.3.4", + "vue-tsc": "3.3.5", "wait-on": "9.0.10", "workbox-cli": "7.4.1", "ws": "8.21.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 21158fb5e..afd41923f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -197,8 +197,8 @@ importers: specifier: 3.6.1 version: 3.6.1 '@tailwindcss/vite': - specifier: 4.3.0 - version: 4.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + specifier: 4.3.1 + version: 4.3.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) '@tsconfig/node24': specifier: 24.0.4 version: 24.0.4 @@ -263,8 +263,8 @@ importers: specifier: 10.9.2 version: 10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: - specifier: 20.10.2 - version: 20.10.2 + specifier: 20.10.3 + version: 20.10.3 histoire: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) @@ -308,8 +308,8 @@ importers: specifier: 2.1.3 version: 2.1.3(stylelint@17.13.0(typescript@5.9.3)) tailwindcss: - specifier: 4.3.0 - version: 4.3.0 + specifier: 4.3.1 + version: 4.3.1 typescript: specifier: 5.9.3 version: 5.9.3 @@ -330,10 +330,10 @@ importers: version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: specifier: 4.1.8 - version: 4.1.8(@types/node@24.13.2)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + version: 4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: - specifier: 3.3.4 - version: 3.3.4(typescript@5.9.3) + specifier: 3.3.5 + version: 3.3.5(typescript@5.9.3) wait-on: specifier: 9.0.10 version: 9.0.10 @@ -1774,10 +1774,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -1785,19 +1781,12 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -2305,65 +2294,65 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tailwindcss/node@4.3.0': - resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} - '@tailwindcss/oxide-android-arm64@4.3.0': - resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.0': - resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.0': - resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.0': - resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': - resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': - resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': - resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': - resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-linux-x64-musl@4.3.0': - resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2374,24 +2363,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': - resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': - resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.0': - resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.3.0': - resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + '@tailwindcss/vite@4.3.1': + resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 @@ -2927,8 +2916,8 @@ packages: typescript: optional: true - '@vue/language-core@3.3.4': - resolution: {integrity: sha512-IuHqQ5zGGOE7CXP72VX6A42IVeIzYv4WAhO6arej11TRNqtdZfGyH8Yr2FOCaDX0dSQG+JwULLoFHGY1igYVjQ==} + '@vue/language-core@3.3.5': + resolution: {integrity: sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==} '@vue/reactivity@3.5.27': resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} @@ -3694,8 +3683,8 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - enhanced-resolve@5.21.3: - resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -4152,8 +4141,8 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - happy-dom@20.10.2: - resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==} + happy-dom@20.10.3: + resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -4536,6 +4525,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + joi@18.2.1: resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} @@ -6155,8 +6148,8 @@ packages: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} - tailwindcss@4.3.0: - resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -6643,8 +6636,8 @@ packages: peerDependencies: vue: ^3.5.0 - vue-tsc@3.3.4: - resolution: {integrity: sha512-XA/JqmQwS2GZmfgpjOEGdrKwaTSEuPwxpHa7/t6f4yiGrJb3gVHTPb9wBfByMNZwQ+xDXs41b8gaS2DKsOozUw==} + vue-tsc@3.3.5: + resolution: {integrity: sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==} hasBin: true peerDependencies: typescript: '>=5.0.0' @@ -6981,8 +6974,8 @@ snapshots: dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.0.2 '@babel/generator@7.29.1': @@ -8538,33 +8531,20 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/remapping@2.3.5': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -9015,72 +8995,72 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tailwindcss/node@4.3.0': + '@tailwindcss/node@4.3.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.3 - jiti: 2.6.1 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.0 + tailwindcss: 4.3.1 - '@tailwindcss/oxide-android-arm64@4.3.0': + '@tailwindcss/oxide-android-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.0': + '@tailwindcss/oxide-darwin-arm64@4.3.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.0': + '@tailwindcss/oxide-darwin-x64@4.3.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.0': + '@tailwindcss/oxide-freebsd-x64@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.0': + '@tailwindcss/oxide-linux-x64-musl@4.3.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.0': + '@tailwindcss/oxide-wasm32-wasi@4.3.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': optional: true - '@tailwindcss/oxide@4.3.0': + '@tailwindcss/oxide@4.3.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-arm64': 4.3.0 - '@tailwindcss/oxide-darwin-x64': 4.3.0 - '@tailwindcss/oxide-freebsd-x64': 4.3.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 - '@tailwindcss/oxide-linux-x64-musl': 4.3.0 - '@tailwindcss/oxide-wasm32-wasi': 4.3.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 - '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.1(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + tailwindcss: 4.3.1 vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) '@tiptap/core@3.17.0(@tiptap/pm@3.17.0)': @@ -9764,7 +9744,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vue/language-core@3.3.4': + '@vue/language-core@3.3.5': dependencies: '@volar/language-core': 2.4.28 '@vue/compiler-dom': 3.5.27 @@ -10515,7 +10495,7 @@ snapshots: dependencies: once: 1.4.0 - enhanced-resolve@5.21.3: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -11088,7 +11068,7 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - happy-dom@20.10.2: + happy-dom@20.10.3: dependencies: '@types/node': 24.13.2 '@types/whatwg-mimetype': 3.0.2 @@ -11501,6 +11481,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + joi@18.2.1: dependencies: '@hapi/address': 5.1.1 @@ -13308,7 +13290,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tailwindcss@4.3.0: {} + tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -13735,7 +13717,7 @@ snapshots: terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.8(@types/node@24.13.2)(happy-dom@20.10.2)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) @@ -13759,7 +13741,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 - happy-dom: 20.10.2 + happy-dom: 20.10.3 jsdom: 27.4.0 transitivePeerDependencies: - msw @@ -13812,10 +13794,10 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.27(typescript@5.9.3) - vue-tsc@3.3.4(typescript@5.9.3): + vue-tsc@3.3.5(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 - '@vue/language-core': 3.3.4 + '@vue/language-core': 3.3.5 typescript: 5.9.3 vue@3.5.27(typescript@5.9.3): From a8bce2ef0b99ff98d4f65b4f1c384fd5950e4fd6 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Wed, 17 Jun 2026 00:35:30 +0000 Subject: [PATCH 047/111] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/uk-UA.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/lang/uk-UA.json b/frontend/src/i18n/lang/uk-UA.json index d92642a0d..9c95bc7f1 100644 --- a/frontend/src/i18n/lang/uk-UA.json +++ b/frontend/src/i18n/lang/uk-UA.json @@ -1480,7 +1480,15 @@ "smartFill": "Заповнити з останнього запису" }, "list": { - "emptyTask": "Для цього завдання ще немає записів обліку часу." + "emptyTask": "Для цього завдання ще немає записів обліку часу.", + "emptyFiltered": "Немає записів обліку часу для вибраних фільтрів.", + "total": "Загалом", + "time": "Час", + "duration": "Тривалість" + }, + "browse": { + "selectRange": "Обрати діапазон", + "userSearch": "Знайти користувача…" } }, "time": { From ea4bb09679f7da771b35c1a27be5d04e74699f13 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:50:20 +0000 Subject: [PATCH 048/111] chore(deps): update dev-dependencies --- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 681 ++++++++++++++++------------------------ 2 files changed, 272 insertions(+), 413 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 9425d7be2..5c520099d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -139,7 +139,7 @@ "postcss-easing-gradients": "3.0.1", "postcss-html": "1.8.1", "postcss-preset-env": "11.3.0", - "rollup": "4.61.1", + "rollup": "4.62.0", "rollup-plugin-visualizer": "6.0.11", "sass-embedded": "1.100.0", "stylelint": "17.13.0", @@ -152,7 +152,7 @@ "unplugin-inject-preload": "3.0.0", "vite": "7.3.5", "vite-plugin-pwa": "1.3.0", - "vite-plugin-vue-devtools": "8.1.2", + "vite-plugin-vue-devtools": "8.1.3", "vite-svg-loader": "5.1.1", "vitest": "4.1.8", "vue-tsc": "3.3.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index afd41923f..d818b1c71 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: minimatch: ^10.2.3 - rollup: 4.61.1 + rollup: 4.62.0 basic-ftp: '>=5.2.2' serialize-javascript: ^7.0.5 flatted: ^3.4.1 @@ -41,7 +41,7 @@ importers: version: 3.1.3(@fortawesome/fontawesome-svg-core@7.1.0)(vue@3.5.27(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: 11.0.3 - version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.61.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) '@kyvg/vue3-notification': specifier: 3.4.2 version: 3.4.2(vue@3.5.27(typescript@5.9.3)) @@ -284,11 +284,11 @@ importers: specifier: 11.3.0 version: 11.3.0(postcss@8.5.14) rollup: - specifier: 4.61.1 - version: 4.61.1 + specifier: 4.62.0 + version: 4.62.0 rollup-plugin-visualizer: specifier: 6.0.11 - version: 6.0.11(rollup@4.61.1) + version: 6.0.11(rollup@4.62.0) sass-embedded: specifier: 1.100.0 version: 1.100.0 @@ -323,8 +323,8 @@ importers: specifier: 1.3.0 version: 1.3.0(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(workbox-build@7.4.1)(workbox-window@7.4.1) vite-plugin-vue-devtools: - specifier: 8.1.2 - version: 8.1.2(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) + specifier: 8.1.3 + version: 8.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) vite-svg-loader: specifier: 5.1.1 version: 5.1.1(vue@3.5.27(typescript@5.9.3)) @@ -388,14 +388,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.26.0': - resolution: {integrity: sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -433,10 +425,6 @@ packages: peerDependencies: '@babel/core': '>=7.29.6' - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,26 +437,10 @@ packages: resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.26.0': - resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6' - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': '>=7.29.6' - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -544,11 +516,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -958,26 +925,10 @@ packages: resolution: {integrity: sha512-DSgLeL/FNcpXuzav5wfYvHCGvynXkJbn3Zvc3823AEe9nPwW9IK4UoCSS5yGymmQzN0pCPvivtgS6/8U2kkm1w==} engines: {node: '>=6.9.0'} - '@babel/template@7.26.9': - resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.9': - resolution: {integrity: sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} @@ -986,10 +937,6 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1993,7 +1940,7 @@ packages: peerDependencies: '@babel/core': '>=7.29.6' '@types/babel__core': ^7.1.9 - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: '@types/babel__core': optional: true @@ -2004,7 +1951,7 @@ packages: resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: rollup: optional: true @@ -2013,7 +1960,7 @@ packages: resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: rollup: optional: true @@ -2022,7 +1969,7 @@ packages: resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} engines: {node: '>=20.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: rollup: optional: true @@ -2031,133 +1978,133 @@ packages: resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + '@rollup/rollup-android-arm-eabi@4.62.0': + resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + '@rollup/rollup-android-arm64@4.62.0': + resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + '@rollup/rollup-darwin-arm64@4.62.0': + resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + '@rollup/rollup-darwin-x64@4.62.0': + resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + '@rollup/rollup-freebsd-arm64@4.62.0': + resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + '@rollup/rollup-freebsd-x64@4.62.0': + resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + '@rollup/rollup-linux-arm64-gnu@4.62.0': + resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + '@rollup/rollup-linux-arm64-musl@4.62.0': + resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + '@rollup/rollup-linux-loong64-gnu@4.62.0': + resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + '@rollup/rollup-linux-loong64-musl@4.62.0': + resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + '@rollup/rollup-linux-ppc64-musl@4.62.0': + resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + '@rollup/rollup-linux-riscv64-musl@4.62.0': + resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + '@rollup/rollup-linux-s390x-gnu@4.62.0': + resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + '@rollup/rollup-linux-x64-gnu@4.62.0': + resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + '@rollup/rollup-linux-x64-musl@4.62.0': + resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + '@rollup/rollup-openbsd-x64@4.62.0': + resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + '@rollup/rollup-openharmony-arm64@4.62.0': + resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + '@rollup/rollup-win32-arm64-msvc@4.62.0': + resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + '@rollup/rollup-win32-ia32-msvc@4.62.0': + resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + '@rollup/rollup-win32-x64-gnu@4.62.0': + resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + '@rollup/rollup-win32-x64-msvc@4.62.0': + resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} cpu: [x64] os: [win32] @@ -2888,22 +2835,22 @@ packages: '@vue/devtools-api@7.7.7': resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==} - '@vue/devtools-core@8.1.2': - resolution: {integrity: sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg==} + '@vue/devtools-core@8.1.3': + resolution: {integrity: sha512-xezkv5/CPH/o5C8PE2Len9MnTJMsctYYQbKbbUiNOJpKd+fRHj27nKDb/sbtYI8NSQduegeQhCJGKRgAiOV6Uw==} peerDependencies: vue: ^3.0.0 '@vue/devtools-kit@7.7.7': resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==} - '@vue/devtools-kit@8.1.2': - resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==} + '@vue/devtools-kit@8.1.3': + resolution: {integrity: sha512-cRn7GXiCQkMYU2Z3h3pM4YO/ndbx9FY1yLDAqIqPLcmIq4H6zAOJHein6tvZU3AfPwgrodqLiPBEF+YQaS8AxA==} '@vue/devtools-shared@7.7.7': resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==} - '@vue/devtools-shared@8.1.2': - resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} + '@vue/devtools-shared@8.1.3': + resolution: {integrity: sha512-CM3uIPL+v+lrJUk33+pxspYo0MhuMWlCvf7zC9fybifvCPyM2jUbYRPwoYEJgYbwRqPikm5HozbUhp60MF2QuA==} '@vue/eslint-config-typescript@14.8.0': resolution: {integrity: sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==} @@ -5605,15 +5552,15 @@ packages: hasBin: true peerDependencies: rolldown: 1.x || ^1.0.0-beta - rollup: 4.61.1 + rollup: 4.62.0 peerDependenciesMeta: rolldown: optional: true rollup: optional: true - rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + rollup@4.62.0: + resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6488,8 +6435,8 @@ packages: '@vite-pwa/assets-generator': optional: true - vite-plugin-vue-devtools@8.1.2: - resolution: {integrity: sha512-gt5h1CNryR9Hy0tvhSbqY3j0F7aj0pGxBxWLa1lXSiZVkhdWDf0vbCOZyjh8ivFGE6FDHTGy3zkcZGlMZdVHig==} + vite-plugin-vue-devtools@8.1.3: + resolution: {integrity: sha512-KBTUhbTXvY+GsCdShnCHG4WdijEV74KIDxhF8erfSs5g5mS13g/cPRUf4mLpD10qr5FqHYosNt0j6rP5kpiS1Q==} engines: {node: '>=v14.21.3'} peerDependencies: vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6970,22 +6917,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.26.0': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.0.2 - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6996,19 +6927,19 @@ snapshots: '@babel/helper-annotate-as-pure@7.25.9': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helper-builder-binary-assignment-operator-visitor@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-compilation-targets@7.25.9': dependencies: - '@babel/compat-data': 7.26.0 - '@babel/helper-validator-option': 7.25.9 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -7029,7 +6960,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.25.9 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -7044,36 +6975,27 @@ snapshots: '@babel/helper-define-polyfill-provider@0.6.2(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -7084,24 +7006,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.25.9 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -7113,7 +7017,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.25.9': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.25.9': {} @@ -7124,7 +7028,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-wrap-function': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -7133,21 +7037,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.25.9 '@babel/helper-optimise-call-expression': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-simple-access@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.9': dependencies: - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -7165,9 +7069,9 @@ snapshots: '@babel/helper-wrap-function@7.25.9': dependencies: - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -7180,10 +7084,6 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -7191,25 +7091,25 @@ snapshots: '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: @@ -7218,8 +7118,8 @@ snapshots: '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -7227,7 +7127,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-decorators': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -7239,58 +7139,58 @@ snapshots: '@babel/plugin-syntax-decorators@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-assertions@7.26.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-async-generator-functions@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-async-to-generator@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -7298,18 +7198,18 @@ snapshots: '@babel/plugin-transform-block-scoped-functions@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-block-scoping@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-class-properties@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -7317,7 +7217,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -7325,10 +7225,10 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) - '@babel/traverse': 7.25.9 + '@babel/traverse': 7.29.7 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -7336,53 +7236,53 @@ snapshots: '@babel/plugin-transform-computed-properties@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/template': 7.26.9 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.29.7 '@babel/plugin-transform-destructuring@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-dotall-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-keys@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-dynamic-import@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-exponentiation-operator@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-builder-binary-assignment-operator-visitor': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color '@babel/plugin-transform-export-namespace-from@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-for-of@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color @@ -7390,45 +7290,45 @@ snapshots: '@babel/plugin-transform-function-name@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/traverse': 7.25.9 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-json-strings@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-literals@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-logical-assignment-operators@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-member-expression-literals@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-modules-amd@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color '@babel/plugin-transform-modules-commonjs@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-simple-access': 7.25.9 transitivePeerDependencies: - supports-color @@ -7436,18 +7336,18 @@ snapshots: '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-modules-umd@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -7455,34 +7355,34 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-new-target@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-nullish-coalescing-operator@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-numeric-separator@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-object-rest-spread@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.29.7) '@babel/plugin-transform-object-super@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -7490,12 +7390,12 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-optional-chaining@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color @@ -7503,13 +7403,13 @@ snapshots: '@babel/plugin-transform-parameters@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-private-methods@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -7518,41 +7418,41 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color '@babel/plugin-transform-property-literals@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 regenerator-transform: 0.15.2 '@babel/plugin-transform-regexp-modifiers@7.26.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-reserved-words@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-shorthand-properties@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-spread@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 transitivePeerDependencies: - supports-color @@ -7560,24 +7460,24 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-template-literals@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typeof-symbol@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typescript@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.25.9 '@babel/helper-create-class-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.25.9 '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.29.7) transitivePeerDependencies: @@ -7586,25 +7486,25 @@ snapshots: '@babel/plugin-transform-unicode-escapes@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-property-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-sets-regex@7.25.9(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.29.7) - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.28.6 '@babel/preset-env@7.26.0(@babel/core@7.29.7)': dependencies: @@ -7684,56 +7584,20 @@ snapshots: '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/types': 7.28.5 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.7 esutils: 2.0.3 '@babel/runtime@7.25.4': dependencies: regenerator-runtime: 0.14.1 - '@babel/template@7.26.9': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.25.9': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.26.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.26.9 - '@babel/types': 7.28.5 - debug: 4.4.3 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -7751,11 +7615,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -8484,13 +8343,13 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.61.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3))) '@intlify/shared': 11.2.8 '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.27)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.0) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) debug: 4.4.3 @@ -8734,122 +8593,122 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.61.1)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.0)': dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.0) optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 transitivePeerDependencies: - supports-color - '@rollup/plugin-node-resolve@16.0.3(rollup@4.61.1)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.0)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 - '@rollup/plugin-replace@6.0.3(rollup@4.61.1)': + '@rollup/plugin-replace@6.0.3(rollup@4.62.0)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.61.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.0) magic-string: 0.30.21 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 - '@rollup/plugin-terser@1.0.0(rollup@4.61.1)': + '@rollup/plugin-terser@1.0.0(rollup@4.62.0)': dependencies: serialize-javascript: 7.0.5 smob: 1.5.0 terser: 5.31.6 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 - '@rollup/pluginutils@5.1.3(rollup@4.61.1)': + '@rollup/pluginutils@5.1.3(rollup@4.62.0)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 - '@rollup/rollup-android-arm-eabi@4.61.1': + '@rollup/rollup-android-arm-eabi@4.62.0': optional: true - '@rollup/rollup-android-arm64@4.61.1': + '@rollup/rollup-android-arm64@4.62.0': optional: true - '@rollup/rollup-darwin-arm64@4.61.1': + '@rollup/rollup-darwin-arm64@4.62.0': optional: true - '@rollup/rollup-darwin-x64@4.61.1': + '@rollup/rollup-darwin-x64@4.62.0': optional: true - '@rollup/rollup-freebsd-arm64@4.61.1': + '@rollup/rollup-freebsd-arm64@4.62.0': optional: true - '@rollup/rollup-freebsd-x64@4.61.1': + '@rollup/rollup-freebsd-x64@4.62.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.61.1': + '@rollup/rollup-linux-arm-musleabihf@4.62.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.61.1': + '@rollup/rollup-linux-arm64-gnu@4.62.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.61.1': + '@rollup/rollup-linux-arm64-musl@4.62.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.61.1': + '@rollup/rollup-linux-loong64-gnu@4.62.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.61.1': + '@rollup/rollup-linux-loong64-musl@4.62.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.61.1': + '@rollup/rollup-linux-ppc64-gnu@4.62.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.61.1': + '@rollup/rollup-linux-ppc64-musl@4.62.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.61.1': + '@rollup/rollup-linux-riscv64-gnu@4.62.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.61.1': + '@rollup/rollup-linux-riscv64-musl@4.62.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.61.1': + '@rollup/rollup-linux-s390x-gnu@4.62.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.61.1': + '@rollup/rollup-linux-x64-gnu@4.62.0': optional: true - '@rollup/rollup-linux-x64-musl@4.61.1': + '@rollup/rollup-linux-x64-musl@4.62.0': optional: true - '@rollup/rollup-openbsd-x64@4.61.1': + '@rollup/rollup-openbsd-x64@4.62.0': optional: true - '@rollup/rollup-openharmony-arm64@4.61.1': + '@rollup/rollup-openharmony-arm64@4.62.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.61.1': + '@rollup/rollup-win32-arm64-msvc@4.62.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.61.1': + '@rollup/rollup-win32-ia32-msvc@4.62.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.61.1': + '@rollup/rollup-win32-x64-gnu@4.62.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.61.1': + '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true '@sentry-internal/browser-utils@10.36.0': @@ -9640,12 +9499,12 @@ snapshots: '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.29.7)': dependencies: - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.29.7) - '@babel/template': 7.26.9 - '@babel/traverse': 7.25.9 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@vue/babel-helper-vue-transform-on': 1.2.5 '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.29.7) html-tags: 3.3.1 @@ -9657,11 +9516,11 @@ snapshots: '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.29.7)': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/parser': 7.28.5 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/parser': 7.29.7 '@vue/compiler-sfc': 3.5.27 transitivePeerDependencies: - supports-color @@ -9702,10 +9561,10 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@8.1.2(vue@3.5.27(typescript@5.9.3))': + '@vue/devtools-core@8.1.3(vue@3.5.27(typescript@5.9.3))': dependencies: - '@vue/devtools-kit': 8.1.2 - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-kit': 8.1.3 + '@vue/devtools-shared': 8.1.3 vue: 3.5.27(typescript@5.9.3) '@vue/devtools-kit@7.7.7': @@ -9718,9 +9577,9 @@ snapshots: speakingurl: 14.0.1 superjson: 2.2.2 - '@vue/devtools-kit@8.1.2': + '@vue/devtools-kit@8.1.3': dependencies: - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-shared': 8.1.3 birpc: 2.6.1 hookable: 5.5.3 perfect-debounce: 2.0.0 @@ -9729,7 +9588,7 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/devtools-shared@8.1.2': {} + '@vue/devtools-shared@8.1.3': {} '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: @@ -9939,7 +9798,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.11(@babel/core@7.29.7): dependencies: - '@babel/compat-data': 7.26.0 + '@babel/compat-data': 7.29.7 '@babel/core': 7.29.7 '@babel/helper-define-polyfill-provider': 0.6.2(@babel/core@7.29.7) semver: 6.3.1 @@ -12665,44 +12524,44 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.11(rollup@4.61.1): + rollup-plugin-visualizer@6.0.11(rollup@4.62.0): dependencies: open: 8.4.2 picomatch: 4.0.4 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.61.1 + rollup: 4.62.0 - rollup@4.61.1: + rollup@4.62.0: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.61.1 - '@rollup/rollup-android-arm64': 4.61.1 - '@rollup/rollup-darwin-arm64': 4.61.1 - '@rollup/rollup-darwin-x64': 4.61.1 - '@rollup/rollup-freebsd-arm64': 4.61.1 - '@rollup/rollup-freebsd-x64': 4.61.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 - '@rollup/rollup-linux-arm-musleabihf': 4.61.1 - '@rollup/rollup-linux-arm64-gnu': 4.61.1 - '@rollup/rollup-linux-arm64-musl': 4.61.1 - '@rollup/rollup-linux-loong64-gnu': 4.61.1 - '@rollup/rollup-linux-loong64-musl': 4.61.1 - '@rollup/rollup-linux-ppc64-gnu': 4.61.1 - '@rollup/rollup-linux-ppc64-musl': 4.61.1 - '@rollup/rollup-linux-riscv64-gnu': 4.61.1 - '@rollup/rollup-linux-riscv64-musl': 4.61.1 - '@rollup/rollup-linux-s390x-gnu': 4.61.1 - '@rollup/rollup-linux-x64-gnu': 4.61.1 - '@rollup/rollup-linux-x64-musl': 4.61.1 - '@rollup/rollup-openbsd-x64': 4.61.1 - '@rollup/rollup-openharmony-arm64': 4.61.1 - '@rollup/rollup-win32-arm64-msvc': 4.61.1 - '@rollup/rollup-win32-ia32-msvc': 4.61.1 - '@rollup/rollup-win32-x64-gnu': 4.61.1 - '@rollup/rollup-win32-x64-msvc': 4.61.1 + '@rollup/rollup-android-arm-eabi': 4.62.0 + '@rollup/rollup-android-arm64': 4.62.0 + '@rollup/rollup-darwin-arm64': 4.62.0 + '@rollup/rollup-darwin-x64': 4.62.0 + '@rollup/rollup-freebsd-arm64': 4.62.0 + '@rollup/rollup-freebsd-x64': 4.62.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 + '@rollup/rollup-linux-arm-musleabihf': 4.62.0 + '@rollup/rollup-linux-arm64-gnu': 4.62.0 + '@rollup/rollup-linux-arm64-musl': 4.62.0 + '@rollup/rollup-linux-loong64-gnu': 4.62.0 + '@rollup/rollup-linux-loong64-musl': 4.62.0 + '@rollup/rollup-linux-ppc64-gnu': 4.62.0 + '@rollup/rollup-linux-ppc64-musl': 4.62.0 + '@rollup/rollup-linux-riscv64-gnu': 4.62.0 + '@rollup/rollup-linux-riscv64-musl': 4.62.0 + '@rollup/rollup-linux-s390x-gnu': 4.62.0 + '@rollup/rollup-linux-x64-gnu': 4.62.0 + '@rollup/rollup-linux-x64-musl': 4.62.0 + '@rollup/rollup-openbsd-x64': 4.62.0 + '@rollup/rollup-openharmony-arm64': 4.62.0 + '@rollup/rollup-win32-arm64-msvc': 4.62.0 + '@rollup/rollup-win32-ia32-msvc': 4.62.0 + '@rollup/rollup-win32-x64-gnu': 4.62.0 + '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -13662,11 +13521,11 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@8.1.2(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): + vite-plugin-vue-devtools@8.1.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 8.1.2(vue@3.5.27(typescript@5.9.3)) - '@vue/devtools-kit': 8.1.2 - '@vue/devtools-shared': 8.1.2 + '@vue/devtools-core': 8.1.3(vue@3.5.27(typescript@5.9.3)) + '@vue/devtools-kit': 8.1.3 + '@vue/devtools-shared': 8.1.3 sirv: 3.0.2 vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vite-plugin-inspect: 11.3.3(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) @@ -13705,7 +13564,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 - rollup: 4.61.1 + rollup: 4.62.0 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.13.2 @@ -13945,10 +13804,10 @@ snapshots: '@babel/core': 7.29.7 '@babel/preset-env': 7.26.0(@babel/core@7.29.7) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.61.1) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.61.1) - '@rollup/plugin-replace': 6.0.3(rollup@4.61.1) - '@rollup/plugin-terser': 1.0.0(rollup@4.61.1) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.0) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.0) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.0) + '@rollup/plugin-terser': 1.0.0(rollup@4.62.0) '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 ajv: 8.18.0 common-tags: 1.8.2 @@ -13957,7 +13816,7 @@ snapshots: fs-extra: 9.1.0 glob: 11.1.0 pretty-bytes: 5.6.0 - rollup: 4.61.1 + rollup: 4.62.0 source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 From 8bec65459542fb9c0e412e7901b1279a3803a181 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:48:41 +0200 Subject: [PATCH 049/111] refactor(background): extract download + unsplash-proxy logic for reuse Split the HTTP plumbing from the business logic in the v1 project-background download and Unsplash image proxy handlers so /api/v2 can reuse it without duplicating it: - LoadProjectBackgroundForDownload (background/handler) loads the bg file + modtime and fires the Unsplash pingback; GetProjectBackground now calls it. - WriteProjectBackground (web/files) writes v1's exact background wire shape (image/jpg, no-cache, stat-modtime Last-Modified, If-Modified-Since 304). - FetchUnsplashImageByID / FetchUnsplashThumbByID (background/unsplash) return the open upstream body for the caller to stream; the v1 proxy handlers now call them. A typed ErrUnsplashImageDoesNotExist maps to 404 on both APIs. - ErrProjectHasNoBackground (models) gives the no-background case a domain error; v1 keeps its verbatim 404 message. v1 responses are unchanged on the wire. --- pkg/models/error.go | 28 ++++++ pkg/modules/background/handler/background.go | 65 +++++++------- pkg/modules/background/unsplash/proxy.go | 93 ++++++++++++++++---- pkg/web/files/project_background.go | 55 ++++++++++++ 4 files changed, 187 insertions(+), 54 deletions(-) create mode 100644 pkg/web/files/project_background.go diff --git a/pkg/models/error.go b/pkg/models/error.go index 2f9d652c9..0b793aecc 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -535,6 +535,34 @@ 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 // ============== diff --git a/pkg/modules/background/handler/background.go b/pkg/modules/background/handler/background.go index afe7901e5..da9d0e522 100644 --- a/pkg/modules/background/handler/background.go +++ b/pkg/modules/background/handler/background.go @@ -31,6 +31,7 @@ import ( "image" "io" "net/http" + "os" "strconv" "strings" @@ -43,6 +44,7 @@ import ( "code.vikunja.io/api/pkg/modules/background/unsplash" "code.vikunja.io/api/pkg/modules/background/upload" "code.vikunja.io/api/pkg/web" + webfiles "code.vikunja.io/api/pkg/web/files" "github.com/bbrks/go-blurhash" "github.com/gabriel-vasile/mimetype" @@ -385,54 +387,47 @@ func GetProjectBackground(c *echo.Context) error { return err } - if project.BackgroundFileID == 0 { - _ = s.Rollback() - return echo.NewHTTPError(http.StatusNotFound, "Project background not found") - } - - // Get the file - bgFile := &files.File{ - ID: project.BackgroundFileID, - } - if err := bgFile.LoadFileByID(); err != nil { - _ = s.Rollback() - return err - } - stat, err := files.FileStat(bgFile) + bgFile, stat, err := LoadProjectBackgroundForDownload(s, project) if err != nil { _ = s.Rollback() + if models.IsErrProjectHasNoBackground(err) { + return echo.NewHTTPError(http.StatusNotFound, "Project background not found") + } return err } - // Unsplash requires pingbacks as per their api usage guidelines. - // To do this in a privacy-preserving manner, we do the ping from inside of Vikunja to not expose any user details. - // FIXME: This should use an event once we have events - unsplash.Pingback(s, bgFile) - if err := s.Commit(); err != nil { _ = s.Rollback() return err } - // Override the global no-store directive so browsers can cache background images. - // no-cache allows caching but requires revalidation via If-Modified-Since. - c.Response().Header().Set("Cache-Control", "no-cache") + webfiles.WriteProjectBackground(c.Response(), c.Request(), bgFile, stat) + return nil +} - // Set Last-Modified header if we have the file stat, so clients can decide whether to use cached files - if stat != nil { - modTime := stat.ModTime().UTC() - c.Response().Header().Set(echo.HeaderLastModified, modTime.Format(http.TimeFormat)) - - // Check If-Modified-Since and return 304 if the file hasn't changed - if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" { - if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) { - return c.NoContent(http.StatusNotModified) - } - } +// LoadProjectBackgroundForDownload opens the project's background file (bytes ready to +// read) and stats it for the modtime the download uses for caching. It also fires the +// Unsplash pingback side effect, required by Unsplash's API guidelines and done +// server-side so no user details are exposed. Returns ErrProjectHasNoBackground when the +// project has none; the caller owns committing the session and closing bgFile.File. +func LoadProjectBackgroundForDownload(s *xorm.Session, project *models.Project) (bgFile *files.File, stat os.FileInfo, err error) { + if project.BackgroundFileID == 0 { + return nil, nil, &models.ErrProjectHasNoBackground{ProjectID: project.ID} } - // Serve the file - return c.Stream(http.StatusOK, "image/jpg", bgFile.File) + bgFile = &files.File{ID: project.BackgroundFileID} + if err := bgFile.LoadFileByID(); err != nil { + return nil, nil, err + } + stat, err = files.FileStat(bgFile) + if err != nil { + return nil, nil, err + } + + // FIXME: This should use an event once we have events + unsplash.Pingback(s, bgFile) + + return bgFile, stat, nil } // RemoveProjectBackground removes a project background, no matter the background provider diff --git a/pkg/modules/background/unsplash/proxy.go b/pkg/modules/background/unsplash/proxy.go index 69fb23716..1d0b11607 100644 --- a/pkg/modules/background/unsplash/proxy.go +++ b/pkg/modules/background/unsplash/proxy.go @@ -18,32 +18,95 @@ package unsplash import ( "context" + "errors" + "io" "net/http" "strings" "code.vikunja.io/api/pkg/utils" + "code.vikunja.io/api/pkg/web" "github.com/labstack/echo/v5" ) -func unsplashImage(url string, c *echo.Context) error { +// ErrUnsplashImageDoesNotExist is returned when Unsplash answers an image proxy fetch +// with a non-success status, mirroring v1's echo.ErrNotFound. It satisfies +// web.HTTPErrorProcessor so the v2 error bridge maps it to a 404. +type ErrUnsplashImageDoesNotExist struct{} + +// IsErrUnsplashImageDoesNotExist checks if an error is ErrUnsplashImageDoesNotExist. +func IsErrUnsplashImageDoesNotExist(err error) bool { + var target *ErrUnsplashImageDoesNotExist + return errors.As(err, &target) +} + +func (err *ErrUnsplashImageDoesNotExist) Error() string { + return "Unsplash image does not exist" +} + +// HTTPError holds the http error description. +func (err *ErrUnsplashImageDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{HTTPCode: http.StatusNotFound, Message: "Not Found"} +} + +// fetchUnsplashImage fetches an image from Unsplash through the SSRF-safe client and +// returns its still-open response body for the caller to stream and close. The url is +// rebased onto the hardcoded images.unsplash.com host (stripping any client-supplied +// host) so the proxy can only ever reach Unsplash. It returns +// ErrUnsplashImageDoesNotExist on a non-success upstream status. +func fetchUnsplashImage(url string) (io.ReadCloser, error) { // Replacing and appending the url for security reasons req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://images.unsplash.com/"+strings.Replace(url, "https://images.unsplash.com/", "", 1), nil) if err != nil { - return err + return nil, err } resp, err := utils.NewSSRFSafeHTTPClient().Do(req) //nolint:gosec // SSRF protection is handled by the SSRF-safe client if err != nil { - return err + return nil, err } - defer resp.Body.Close() if resp.StatusCode > 399 { - return echo.ErrNotFound + _ = resp.Body.Close() + return nil, &ErrUnsplashImageDoesNotExist{} } - return c.Stream(http.StatusOK, "image/jpg", resp.Body) + return resp.Body, nil } -// ProxyUnsplashImage proxies a thumbnail from unsplash for privacy reasons. +// FetchUnsplashImageByID resolves an Unsplash image by id, fires the required pingback, +// and returns the full-resolution image body for the caller to stream and close. +func FetchUnsplashImageByID(imageID string) (io.ReadCloser, error) { + photo, err := getUnsplashPhotoInfoByID(imageID) + if err != nil { + return nil, err + } + pingbackByPhotoID(photo.ID) + return fetchUnsplashImage(photo.Urls.Raw) +} + +// FetchUnsplashThumbByID resolves an Unsplash image by id, fires the required pingback, +// and returns a thumbnail (max width 200px) body for the caller to stream and close. +func FetchUnsplashThumbByID(imageID string) (io.ReadCloser, error) { + photo, err := getUnsplashPhotoInfoByID(imageID) + if err != nil { + return nil, err + } + pingbackByPhotoID(photo.ID) + return fetchUnsplashImage("https://images.unsplash.com/" + getImageID(photo.Urls.Raw) + "?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ") +} + +// streamUnsplashImage streams a fetched image body to the v1 echo response, mapping the +// not-found sentinel back to echo.ErrNotFound so v1's wire response is unchanged. +func streamUnsplashImage(body io.ReadCloser, err error, c *echo.Context) error { + if err != nil { + if IsErrUnsplashImageDoesNotExist(err) { + return echo.ErrNotFound + } + return err + } + defer body.Close() + return c.Stream(http.StatusOK, "image/jpg", body) +} + +// ProxyUnsplashImage proxies an image from unsplash for privacy reasons. // @Summary Get an unsplash image // @Description Get an unsplash image. **Returns json on error.** // @tags project @@ -55,12 +118,8 @@ func unsplashImage(url string, c *echo.Context) error { // @Failure 500 {object} models.Message "Internal error" // @Router /backgrounds/unsplash/image/{image} [get] func ProxyUnsplashImage(c *echo.Context) error { - photo, err := getUnsplashPhotoInfoByID(c.Param("image")) - if err != nil { - return err - } - pingbackByPhotoID(photo.ID) - return unsplashImage(photo.Urls.Raw, c) + body, err := FetchUnsplashImageByID(c.Param("image")) + return streamUnsplashImage(body, err, c) } // ProxyUnsplashThumb proxies a thumbnail from unsplash for privacy reasons. @@ -75,10 +134,6 @@ func ProxyUnsplashImage(c *echo.Context) error { // @Failure 500 {object} models.Message "Internal error" // @Router /backgrounds/unsplash/image/{image}/thumb [get] func ProxyUnsplashThumb(c *echo.Context) error { - photo, err := getUnsplashPhotoInfoByID(c.Param("image")) - if err != nil { - return err - } - pingbackByPhotoID(photo.ID) - return unsplashImage("https://images.unsplash.com/"+getImageID(photo.Urls.Raw)+"?ixlib=rb-1.2.1&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=200&fit=max&ixid=eyJhcHBfaWQiOjcyODAwfQ", c) + body, err := FetchUnsplashThumbByID(c.Param("image")) + return streamUnsplashImage(body, err, c) } diff --git a/pkg/web/files/project_background.go b/pkg/web/files/project_background.go new file mode 100644 index 000000000..aeda725a7 --- /dev/null +++ b/pkg/web/files/project_background.go @@ -0,0 +1,55 @@ +// 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 . + +package files + +import ( + "io" + "net/http" + "os" + + "code.vikunja.io/api/pkg/files" +) + +// WriteProjectBackground streams a project's background file (its .File reader must be +// open) to the response, shared by the v1 and v2 background handlers. It does not close +// the reader; the caller owns it. +// +// The wire shape differs from WriteFileDownload on purpose and must stay byte-identical +// to v1: backgrounds are always served as image/jpg (no Content-Disposition, no +// Content-Length), with a cache-revalidation Last-Modified from the storage modtime +// rather than the file's DB Created timestamp. +func WriteProjectBackground(w http.ResponseWriter, r *http.Request, bgFile *files.File, stat os.FileInfo) { + // Override the global no-store directive so browsers can cache background images. + // no-cache allows caching but requires revalidation via If-Modified-Since. + w.Header().Set("Cache-Control", "no-cache") + + if stat != nil { + modTime := stat.ModTime().UTC() + w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat)) + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, err := http.ParseTime(ifModSince); err == nil && !modTime.After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + } + + w.Header().Set("Content-Type", "image/jpg") + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, bgFile.File) +} From 5ccbd0d74e15410a0e3f83a25b316f367ed634b1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:48:47 +0200 Subject: [PATCH 050/111] feat(api/v2): add project background download and unsplash proxies Port the remaining read-only background blob endpoints to /api/v2: - GET /projects/{project}/background streams the stored background (project CanRead, in-handler), modeled as an image/jpeg binary response. Honors If-Modified-Since (304) and serves through the shared WriteProjectBackground. - GET /backgrounds/unsplash/images/{image} and .../thumb proxy the upstream Unsplash image through the SSRF-safe client, gated on the unsplash provider like the sibling unsplash routes, modeled as image/jpeg binary responses. All three reuse the v1 business logic extracted in the previous commit. --- pkg/routes/api/v2/backgrounds.go | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go index f01fcb4e3..4d7b5befe 100644 --- a/pkg/routes/api/v2/backgrounds.go +++ b/pkg/routes/api/v2/backgrounds.go @@ -18,6 +18,7 @@ package apiv2 import ( "context" + "io" "net/http" "code.vikunja.io/api/pkg/config" @@ -26,6 +27,8 @@ import ( "code.vikunja.io/api/pkg/modules/background" backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" "code.vikunja.io/api/pkg/modules/background/unsplash" + "code.vikunja.io/api/pkg/modules/humaecho5" + webfiles "code.vikunja.io/api/pkg/web/files" "github.com/danielgtaylor/huma/v2" ) @@ -55,6 +58,26 @@ func RegisterBackgroundRoutes(api huma.API) { Tags: tags, }, backgroundRemove) + Register(api, huma.Operation{ + OperationID: "projects-background-get", + Summary: "Get a project background", + Description: "Streams a project's background image, whichever provider set it. Requires read access to the project. Always served as image/jpeg with a revalidation Last-Modified header, so a conditional If-Modified-Since request gets a 304. Returns 404 when the project has no background.", + Method: http.MethodGet, + Path: "/projects/{project}/background", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The project background as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, backgroundGet) + if config.BackgroundsUploadEnabled.GetBool() { Register(api, huma.Operation{ OperationID: "projects-background-upload", @@ -89,6 +112,39 @@ func RegisterBackgroundRoutes(api huma.API) { Path: "/projects/{project}/backgrounds/unsplash", Tags: tags, }, backgroundUnsplashSet) + + unsplashProxyResponses := map[string]*huma.Response{ + "200": { + Description: "The proxied Unsplash image as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + } + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-image", + Summary: "Proxy a full-resolution Unsplash image", + Description: "Proxies the full-resolution Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashImage) + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-thumb", + Summary: "Proxy an Unsplash image thumbnail", + Description: "Proxies a thumbnail (max width 200px) of the Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}/thumb", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashThumb) } } @@ -227,6 +283,92 @@ func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBo return &singleBody[models.Project]{Body: project}, nil } +// backgroundGet owns auth, the session and the permission check because there is no +// handler.Do* for a file body. CanRead hydrates the project (including its +// BackgroundFileID), which the shared loader then needs. +func backgroundGet(ctx context.Context, in *struct { + ProjectID int64 `path:"project" doc:"The id of the project whose background to fetch."` +}) (*huma.StreamResponse, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, _, err := project.CanRead(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + bgFile, stat, err := backgroundHandler.LoadProjectBackgroundForDownload(s, project) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + // The stream callback (which closes the reader) won't run on this error path. + _ = bgFile.File.Close() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = bgFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteProjectBackground((*c).Response(), (*c).Request(), bgFile, stat) + }}, nil +} + +func backgroundUnsplashImage(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashImageByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +func backgroundUnsplashThumb(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashThumbByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +// streamUnsplashProxy copies the open upstream Unsplash body to the response as +// image/jpeg and closes it, mirroring v1's c.Stream. +func streamUnsplashProxy(body io.ReadCloser) *huma.StreamResponse { + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = body.Close() }() + c := humaecho5.Unwrap(hctx) + resp := (*c).Response() + resp.Header().Set("Content-Type", "image/jpg") + resp.WriteHeader(http.StatusOK) + _, _ = io.Copy(resp, body) + }} +} + func backgroundRemove(ctx context.Context, in *struct { ProjectID int64 `path:"project"` }) (*singleBody[models.Project], error) { From c5d615843d4cf1d83aa38dd83edbcf9d607e57f5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:48:55 +0200 Subject: [PATCH 051/111] test(api/v2): cover background download and unsplash proxy routes - Download: upload-then-download (real bytes), content-type, If-Modified-Since 304, read-only access allowed, no-access 403, unauthenticated 401, no background 404, and the config-disabled route being absent. - Unsplash proxies: routes absent when the provider is disabled, and 401 when unauthenticated. The live Unsplash fetch is not exercised, matching v1. --- pkg/webtests/huma_background_download_test.go | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 pkg/webtests/huma_background_download_test.go diff --git a/pkg/webtests/huma_background_download_test.go b/pkg/webtests/huma_background_download_test.go new file mode 100644 index 000000000..e4c542a26 --- /dev/null +++ b/pkg/webtests/huma_background_download_test.go @@ -0,0 +1,162 @@ +// 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 . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// getBackgroundRequest issues a GET against the background download route with an +// optional If-Modified-Since header (humaRequest can't set arbitrary headers). +func getBackgroundRequest(t *testing.T, e *echo.Echo, project, token, ifModifiedSince string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/"+project+"/background", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if ifModifiedSince != "" { + req.Header.Set("If-Modified-Since", ifModifiedSince) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaProjectBackgroundDownload covers GET /projects/{project}/background. The +// fixture file row (project 35, background_file_id 1) carries no bytes, so the happy +// path uploads a real background first (the "upload-then-download" pattern) before +// fetching it back. +func TestHumaProjectBackgroundDownload(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Owner uploads then downloads the background", func(t *testing.T) { + // testuser1 owns project 1, which starts without a background. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "image/jpg", rec.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) + assert.NotEmpty(t, rec.Body.Bytes(), "the download must return the stored bytes") + }) + + t.Run("If-Modified-Since returns 304", func(t *testing.T) { + // The in-memory test storage reports a zero modtime, so any valid + // If-Modified-Since is not-before it and yields a 304. + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "1", humaTokenFor(t, &testuser1), "Wed, 21 Oct 2015 07:28:00 GMT") + assert.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String()) + assert.Empty(t, rec.Body.Bytes(), "a 304 must not carry a body") + }) + + t.Run("Project without a background returns 404", func(t *testing.T) { + // testuser1 owns project 21, which has no background and isn't uploaded to + // by any other subtest (project 1 is, and subtests share this env). + rec := getBackgroundRequest(t, e, "21", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Read-only user may download", func(t *testing.T) { + // testuser6 owns project 35 and uploads a real background; testuser15 has + // read-only access, which CanRead allows for the download. Uploading first + // gives the file real bytes (the fixture row has none). + body, contentType := multipartFileBody(t, "background", "bg.png", pngBytes(t)) + up := uploadBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), body, contentType) + require.Equal(t, http.StatusOK, up.Code, "upload body: %s", up.Body.String()) + + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotEmpty(t, rec.Body.Bytes(), "the read-only user must receive the bytes") + }) + + t.Run("No access at all is forbidden", func(t *testing.T) { + // testuser1 has no access to project 35. + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := getBackgroundRequest(t, e, "35", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaProjectBackgroundDownloadDisabledByConfig verifies the download route is +// absent (404) when project backgrounds are disabled. +func TestHumaProjectBackgroundDownloadDisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.BackgroundsEnabled.Set(false) + defer config.BackgroundsEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := getBackgroundRequest(t, e, "35", humaTokenFor(t, &testuser6), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when backgrounds are disabled; body: %s", rec.Body.String()) +} + +// TestHumaUnsplashProxy covers the Unsplash image/thumb proxy routes' gating and auth. +// They only register when the unsplash provider is enabled (off by default), so the +// router is rebuilt with the flag on. The proxy's happy path needs the live Unsplash +// API and is therefore not covered here, matching v1 (which has no proxy tests). +func TestHumaUnsplashProxy(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + t.Run("Routes absent when unsplash is disabled", func(t *testing.T) { + // Unsplash is disabled by default; the proxy routes must not exist. + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "image proxy must be absent when unsplash is disabled; body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "thumb proxy must be absent when unsplash is disabled; body: %s", rec.Body.String()) + }) + + t.Run("Proxies require auth when unsplash is enabled", func(t *testing.T) { + config.BackgroundsUnsplashEnabled.Set(true) + defer config.BackgroundsUnsplashEnabled.Set(false) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "image proxy body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/backgrounds/unsplash/images/abc/thumb", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "thumb proxy body: %s", rec.Body.String()) + }) +} From ffcf92936a86a945462fb20c77f293458ba83c26 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:38:59 +0000 Subject: [PATCH 052/111] chore(deps): update dev-dependencies --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 10 +- frontend/package.json | 10 +- frontend/pnpm-lock.yaml | 392 +++++++++++++++++++++------------------- 4 files changed, 215 insertions(+), 199 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 415238608..1aaabf2c2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -61,7 +61,7 @@ } }, "devDependencies": { - "electron": "40.10.3", + "electron": "40.10.4", "electron-builder": "26.15.3", "unzipper": "0.12.3" }, diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 0eaa08028..248ad6337 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -23,8 +23,8 @@ importers: version: 5.2.1 devDependencies: electron: - specifier: 40.10.3 - version: 40.10.3 + specifier: 40.10.4 + version: 40.10.4 electron-builder: specifier: 26.15.3 version: 26.15.3(electron-builder-squirrel-windows@24.13.3) @@ -535,8 +535,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron@40.10.3: - resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==} + electron@40.10.4: + resolution: {integrity: sha512-ouNZrXXmdPL/wiTQ+xzXpb7B/BHg+j7XARig0SE7azFO3bjbYUd6lFjIAAiDQ02Pl/Oj7MUk+4C0hdf9yFtA1A==} engines: {node: '>= 22.12.0'} hasBin: true @@ -2184,7 +2184,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.10.3: + electron@40.10.4: dependencies: '@electron-internal/extract-zip': 1.0.2 '@electron/get': 5.0.0 diff --git a/frontend/package.json b/frontend/package.json index 5c520099d..d2ce35838 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,8 +117,8 @@ "@types/node": "24.13.2", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", "@vitejs/plugin-vue": "6.0.7", "@vue/eslint-config-typescript": "14.8.0", "@vue/test-utils": "2.4.11", @@ -132,13 +132,13 @@ "eslint": "9.39.4", "eslint-plugin-depend": "1.5.0", "eslint-plugin-vue": "10.9.2", - "happy-dom": "20.10.3", + "happy-dom": "20.10.5", "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.0", + "postcss-preset-env": "11.3.1", "rollup": "4.62.0", "rollup-plugin-visualizer": "6.0.11", "sass-embedded": "1.100.0", @@ -154,7 +154,7 @@ "vite-plugin-pwa": "1.3.0", "vite-plugin-vue-devtools": "8.1.3", "vite-svg-loader": "5.1.1", - "vitest": "4.1.8", + "vitest": "4.1.9", "vue-tsc": "3.3.5", "wait-on": "9.0.10", "workbox-cli": "7.4.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index d818b1c71..5d8174584 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -218,17 +218,17 @@ importers: specifier: 8.18.1 version: 8.18.1 '@typescript-eslint/eslint-plugin': - specifier: 8.61.0 - version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.61.1 + version: 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.61.0 - version: 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.61.1 + version: 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.7 version: 6.0.7(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.8.0 - version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.11 version: 2.4.11(@vue/compiler-dom@3.5.27)(@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) @@ -261,10 +261,10 @@ importers: version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.9.2 - version: 10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: - specifier: 20.10.3 - version: 20.10.3 + specifier: 20.10.5 + version: 20.10.5 histoire: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) @@ -281,8 +281,8 @@ importers: specifier: 1.8.1 version: 1.8.1 postcss-preset-env: - specifier: 11.3.0 - version: 11.3.0(postcss@8.5.14) + specifier: 11.3.1 + version: 11.3.1(postcss@8.5.14) rollup: specifier: 4.62.0 version: 4.62.0 @@ -329,8 +329,8 @@ importers: specifier: 5.1.1 version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: - specifier: 4.1.8 - version: 4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + specifier: 4.1.9 + version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.5)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: specifier: 3.3.5 version: 3.3.5(typescript@5.9.3) @@ -1007,8 +1007,8 @@ packages: '@csstools/css-parser-algorithms': ^3.0.5 '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-color-parser@4.1.1': - resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + '@csstools/css-color-parser@4.1.7': + resolution: {integrity: sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -1057,8 +1057,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/postcss-alpha-function@2.0.5': - resolution: {integrity: sha512-i2lNJ6b4GdMoybHlpUM07TIk8KQRXTTe7Qf8LfctQhjDRTIgaodWTQqzWU4fpWO/nxBWNkSloDM22Lw/30NBcg==} + '@csstools/postcss-alpha-function@2.0.6': + resolution: {integrity: sha512-XaMnJJqqZv4veulLELvM+5caEMcLTsFyqTrkwGKPMF+UbiM7dlQoe4K46EnwfSJIvnm91K1ZXsZSd3OuJ04p9w==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1069,26 +1069,26 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-function-display-p3-linear@2.0.4': - resolution: {integrity: sha512-xrGqSFj9pu6XbJYD4NNCxYK9WFbf0KMfXFaisnJezkIRDZCwefUB2azkU4Zr0dFmLtIb9LlshrSZ0be1/QVthQ==} + '@csstools/postcss-color-function-display-p3-linear@2.0.5': + resolution: {integrity: sha512-YzY5qI0S/CsvqvMSiDn85ZyTCRLdnywxQn+6Fv8AU17aCE/fjcor54OSdVb/HlABBTcBq+d8NlWcLz11Bmo2mQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-function@5.0.4': - resolution: {integrity: sha512-PhUu86ppxKcNHHqrJ43ZL1mYa2uHKGRoY0KPbZA9k8iOaanL3I+1zYqbgVumxj1UgNTDw5BE3BUQ1Dono6bD6g==} + '@csstools/postcss-color-function@5.0.5': + resolution: {integrity: sha512-s+9fU1+sZazUNk0WyKShlfmTLC0fosxNY5x7DiD637xXbZLX2lyce23QrdRhytP3Ja1G77qUk6cRD37N1gemdQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-mix-function@4.0.4': - resolution: {integrity: sha512-zYS78MHBuih9f9qtPFcSvVXMKg9q/lNPeFJUjyw7+/W1VHRjubvs5MlzuC363UUeahAhrOvYdo2ZZhmlxZbj6w==} + '@csstools/postcss-color-mix-function@4.0.5': + resolution: {integrity: sha512-eBrrzTKudOlDl2XOJzW/pzHPIkC8tGkcGpNiFO/vmevb08U1huYEINhlxr8iz4OzSqs1GtiJx4d2v5iHFOZjNw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4': - resolution: {integrity: sha512-qlrABMEFPUqbCxX0aOsHcxQZo/8XgMqnEtqqtVUbdizcuTUtJyLdHike7hkoemwDspMSEotdIfRlUY4jhZaD+A==} + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.5': + resolution: {integrity: sha512-O4tE1hZXfEAbTP1IC2R857KjPCLNtpsFUqY2dqgycF/3M6GuFyJI20EWwkxVZzlSFvWdIcNppwRf9pxPFn0qnA==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1105,8 +1105,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-contrast-color-function@3.0.4': - resolution: {integrity: sha512-EiTZzUICztGqEuYg8AVCUWH9vH2jDzO6RryxMja+PWluZHP6n3/iG6i1leTt5LiDQjDUQlCRbQtMNj7V7S+b4Q==} + '@csstools/postcss-contrast-color-function@3.0.5': + resolution: {integrity: sha512-gfdTZ4a5ioL2zM/yN2FqExy6rql+6egkI5sDuK9MvrbfrVJMzB0OjiCkboT5UprU/P0JwfTiIutW1ZSyqK4Icw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1129,20 +1129,20 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-gamut-mapping@3.0.4': - resolution: {integrity: sha512-2dWGsxtxypKU9Ra862F2335W8xegRwl9ohQ6hk808PiQlEahSaFtt5fqsGmKDaSiaFUx+2X8GZxVo970Ajr2vQ==} + '@csstools/postcss-gamut-mapping@3.0.5': + resolution: {integrity: sha512-X6XkKkR9R8KyJey9n1ryEzzfX6WpihPz/JBsyIVvxAlztQcMjMA7I9mMybWVv3ZyRMC+0+H7RlIUe85vZkasNQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-gradients-interpolation-method@6.0.4': - resolution: {integrity: sha512-sC/7dqVTtQTniLjPp/NagzeUn4sGinnMTicNBLDzirKq/GNXuJaApBOnvBmgNXjV6XPizfMhNRYCk5stn3q2nQ==} + '@csstools/postcss-gradients-interpolation-method@6.0.5': + resolution: {integrity: sha512-wXiZI6bLRAGcw7XuzsqqPnTVNrHFkHTkcymK2su+ynJjemfCdpCD9HdG+ICikPqtQ782r6LSZdyC3cDhSQqF3Q==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-hwb-function@5.0.4': - resolution: {integrity: sha512-cl0KPaaeYyAXNHO3pqK8adbpbAGmIU1cT1thyaEkmP8yvbJvmyztkpdGADGqziUUoh4dZQ0IhHxOxnKQ296T+A==} + '@csstools/postcss-hwb-function@5.0.5': + resolution: {integrity: sha512-HeJOXAMr1nYHZ7gJT1+6d899X9Y+5qJcpbLJ8WzhujQOIB4oqbzeP3769sd1xl3eH4qbasxtewxr4crs08SEQw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1237,8 +1237,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-oklab-function@5.0.4': - resolution: {integrity: sha512-vIgrKe5ffW99it5SUIXOBczGLSiTaHBhU6afVr9KPwoZ4uq9H0E3Ehvi+xsUjmvnAyMTxOUSszNo04kEhbvYjQ==} + '@csstools/postcss-oklab-function@5.0.5': + resolution: {integrity: sha512-A+Nkzj2ODvQboM5FlqEcp0iqilyVo78f9FMx/3cHrRrEBqCymSXvf8sa1cTY54lJoUVI3Sn9XysgvYaVIAuIYg==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -1267,8 +1267,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - '@csstools/postcss-relative-color-syntax@4.0.4': - resolution: {integrity: sha512-reFFKD9eS602We8621e5cAroKD7hH4104duLNBBhzwawGN7dhbnL1+c/DRHqwyq6eGK35HaKMMiifEZhAztlOA==} + '@csstools/postcss-relative-color-syntax@4.0.5': + resolution: {integrity: sha512-kBzf+LIm824cpjsZPhNtl/2N1KK+TXnxy8Kce4y+pEAQSrxhpX6WDUg54wjdHBGx2UZUXKBnlaUOsc71sSRDvg==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -2612,11 +2612,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.0 + '@typescript-eslint/parser': ^8.61.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' @@ -2627,8 +2627,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2646,8 +2646,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2660,8 +2660,8 @@ packages: resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.0': @@ -2682,6 +2682,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.60.1': resolution: {integrity: sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2689,8 +2695,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2708,6 +2714,10 @@ packages: resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.0': resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2720,8 +2730,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2733,8 +2743,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2748,8 +2758,8 @@ packages: resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -2763,11 +2773,11 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2777,20 +2787,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -4088,8 +4098,8 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - happy-dom@20.10.3: - resolution: {integrity: sha512-Hjdiy8RziuCcn5z04QI/rlsNuQoG8P0xxjgvsSMpi89cvIXIOcucQtiHS1yHSShxoBcSCeYqAskINmTiy/mlfw==} + happy-dom@20.10.5: + resolution: {integrity: sha512-0aA6BQoMnpcRE/c1E8ZyF2jXnET7MJskereWOXher4CJuYjrI5esN0Az/1NPMD4KeWUbampBGw2MGqabMPFIbg==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -5116,8 +5126,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-color-functional-notation@8.0.4: - resolution: {integrity: sha512-Zn3yPgBFakVXthmA2n1NUMY7gdhuFUB/DrUJ0Eug/d0rl9wahMQZykp4NVTJLGzQrDUwZ2rzjiTeW5udxFNG8A==} + postcss-color-functional-notation@8.0.5: + resolution: {integrity: sha512-Cxr97Vtt2VeJCGaex0JNSU5MViqYtjKmJLHKM+jI7d+qIs0J5xgHEVG6Q2bTCaFJ1yjcFz9s9VmWCibuzk3+MA==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -5201,8 +5211,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-lab-function@8.0.4: - resolution: {integrity: sha512-dqcJSzVasdELD9xqJ1wfP95uzP57J6zFd80c7S3AWK127H9zwqR9Kbk5ZgyIfN2DiMStI7Vq8E7ablXNeTvpew==} + postcss-lab-function@8.0.5: + resolution: {integrity: sha512-ohQnYx1LloPkiLQhAjpt/Y9tAGCGOBOUaxgbcmO+1bDTFzUQCTfdpemOVh6oewI4V2K6q7+Vz8d3rP1glvK3uw==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -5245,8 +5255,8 @@ packages: peerDependencies: postcss: '>=8.5.10' - postcss-preset-env@11.3.0: - resolution: {integrity: sha512-PpijTuY+NT35vvk7us0pw9lJVrsZZWukjONZsza2Kq1Gag8nrUXRkgdKdxyyhZPJ6R43L3/nLpspUK99TmU9xg==} + postcss-preset-env@11.3.1: + resolution: {integrity: sha512-ox2lu2L0fbuKXB0zRcUFCNii7koS9+fNLFqj+WOKaJ4DU/zZsYkFHOmz73lWNTKx8OHDqnV0R7Si98PIbJXLjQ==} engines: {node: '>=20.19.0'} peerDependencies: postcss: '>=8.5.10' @@ -6491,20 +6501,20 @@ packages: yaml: optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -7704,7 +7714,7 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7736,9 +7746,9 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-alpha-function@2.0.5(postcss@8.5.14)': + '@csstools/postcss-alpha-function@2.0.6(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -7751,36 +7761,36 @@ snapshots: postcss: 8.5.14 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@2.0.4(postcss@8.5.14)': + '@csstools/postcss-color-function-display-p3-linear@2.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-color-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-mix-function@4.0.4(postcss@8.5.14)': + '@csstools/postcss-color-mix-function@4.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-color-mix-variadic-function-arguments@2.0.4(postcss@8.5.14)': + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -7801,9 +7811,9 @@ snapshots: '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-contrast-color-function@3.0.4(postcss@8.5.14)': + '@csstools/postcss-contrast-color-function@3.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -7828,25 +7838,25 @@ snapshots: '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-gamut-mapping@3.0.4(postcss@8.5.14)': + '@csstools/postcss-gamut-mapping@3.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 postcss: 8.5.14 - '@csstools/postcss-gradients-interpolation-method@6.0.4(postcss@8.5.14)': + '@csstools/postcss-gradients-interpolation-method@6.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/utilities': 3.0.0(postcss@8.5.14) postcss: 8.5.14 - '@csstools/postcss-hwb-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-hwb-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -7941,9 +7951,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@5.0.4(postcss@8.5.14)': + '@csstools/postcss-oklab-function@5.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -7972,9 +7982,9 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 postcss: 8.5.14 - '@csstools/postcss-relative-color-syntax@4.0.4(postcss@8.5.14)': + '@csstools/postcss-relative-color-syntax@4.0.5(postcss@8.5.14)': dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -9228,14 +9238,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -9256,12 +9266,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 @@ -9270,8 +9280,8 @@ snapshots: '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9279,17 +9289,17 @@ snapshots: '@typescript-eslint/project-service@8.60.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) - '@typescript-eslint/types': 8.60.1 + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9305,10 +9315,10 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - '@typescript-eslint/scope-manager@8.61.0': + '@typescript-eslint/scope-manager@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: @@ -9322,6 +9332,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.60.1 @@ -9334,11 +9348,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -9352,6 +9366,8 @@ snapshots: '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.61.1': {} + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) @@ -9382,12 +9398,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.3 @@ -9408,12 +9424,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -9429,9 +9445,9 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.0 - '@typescript-eslint/visitor-keys@8.61.0': + '@typescript-eslint/visitor-keys@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/types': 8.61.1 eslint-visitor-keys: 5.0.0 '@ungap/structured-clone@1.3.0': {} @@ -9442,44 +9458,44 @@ snapshots: vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) vue: 3.5.27(typescript@5.9.3) - '@vitest/expect@4.1.8': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': + '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3) - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.8': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -9590,11 +9606,11 @@ snapshots: '@vue/devtools-shared@8.1.3': {} - '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) @@ -10513,7 +10529,7 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -10524,7 +10540,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -10927,7 +10943,7 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - happy-dom@20.10.3: + happy-dom@20.10.5: dependencies: '@types/node': 24.13.2 '@types/whatwg-mimetype': 3.0.2 @@ -11951,9 +11967,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@8.0.4(postcss@8.5.14): + postcss-color-functional-notation@8.0.5(postcss@8.5.14): dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -12047,9 +12063,9 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-lab-function@8.0.4(postcss@8.5.14): + postcss-lab-function@8.0.5(postcss@8.5.14): dependencies: - '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.7(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) @@ -12088,23 +12104,23 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - postcss-preset-env@11.3.0(postcss@8.5.14): + postcss-preset-env@11.3.1(postcss@8.5.14): dependencies: - '@csstools/postcss-alpha-function': 2.0.5(postcss@8.5.14) + '@csstools/postcss-alpha-function': 2.0.6(postcss@8.5.14) '@csstools/postcss-cascade-layers': 6.0.0(postcss@8.5.14) - '@csstools/postcss-color-function': 5.0.4(postcss@8.5.14) - '@csstools/postcss-color-function-display-p3-linear': 2.0.4(postcss@8.5.14) - '@csstools/postcss-color-mix-function': 4.0.4(postcss@8.5.14) - '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.4(postcss@8.5.14) + '@csstools/postcss-color-function': 5.0.5(postcss@8.5.14) + '@csstools/postcss-color-function-display-p3-linear': 2.0.5(postcss@8.5.14) + '@csstools/postcss-color-mix-function': 4.0.5(postcss@8.5.14) + '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.5(postcss@8.5.14) '@csstools/postcss-container-rule-prelude-list': 1.0.1(postcss@8.5.14) '@csstools/postcss-content-alt-text': 3.0.1(postcss@8.5.14) - '@csstools/postcss-contrast-color-function': 3.0.4(postcss@8.5.14) + '@csstools/postcss-contrast-color-function': 3.0.5(postcss@8.5.14) '@csstools/postcss-exponential-functions': 3.0.3(postcss@8.5.14) '@csstools/postcss-font-format-keywords': 5.0.0(postcss@8.5.14) '@csstools/postcss-font-width-property': 1.0.0(postcss@8.5.14) - '@csstools/postcss-gamut-mapping': 3.0.4(postcss@8.5.14) - '@csstools/postcss-gradients-interpolation-method': 6.0.4(postcss@8.5.14) - '@csstools/postcss-hwb-function': 5.0.4(postcss@8.5.14) + '@csstools/postcss-gamut-mapping': 3.0.5(postcss@8.5.14) + '@csstools/postcss-gradients-interpolation-method': 6.0.5(postcss@8.5.14) + '@csstools/postcss-hwb-function': 5.0.5(postcss@8.5.14) '@csstools/postcss-ic-unit': 5.0.1(postcss@8.5.14) '@csstools/postcss-image-function': 1.0.0(postcss@8.5.14) '@csstools/postcss-initial': 3.0.0(postcss@8.5.14) @@ -12120,12 +12136,12 @@ snapshots: '@csstools/postcss-mixins': 1.0.0(postcss@8.5.14) '@csstools/postcss-nested-calc': 5.0.0(postcss@8.5.14) '@csstools/postcss-normalize-display-values': 5.0.1(postcss@8.5.14) - '@csstools/postcss-oklab-function': 5.0.4(postcss@8.5.14) + '@csstools/postcss-oklab-function': 5.0.5(postcss@8.5.14) '@csstools/postcss-position-area-property': 2.0.0(postcss@8.5.14) '@csstools/postcss-progressive-custom-properties': 5.1.0(postcss@8.5.14) '@csstools/postcss-property-rule-prelude-list': 2.0.0(postcss@8.5.14) '@csstools/postcss-random-function': 3.0.3(postcss@8.5.14) - '@csstools/postcss-relative-color-syntax': 4.0.4(postcss@8.5.14) + '@csstools/postcss-relative-color-syntax': 4.0.5(postcss@8.5.14) '@csstools/postcss-scope-pseudo-class': 5.0.0(postcss@8.5.14) '@csstools/postcss-sign-functions': 2.0.3(postcss@8.5.14) '@csstools/postcss-stepped-value-functions': 5.0.3(postcss@8.5.14) @@ -12143,7 +12159,7 @@ snapshots: postcss: 8.5.14 postcss-attribute-case-insensitive: 8.0.0(postcss@8.5.14) postcss-clamp: 4.1.0(postcss@8.5.14) - postcss-color-functional-notation: 8.0.4(postcss@8.5.14) + postcss-color-functional-notation: 8.0.5(postcss@8.5.14) postcss-color-hex-alpha: 11.0.0(postcss@8.5.14) postcss-color-rebeccapurple: 11.0.0(postcss@8.5.14) postcss-custom-media: 12.0.1(postcss@8.5.14) @@ -12156,7 +12172,7 @@ snapshots: postcss-font-variant: 5.0.0(postcss@8.5.14) postcss-gap-properties: 7.0.0(postcss@8.5.14) postcss-image-set-function: 8.0.0(postcss@8.5.14) - postcss-lab-function: 8.0.4(postcss@8.5.14) + postcss-lab-function: 8.0.5(postcss@8.5.14) postcss-logical: 9.0.0(postcss@8.5.14) postcss-nesting: 14.0.0(postcss@8.5.14) postcss-opacity-percentage: 3.0.0(postcss@8.5.14) @@ -13576,15 +13592,15 @@ snapshots: terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.8(@types/node@24.13.2)(happy-dom@20.10.3)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.5)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -13600,7 +13616,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 - happy-dom: 20.10.3 + happy-dom: 20.10.5 jsdom: 27.4.0 transitivePeerDependencies: - msw From 5555950f0315d8f8177e066fd39fefafbd49fa83 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:10:53 +0200 Subject: [PATCH 053/111] refactor(testing): extract e2e fixture reset/truncate into shared package Pull the HTTP-agnostic table reset and truncate-all logic out of the v1 testing handlers into pkg/routes/api/shared so /api/v2 can reuse it. v1's wire behavior is unchanged; it now delegates to the shared functions. --- pkg/routes/api/shared/testing.go | 92 ++++++++++++++++++++++++++++++++ pkg/routes/api/v1/testing.go | 66 ++--------------------- 2 files changed, 95 insertions(+), 63 deletions(-) create mode 100644 pkg/routes/api/shared/testing.go diff --git a/pkg/routes/api/shared/testing.go b/pkg/routes/api/shared/testing.go new file mode 100644 index 000000000..ba9118e5a --- /dev/null +++ b/pkg/routes/api/shared/testing.go @@ -0,0 +1,92 @@ +// 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 . + +package shared + +import ( + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" +) + +// dependentTestingTables lists tables that reference a reset table by ID and +// must be truncated alongside it. Without foreign key cascades, stale rows +// would persist and pollute subsequent tests that reuse the same +// auto-increment IDs. +var dependentTestingTables = map[string][]string{ + "users": {"notifications"}, +} + +// ReplaceTableContents resets a single table to the provided rows for the e2e +// testing endpoint and returns the table's resulting contents. When truncate is +// true the table (and any dependent tables) is emptied first; otherwise the rows +// are restored on top of existing data. Callers must already have verified the +// testing token. +func ReplaceTableContents(table string, content []map[string]interface{}, truncate bool) ([]map[string]interface{}, error) { + // Wait for all async event handlers from the previous test to complete + // before modifying the database. Without this, handlers hold SQLite + // connections and starve this request's truncate/insert operations. + events.WaitForPendingHandlers() + + var err error + if truncate { + for _, dep := range dependentTestingTables[table] { + if err = db.RestoreAndTruncate(dep, nil); err != nil { + return nil, err + } + } + err = db.RestoreAndTruncate(table, content) + } else { + err = db.Restore(table, content) + } + if err != nil { + return nil, err + } + + // License state is cached at startup; re-apply so tests take effect without a restart. + if table == "license_status" { + if err := license.ReloadFromCache(); err != nil { + return nil, err + } + } + + s := db.NewSession() + defer s.Close() + data := []map[string]interface{}{} + if err := s.Table(table).Find(&data); err != nil { + return nil, err + } + return data, nil +} + +// TruncateAllTestingTables empties every Vikunja table for the e2e testing +// endpoint. Callers must already have verified the testing token. +func TruncateAllTestingTables() error { + events.WaitForPendingHandlers() + + if err := db.TruncateAllTables(); err != nil { + return err + } + + // Reload after truncate; otherwise features enabled by a prior test outlive + // the now-empty license_status table. A reload failure here is non-fatal — + // the truncate already succeeded — so it is logged and swallowed. + if err := license.ReloadFromCache(); err != nil { + log.Errorf("Error reloading license after truncate: %v", err) + } + return nil +} diff --git a/pkg/routes/api/v1/testing.go b/pkg/routes/api/v1/testing.go index 98f5aeca1..62d5f5206 100644 --- a/pkg/routes/api/v1/testing.go +++ b/pkg/routes/api/v1/testing.go @@ -22,10 +22,8 @@ import ( "net/http" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" - "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/license" "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/routes/api/shared" "github.com/labstack/echo/v5" ) @@ -63,36 +61,8 @@ func HandleTesting(c *echo.Context) error { }) } - // Wait for all async event handlers from the previous test to complete - // before modifying the database. Without this, handlers hold SQLite - // connections and starve this request's truncate/insert operations. - events.WaitForPendingHandlers() - truncate := c.QueryParam("truncate") - if truncate == "true" || truncate == "" { - // When truncating certain tables, also truncate dependent tables - // whose rows reference the truncated table by user/entity ID. - // Without foreign key cascades, stale rows would persist and - // pollute subsequent tests that reuse the same auto-increment IDs. - dependentTables := map[string][]string{ - "users": {"notifications"}, - } - if deps, ok := dependentTables[table]; ok { - for _, dep := range deps { - if err = db.RestoreAndTruncate(dep, nil); err != nil { - log.Errorf("Error truncating dependent table %s: %v", dep, err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - } - } - err = db.RestoreAndTruncate(table, content) - } else { - err = db.Restore(table, content) - } - + data, err := shared.ReplaceTableContents(table, content, truncate == "true" || truncate == "") if err != nil { log.Errorf("Error replacing table data: %v", err) return c.JSON(http.StatusInternalServerError, map[string]interface{}{ @@ -101,29 +71,6 @@ func HandleTesting(c *echo.Context) error { }) } - // License state is cached at startup; re-apply so tests take effect without a restart. - if table == "license_status" { - if err := license.ReloadFromCache(); err != nil { - log.Errorf("Error reloading license from seeded cache: %v", err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - } - - s := db.NewSession() - defer s.Close() - data := []map[string]interface{}{} - err = s.Table(table).Find(&data) - if err != nil { - log.Errorf("Error fetching table data: %v", err) - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ - "error": true, - "message": err.Error(), - }) - } - return c.JSON(http.StatusCreated, data) } @@ -142,9 +89,7 @@ func HandleTestingTruncateAll(c *echo.Context) error { return echo.ErrForbidden } - events.WaitForPendingHandlers() - - if err := db.TruncateAllTables(); err != nil { + if err := shared.TruncateAllTestingTables(); err != nil { log.Errorf("Error truncating all tables: %v", err) return c.JSON(http.StatusInternalServerError, map[string]interface{}{ "error": true, @@ -152,11 +97,6 @@ func HandleTestingTruncateAll(c *echo.Context) error { }) } - // Reload after truncate; otherwise features enabled by a prior test outlive the now-empty license_status table. - if err := license.ReloadFromCache(); err != nil { - log.Errorf("Error reloading license after truncate: %v", err) - } - return c.JSON(http.StatusOK, map[string]string{ "message": "ok", }) From 4737114b12354576a12d92dabff6b3fe39fc8e18 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:11:00 +0200 Subject: [PATCH 054/111] feat(api/v2): add e2e testing-support endpoints on /api/v2 Port the testing fixture endpoints to /api/v2: PUT /test/{table} resets a table to a posted fixture set and DELETE /test/all truncates everything. Both authenticate with the configured testing token via a custom Authorization header (not JWT/API-token) and only mount when that token is set. Reuses the shared reset/truncate logic extracted from v1. --- pkg/routes/api/v2/testing.go | 129 +++++++++++++++++ pkg/routes/routes.go | 5 + pkg/webtests/huma_testing_test.go | 223 ++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 pkg/routes/api/v2/testing.go create mode 100644 pkg/webtests/huma_testing_test.go diff --git a/pkg/routes/api/v2/testing.go b/pkg/routes/api/v2/testing.go new file mode 100644 index 000000000..2f753f3fe --- /dev/null +++ b/pkg/routes/api/v2/testing.go @@ -0,0 +1,129 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/routes/api/shared" + + "github.com/danielgtaylor/huma/v2" +) + +// testingReplaceInput is the request for resetting a single table. The +// Authorization header carries the configured testing token (not a JWT or API +// token); the endpoint is public and checks it in-handler like v1. +type testingReplaceInput struct { + Table string `path:"table" doc:"The table to reset."` + // String (not bool) so absent is distinguishable from an explicit "false": + // like v1, an absent truncate parameter means truncate. Huma does not + // support *bool params, and a bool with default:"true" silently ignores an + // explicit ?truncate=false, so the parameter is read as a raw string and + // interpreted in the handler exactly like v1 does. + Truncate string `query:"truncate" enum:"true,false" doc:"Empty the table (and its dependents) before inserting the rows. Defaults to true; pass false to restore on top of existing data."` + Authorization string `header:"Authorization" doc:"The configured testing token."` + Body []map[string]any `doc:"The rows to write into the table. Free-form objects matching the table's columns."` +} + +type testingReplaceBody struct { + Body []map[string]any `doc:"The table's contents after the reset."` +} + +type testingTruncateAllInput struct { + Authorization string `header:"Authorization" doc:"The configured testing token."` +} + +type testingTruncateAllBody struct { + Body struct { + Message string `json:"message" doc:"Always \"ok\" on success."` + } +} + +// RegisterTestingRoutes wires the e2e testing-support endpoints onto the Huma +// API. They are only mounted when the testing token is configured, matching v1. +func RegisterTestingRoutes(api huma.API) { + if config.ServiceTestingtoken.GetString() == "" { + return + } + + tags := []string{"testing"} + // Public: opt out of the globally-applied JWT/API-token auth — these + // authenticate with the testing token via the Authorization header + // instead. Their paths are also listed in unauthenticatedAPIPaths so the + // token middleware lets them through. + noAuth := []map[string][]string{} + + Register(api, huma.Operation{ + OperationID: "testing-truncate-all", + Summary: "Truncate all tables", + Description: "Removes all data from every Vikunja table. Used by e2e tests to ensure a clean state before each test. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.", + Method: http.MethodDelete, + Path: "/test/all", + Tags: tags, + Security: noAuth, + // v1 returns 200 with a body rather than the 204 a DELETE would default to. + DefaultStatus: http.StatusOK, + }, testingTruncateAll) + + Register(api, huma.Operation{ + OperationID: "testing-replace-table", + Summary: "Reset a table to a defined state", + Description: "Replaces the contents of the named table with the rows in the payload and returns the resulting contents. Used by e2e tests to seed fixtures. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.", + Method: http.MethodPut, + Path: "/test/{table}", + Tags: tags, + Security: noAuth, + // Mirror v1's 201 for a successful reset. + DefaultStatus: http.StatusCreated, + }, testingReplaceTable) +} + +func init() { AddRouteRegistrar(RegisterTestingRoutes) } + +func testingReplaceTable(_ context.Context, in *testingReplaceInput) (*testingReplaceBody, error) { + if in.Authorization != config.ServiceTestingtoken.GetString() { + return nil, huma.Error403Forbidden("forbidden") + } + + // Mirror v1: absent or "true" truncates; only an explicit "false" appends. + truncate := in.Truncate == "true" || in.Truncate == "" + data, err := shared.ReplaceTableContents(in.Table, in.Body, truncate) + if err != nil { + log.Errorf("Error replacing table data: %v", err) + return nil, huma.Error500InternalServerError("could not replace table data") + } + + return &testingReplaceBody{Body: data}, nil +} + +func testingTruncateAll(_ context.Context, in *testingTruncateAllInput) (*testingTruncateAllBody, error) { + if in.Authorization != config.ServiceTestingtoken.GetString() { + return nil, huma.Error403Forbidden("forbidden") + } + + if err := shared.TruncateAllTestingTables(); err != nil { + log.Errorf("Error truncating all tables: %v", err) + return nil, huma.Error500InternalServerError("could not truncate tables") + } + + out := &testingTruncateAllBody{} + out.Body.Message = "ok" + return out, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 9f6af5af3..e232cbbfa 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -360,6 +360,11 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/user/confirm": true, "/api/v2/shares/:share/auth": true, "/api/v2/oauth/token": true, + + // Testing endpoints authenticate with the testing token via a custom + // Authorization header, not a JWT; mounted only when that token is set. + "/api/v2/test/all": true, + "/api/v2/test/:table": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. diff --git a/pkg/webtests/huma_testing_test.go b/pkg/webtests/huma_testing_test.go new file mode 100644 index 000000000..4f786c8f1 --- /dev/null +++ b/pkg/webtests/huma_testing_test.go @@ -0,0 +1,223 @@ +// 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 . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "src.techknowlogick.com/xormigrate" +) + +const testingToken = "test-testing-token" + +// setupTestingEnv mirrors setupTestEnv but sets the testing token before +// registering routes, so the config-gated /api/v2/test/* endpoints mount. +// When token is empty the endpoints stay unmounted (the disabled case). +func setupTestingEnv(t *testing.T, token string) *echo.Echo { + t.Helper() + config.InitDefaultConfig() + config.ServicePublicURL.Set("https://localhost") + config.ServiceTestingtoken.Set(token) + t.Cleanup(func() { config.ServiceTestingtoken.Set("") }) + + log.InitLogger() + files.InitTests() + user.InitTests() + models.SetupTests() + events.Fake() + keyvalue.InitStorage() + + // models.SetupTests only syncs models + notifications tables, but + // TruncateAllTables walks *every* registered table — including ones created + // by migration in production (license_status, migration_status) plus + // xormigrate's "migration" tracking table. Create them here so truncate-all + // doesn't hit "no such table" (the same gap that kept v1 from testing it). + engine, err := db.CreateTestEngine() + require.NoError(t, err) + extraTables := append(append([]any{new(xormigrate.Migration)}, license.GetTables()...), migration.GetTables()...) + require.NoError(t, engine.Sync2(extraTables...)) + + require.NoError(t, db.LoadFixtures()) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// testingRequest dispatches a request to a /api/v2/test/* endpoint, sending the +// raw token in the Authorization header (not a Bearer JWT). +func testingRequest(e *echo.Echo, method, path, body, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func countRows(t *testing.T, table string) int { + t.Helper() + s := db.NewSession() + defer s.Close() + rows := []map[string]interface{}{} + require.NoError(t, s.Table(table).Find(&rows)) + return len(rows) +} + +func TestTesting(t *testing.T) { + t.Run("replace table contents", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]` + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", body, testingToken) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var data []map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &data)) + require.Len(t, data, 1) + assert.EqualValues(t, "only label", data[0]["title"]) + assert.Equal(t, 1, countRows(t, "labels"), "table should hold exactly the seeded rows") + }) + + t.Run("replace without truncate keeps existing rows", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + before := countRows(t, "labels") + require.Positive(t, before, "fixtures should seed some labels") + + body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]` + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels?truncate=false", body, testingToken) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + assert.Equal(t, before+1, countRows(t, "labels"), "row should be added on top of existing data") + }) + + t.Run("truncate all tables", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + t.Cleanup(func() { _ = db.LoadFixtures() }) + + require.Positive(t, countRows(t, "labels")) + + rec := testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", testingToken) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "ok", resp.Message) + assert.Equal(t, 0, countRows(t, "labels"), "every table should be empty after truncate") + }) + + t.Run("wrong token is forbidden", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "wrong-token") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "wrong-token") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("missing token is forbidden", func(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestTesting_DisabledConfig(t *testing.T) { + e := setupTestingEnv(t, "") + + rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured") + + rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "") + assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured") +} + +func TestTesting_BodySchemaIsArrayOfObjects(t *testing.T) { + e := setupTestingEnv(t, testingToken) + + req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var spec map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + paths, _ := spec["paths"].(map[string]any) + op, _ := paths["/test/{table}"].(map[string]any) + put, ok := op["put"].(map[string]any) + require.True(t, ok, "PUT /test/{table} must be in the spec") + + reqBody, _ := put["requestBody"].(map[string]any) + content, _ := reqBody["content"].(map[string]any) + appJSON, _ := content["application/json"].(map[string]any) + schema, _ := appJSON["schema"].(map[string]any) + // FieldsOptionalByDefault makes the array nullable, so `type` may be the + // string "array" or the list ["array","null"]. Either is honest; assert it + // describes an array (not, say, a base64 string as json.RawMessage would). + assert.Contains(t, schemaTypes(schema["type"]), "array", "request body must be modeled as an array") +} + +// schemaTypes normalises an OpenAPI `type` value (a string or a list of +// strings when nullable) into a slice for assertion. +func schemaTypes(v any) []string { + switch t := v.(type) { + case string: + return []string{t} + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +} From 13f1a13367a1645e3bf616ec70754635c50df769 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 13:55:50 +0200 Subject: [PATCH 055/111] fix(db): interpolate table identifiers in truncate instead of binding them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL/MariaDB/Postgres cannot bind a table name as a ? placeholder, so the non-SQLite branch failed with a syntax error. Interpolate the already-validated identifier with x.Quote (per-dialect quoting) instead. validateTableName restricts to registered table names, so this is injection-safe — the same trust model the SQLite branch already relies on. Latent bug surfaced by the new cross-engine testing webtest, which is the first to exercise this path on MySQL/MariaDB. --- pkg/db/dump.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/db/dump.go b/pkg/db/dump.go index 1d9f803b9..be345e173 100644 --- a/pkg/db/dump.go +++ b/pkg/db/dump.go @@ -127,7 +127,7 @@ func RestoreAndTruncate(table string, contents []map[string]interface{}) (err er return err } } else { - if _, err := x.Query("TRUNCATE TABLE ?", table); err != nil { + if _, err := x.Query("TRUNCATE TABLE " + x.Quote(table)); err != nil { return err } } @@ -148,7 +148,7 @@ func TruncateAllTables() error { return err } } else { - if _, err := x.Query("TRUNCATE TABLE ?", name); err != nil { + if _, err := x.Query("TRUNCATE TABLE " + x.Quote(name)); err != nil { return err } } From c4819631e2bff76b12a3bdbec04d837f51877025 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 13:55:50 +0200 Subject: [PATCH 056/111] test(api/v2): use cross-engine datetime literals in testing webtest MariaDB strict mode rejects the RFC3339 T/Z form for DATETIME columns. The space-separated form is accepted by MariaDB, Postgres and SQLite alike; the test only asserts on title and row counts, never the datetime. --- pkg/webtests/huma_testing_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/webtests/huma_testing_test.go b/pkg/webtests/huma_testing_test.go index 4f786c8f1..480ef285f 100644 --- a/pkg/webtests/huma_testing_test.go +++ b/pkg/webtests/huma_testing_test.go @@ -104,7 +104,7 @@ func TestTesting(t *testing.T) { e := setupTestingEnv(t, testingToken) t.Cleanup(func() { _ = db.LoadFixtures() }) - body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]` + body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]` rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", body, testingToken) require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) @@ -122,7 +122,7 @@ func TestTesting(t *testing.T) { before := countRows(t, "labels") require.Positive(t, before, "fixtures should seed some labels") - body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]` + body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01 00:00:00","updated":"2020-01-01 00:00:00"}]` rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels?truncate=false", body, testingToken) require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) From 8a255cbff656b10ab34cc9db235bf9f77ac88fc5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 19:32:39 +0200 Subject: [PATCH 057/111] fix(gantt): preserve horizontal scroll when focusing a task bar Focusing the task bar SVG `` inside the `overflow-x:auto` `.gantt-container` triggered Firefox's focus-induced scroll-into-view, which jumped the scroll container back toward `scrollLeft=0` (today). Pass `{ preventScroll: true }` to `focus()` so selecting a bar keeps the current scroll position. Chromium scrolls minimally on focus so it never manifested there. Fixes #2728 --- frontend/src/components/gantt/GanttChart.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/gantt/GanttChart.vue b/frontend/src/components/gantt/GanttChart.vue index 989bd400a..f75753428 100644 --- a/frontend/src/components/gantt/GanttChart.vue +++ b/frontend/src/components/gantt/GanttChart.vue @@ -730,7 +730,7 @@ function focusTaskBar(rowId: string) { setTimeout(() => { const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement if (taskBarElement) { - taskBarElement.focus() + taskBarElement.focus({preventScroll: true}) } }, 0) } From cf456fb2232c7f7fbc0c48a412dd2888b6fb5031 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 19:37:32 +0200 Subject: [PATCH 058/111] fix(kanban): count tasks in bucket, not filter total, for saved-filter bucket limits On a saved-filter (or view-filter) kanban view, checkBucketLimit counted the total number of tasks matching the filter instead of the number of tasks actually in the target bucket. Adding the first task to an empty limited bucket was therefore wrongly rejected with code 10004 "exceeded the limit", even though the bucket was at 0/limit. The same setup on a regular project bucket worked because that branch counts task_buckets rows scoped to the bucket. Scope the count to the bucket by adding `bucket_id = ` to the TaskCollection filter. ReadAll combines this with the saved-filter / view filter, so the count reflects exactly the tasks that are in this bucket and match the filter. This keeps the #355 behaviour (stale task_buckets rows whose tasks no longer match the filter are excluded) while fixing the unscoped over-count. Fixes #2672 --- pkg/models/kanban_task_bucket_test.go | 62 +++++++++++++++++++++++++++ pkg/models/tasks.go | 6 +++ 2 files changed, 68 insertions(+) diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 6d1eb2f24..7bde2ade4 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -226,6 +226,68 @@ func TestTaskBucket_Update(t *testing.T) { }) }) + t.Run("saved filter: first task into empty limited bucket is allowed", func(t *testing.T) { + // Regression test for #2672: on a saved-filter kanban view the bucket + // limit was checked against the total number of tasks matching the + // filter instead of the number of tasks actually in the target bucket, + // so adding the first task to an empty limited bucket was wrongly + // rejected with ErrBucketLimitExceeded. + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // A saved filter matching many tasks; the filter total is well above + // the bucket limit we set below. + sf := &SavedFilter{ + Title: "limit-filter", + Filters: &TaskCollection{Filter: "done = false"}, + } + err := sf.Create(s, u) + require.NoError(t, err) + + filterProjectID := getProjectIDFromSavedFilterID(sf.ID) + + view := &ProjectView{} + exists, err := s.Where("project_id = ? AND view_kind = ?", filterProjectID, ProjectViewKindKanban).Get(view) + require.NoError(t, err) + require.True(t, exists) + + // All matching tasks are placed in the default bucket on creation; + // pick three of them to move into a fresh, empty bucket. + var defaultTasks []*TaskBucket + err = s.Where("project_view_id = ?", view.ID).Find(&defaultTasks) + require.NoError(t, err) + require.GreaterOrEqual(t, len(defaultTasks), 3, "filter must match enough tasks to exceed the bucket limit") + + limitedBucket := &Bucket{ + Title: "limited", + ProjectViewID: view.ID, + ProjectID: filterProjectID, + Limit: 2, + } + err = limitedBucket.Create(s, u) + require.NoError(t, err) + + moveTaskToBucket := func(taskID int64) error { + tb := &TaskBucket{ + TaskID: taskID, + BucketID: limitedBucket.ID, + ProjectViewID: view.ID, + ProjectID: filterProjectID, + } + return tb.Update(s, u) + } + + // Moving the FIRST task into the empty bucket must succeed (0/2 -> 1/2). + require.NoError(t, moveTaskToBucket(defaultTasks[0].TaskID)) + // The second one fills the bucket up to the limit (1/2 -> 2/2). + require.NoError(t, moveTaskToBucket(defaultTasks[1].TaskID)) + // The third one would exceed the limit and must be rejected. + err = moveTaskToBucket(defaultTasks[2].TaskID) + require.Error(t, err) + assert.True(t, IsErrBucketLimitExceeded(err)) + }) + t.Run("keep done timestamp when moving task between projects", func(t *testing.T) { db.LoadAndAssertFixtures(t) u := &user.User{ID: 1} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ee5eda824..eb2989694 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -830,9 +830,15 @@ func checkBucketLimit(s *xorm.Session, a web.Auth, t *Task, bucket *Bucket) (tas } if view.ProjectID < 0 || (view.Filter != nil && view.Filter.Filter != "") { + // For saved filters or views with a filter, the count must be scoped to + // this bucket *and* the filter: raw task_buckets rows can include tasks + // that no longer match the filter (#355), while the unscoped filter total + // counts tasks across all buckets, not just this one (#2672). ReadAll + // combines the bucket_id condition with the saved-filter / view filter. tc := &TaskCollection{ ProjectID: view.ProjectID, ProjectViewID: bucket.ProjectViewID, + Filter: "bucket_id = " + strconv.FormatInt(bucket.ID, 10), } _, _, taskCount, err = tc.ReadAll(s, a, "", 1, 1) From ca4e747bedf48d9a93df9efedaa2e285c7db26e4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:31:01 +0200 Subject: [PATCH 059/111] refactor(files): extract WriteFileDownload shared by attachment download Split the generic file-download writer (ServeContent for seekable readers, manual 304 + io.Copy otherwise) out of WriteAttachmentDownload so other blob endpoints can reuse it. The attachment writer keeps its preview branch and cache override and delegates the rest. --- pkg/web/files/file.go | 57 ++++++++++++++++++++++++++++++++ pkg/web/files/task_attachment.go | 33 +++--------------- 2 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 pkg/web/files/file.go diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go new file mode 100644 index 000000000..fa2b4a334 --- /dev/null +++ b/pkg/web/files/file.go @@ -0,0 +1,57 @@ +// 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 . + +package files + +import ( + "io" + "mime" + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/files" +) + +// WriteFileDownload streams a loaded file (its .File reader must be open) to the +// response as an attachment download: http.ServeContent for seekable local files +// (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does +// not close the reader; the caller owns it. +func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) { + mimeToReturn := f.Mime + if mimeToReturn == "" { + mimeToReturn = "application/octet-stream" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": f.Name})) + w.Header().Set("Content-Type", mimeToReturn) + w.Header().Set("Content-Length", strconv.FormatUint(f.Size, 10)) + w.Header().Set("Last-Modified", f.Created.UTC().Format(http.TimeFormat)) + + // Local files are *os.File (seekable), so ServeContent gives Range + + // If-Modified-Since for free; s3 (and the in-memory test storage) return a + // non-seekable reader, so check If-Modified-Since manually and io.Copy. + if seeker, ok := f.File.(io.ReadSeeker); ok { + http.ServeContent(w, r, f.Name, f.Created, seeker) + return + } + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !f.Created.UTC().After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + _, _ = io.Copy(w, f.File) +} diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go index 3db78e62f..09306f59b 100644 --- a/pkg/web/files/task_attachment.go +++ b/pkg/web/files/task_attachment.go @@ -21,8 +21,6 @@ package files import ( - "io" - "mime" "net/http" "strconv" @@ -62,9 +60,9 @@ func toAttachmentUploadError(err error) AttachmentUploadError { return AttachmentUploadError{Message: err.Error()} } -// WriteAttachmentDownload streams the attachment (or its preview) to the response: -// http.ServeContent for seekable local files (Range + If-Modified-Since for free), -// a manual 304 + io.Copy otherwise. It closes the file reader. +// WriteAttachmentDownload streams the attachment (or its inline image preview) to +// the response and closes the file reader. The non-preview path delegates to +// WriteFileDownload, adding the cache override that lets browsers cache attachments. func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { defer func() { _ = ta.File.File.Close() }() @@ -75,30 +73,7 @@ func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models. return } - mimeToReturn := ta.File.Mime - if mimeToReturn == "" { - mimeToReturn = "application/octet-stream" - } - w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ta.File.Name})) - w.Header().Set("Content-Type", mimeToReturn) - w.Header().Set("Content-Length", strconv.FormatUint(ta.File.Size, 10)) - w.Header().Set("Last-Modified", ta.File.Created.UTC().Format(http.TimeFormat)) // Override the global no-store directive so browsers can cache attachments. w.Header().Set("Cache-Control", "no-cache") - - // Local files are *os.File (seekable), so ServeContent gives Range + - // If-Modified-Since for free; s3 (and the in-memory test storage) return a - // non-seekable reader, so check If-Modified-Since manually and io.Copy. - if seeker, ok := ta.File.File.(io.ReadSeeker); ok { - http.ServeContent(w, r, ta.File.Name, ta.File.Created, seeker) - return - } - - if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { - if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !ta.File.Created.UTC().After(t) { - w.WriteHeader(http.StatusNotModified) - return - } - } - _, _ = io.Copy(w, ta.File.File) + WriteFileDownload(w, r, ta.File) } From ac5e94252b6adced64a32509ad032f06ee79c662 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:31:06 +0200 Subject: [PATCH 060/111] feat(api/v2): add totp qr code endpoint Port GET /user/settings/totp/qrcode to v2 as an image/jpeg blob, modeled in the OpenAPI spec. Extract the qr-to-jpeg encoding into user.GetTOTPQrCodeAsJpegForUser so v1 and v2 share it; refactor v1 onto it. The handler reuses the existing local-account guard, rejecting non-local users with 412. --- pkg/routes/api/v1/user_totp.go | 13 ++------ pkg/routes/api/v2/user_totp.go | 49 +++++++++++++++++++++++++++-- pkg/user/totp.go | 17 ++++++++++ pkg/webtests/huma_user_totp_test.go | 13 ++++++-- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/pkg/routes/api/v1/user_totp.go b/pkg/routes/api/v1/user_totp.go index e3c0ae076..a3c9fc8c4 100644 --- a/pkg/routes/api/v1/user_totp.go +++ b/pkg/routes/api/v1/user_totp.go @@ -17,10 +17,8 @@ package v1 import ( - "bytes" "errors" "fmt" - "image/jpeg" "net/http" "code.vikunja.io/api/pkg/db" @@ -202,14 +200,7 @@ func UserTOTPQrCode(c *echo.Context) error { } defer s.Close() - qrcode, err := user.GetTOTPQrCodeForUser(s, u) - if err != nil { - _ = s.Rollback() - return err - } - - buff := &bytes.Buffer{} - err = jpeg.Encode(buff, qrcode, nil) + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) if err != nil { _ = s.Rollback() return err @@ -220,7 +211,7 @@ func UserTOTPQrCode(c *echo.Context) error { return err } - return c.Blob(http.StatusOK, "image/jpeg", buff.Bytes()) + return c.Blob(http.StatusOK, "image/jpeg", qrcode) } // UserTOTP returns the current totp implementation if any is enabled. diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go index d998a524e..dd3b0c575 100644 --- a/pkg/routes/api/v2/user_totp.go +++ b/pkg/routes/api/v2/user_totp.go @@ -49,10 +49,16 @@ type totpMessageBody struct { Body models.Message } +// totpQrCodeResponse carries the qr code jpeg bytes plus a fixed Content-Type. +// Huma writes the []byte Body straight to the wire; the header field overrides +// content negotiation so image/jpeg reaches the client (matching v1). +type totpQrCodeResponse struct { + ContentType string `header:"Content-Type"` + Body []byte +} + // RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma // API. Totp is a local-account feature; every handler rejects OIDC/LDAP users. -// The QR-code blob endpoint is intentionally not ported here (binary streaming, -// handled in a later wave). func RegisterTOTPRoutes(api huma.API) { if !config.ServiceEnableTotp.GetBool() { return @@ -100,6 +106,27 @@ func RegisterTOTPRoutes(api huma.API) { DefaultStatus: http.StatusOK, Tags: tags, }, totpDisable) + + Register(api, huma.Operation{ + OperationID: "totp-qrcode", + Summary: "Get the totp enrollment qr code", + Description: "Returns the qr code for the authenticated user's enrolled totp setting as a jpeg image, for scanning into an authenticator app. Requires a prior enrollment. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp/qrcode", + Tags: tags, + // Spell out the binary response; a bare []byte Body would otherwise be + // modeled as a base64 JSON string instead of binary image data. + Responses: map[string]*huma.Response{ + "200": { + Description: "The qr code as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, totpQrCode) } func init() { AddRouteRegistrar(RegisterTOTPRoutes) } @@ -208,3 +235,21 @@ func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, er } return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil } + +func totpQrCode(ctx context.Context, _ *struct{}) (*totpQrCodeResponse, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + qrcode, err := user.GetTOTPQrCodeAsJpegForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpQrCodeResponse{ContentType: "image/jpeg", Body: qrcode}, nil +} diff --git a/pkg/user/totp.go b/pkg/user/totp.go index e18948443..98c4327cb 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -17,8 +17,10 @@ package user import ( + "bytes" "fmt" "image" + "image/jpeg" "strconv" "time" @@ -198,6 +200,21 @@ func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err return key.Image(300, 300) } +// GetTOTPQrCodeAsJpegForUser renders the user's totp qr code to jpeg bytes, the +// wire format both API versions serve. +func GetTOTPQrCodeAsJpegForUser(s *xorm.Session, user *User) ([]byte, error) { + qrcode, err := GetTOTPQrCodeForUser(s, user) + if err != nil { + return nil, err + } + + buff := &bytes.Buffer{} + if err := jpeg.Encode(buff, qrcode, nil); err != nil { + return nil, err + } + return buff.Bytes(), nil +} + // HandleFailedTOTPAuth records a failed TOTP attempt and locks the account // after 10 consecutive failures. // diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go index d5cc82f15..df244a23c 100644 --- a/pkg/webtests/huma_user_totp_test.go +++ b/pkg/webtests/huma_user_totp_test.go @@ -34,8 +34,7 @@ import ( var testuser14 = user.User{ID: 14, Username: "user14", Issuer: "https://some.service.com"} // TestHumaTOTP mirrors v1's TestUserTOTPLocalUser and adds the enable/disable -// flows plus the local-account-only guard. The QR-code endpoint is not ported -// to v2 (binary streaming, later wave), so there is no test for it here. +// flows, the qr-code blob endpoint, and the local-account-only guard. // // Fixture topology (pkg/db/fixtures/totp.yml + users.yml): // - user1: totp enrolled, not enabled (secret HXDMVJEC…). @@ -59,6 +58,15 @@ func TestHumaTOTP(t *testing.T) { require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) }) + t.Run("Get qr code for enrolled user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp/qrcode", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) + assert.NotEmpty(t, rec.Body.Bytes(), "the qr code jpeg must have bytes") + }) + t.Run("Enroll a fresh user", func(t *testing.T) { e, err := setupTestEnv() require.NoError(t, err) @@ -123,6 +131,7 @@ func TestHumaTOTP(t *testing.T) { method, path, body string }{ {http.MethodGet, "/api/v2/user/settings/totp", ""}, + {http.MethodGet, "/api/v2/user/settings/totp/qrcode", ""}, {http.MethodPost, "/api/v2/user/settings/totp/enroll", ""}, {http.MethodPost, "/api/v2/user/settings/totp/enable", `{"passcode":"000000"}`}, {http.MethodPost, "/api/v2/user/settings/totp/disable", `{"password":"12345678"}`}, From 8c72e83a4d62cf71b51e44bd4bdcd2f196007c55 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:31:16 +0200 Subject: [PATCH 061/111] feat(api/v2): add user data export endpoints Port POST /user/export/request, POST /user/export/download (zip stream) and GET /user/export (status) to v2. Extract the export-file loader and status builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2 download streams via the shared WriteFileDownload writer; local users confirm with their password, external-provider users are passed through. --- pkg/models/error.go | 29 +++++ pkg/models/export.go | 53 ++++++++ pkg/routes/api/v1/user_export.go | 49 ++----- pkg/routes/api/v2/user_export.go | 179 ++++++++++++++++++++++++++ pkg/webtests/huma_user_export_test.go | 125 ++++++++++++++++++ 5 files changed, 394 insertions(+), 41 deletions(-) create mode 100644 pkg/routes/api/v2/user_export.go create mode 100644 pkg/webtests/huma_user_export_test.go diff --git a/pkg/models/error.go b/pkg/models/error.go index 0b793aecc..8f1a47553 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -2624,3 +2624,32 @@ 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.", + } +} diff --git a/pkg/models/export.go b/pkg/models/export.go index 4772fa2d5..65d1f2fae 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -404,6 +404,59 @@ 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 { + 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] " diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 6efc311c0..405f98a29 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -19,14 +19,11 @@ package v1 import ( "io" "net/http" - "os" "strconv" - "time" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" "github.com/labstack/echo/v5" @@ -127,25 +124,10 @@ func DownloadUserDataExport(c *echo.Context) error { return err } - // Check if user has an export file - exportNotFoundError := echo.NewHTTPError(http.StatusNotFound, "No user data export found.") - if u.ExportFileID == 0 { - return exportNotFoundError - } - - // Download - exportFile := &files.File{ID: u.ExportFileID} - err = exportFile.LoadFileMetaByID() + exportFile, err := models.GetUserDataExportFile(u) if err != nil { - if files.IsErrFileDoesNotExist(err) { - return exportNotFoundError - } - return err - } - err = exportFile.LoadFileByID() - if err != nil { - if os.IsNotExist(err) { - return exportNotFoundError + if models.IsErrUserDataExportDoesNotExist(err) { + return echo.NewHTTPError(http.StatusNotFound, "No user data export found.") } return err } @@ -163,19 +145,12 @@ func DownloadUserDataExport(c *echo.Context) error { return nil } -type UserExportStatus struct { - ID int64 `json:"id"` - Size uint64 `json:"size"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` -} - // GetUserExportStatus returns metadata about the current user export if it exists // @Summary Get current user data export // @tags user // @Produce json // @Security JWTKeyAuth -// @Success 200 {object} v1.UserExportStatus +// @Success 200 {object} models.UserExportStatus // @Router /user/export [get] func GetUserExportStatus(c *echo.Context) error { s := db.NewSession() @@ -186,20 +161,12 @@ func GetUserExportStatus(c *echo.Context) error { return err } - if u.ExportFileID == 0 { - return c.JSON(http.StatusOK, struct{}{}) - } - - exportFile := &files.File{ID: u.ExportFileID} - if err := exportFile.LoadFileMetaByID(); err != nil { + status, err := models.GetUserDataExportStatus(u) + if err != nil { return err } - - status := UserExportStatus{ - ID: exportFile.ID, - Size: exportFile.Size, - Created: exportFile.Created, - Expires: exportFile.Created.Add(7 * 24 * time.Hour), + if status == nil { + return c.JSON(http.StatusOK, struct{}{}) } return c.JSON(http.StatusOK, status) diff --git a/pkg/routes/api/v2/user_export.go b/pkg/routes/api/v2/user_export.go new file mode 100644 index 000000000..820e883d0 --- /dev/null +++ b/pkg/routes/api/v2/user_export.go @@ -0,0 +1,179 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/user" + webfiles "code.vikunja.io/api/pkg/web/files" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userExportPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userExportStatusBody struct { + Body *models.UserExportStatus +} + +func RegisterUserExportRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-export-request", + Summary: "Request a data export", + Description: "Starts building a full export of the authenticated user's data. Local users must confirm with their password. The export runs in the background; an email is sent when it is ready to download.", + Method: http.MethodPost, + Path: "/user/export/request", + Tags: tags, + DefaultStatus: http.StatusOK, + }, userExportRequest) + + Register(api, huma.Operation{ + OperationID: "user-export-download", + Summary: "Download the data export", + Description: "Streams the authenticated user's prepared data export as a zip file. Local users must confirm with their password. Fails with 404 if no export has been prepared. A POST (not GET) because the password is sent in the body.", + Method: http.MethodPost, + Path: "/user/export/download", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The data export as a zip file.", + Content: map[string]*huma.MediaType{ + "application/zip": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, userExportDownload) + + Register(api, huma.Operation{ + OperationID: "user-export-status", + Summary: "Get the current data export", + Description: "Returns metadata about the authenticated user's current data export (id, size, creation and expiry time), or null if none has been prepared.", + Method: http.MethodGet, + Path: "/user/export", + Tags: tags, + }, userExportStatus) +} + +func init() { AddRouteRegistrar(RegisterUserExportRoutes) } + +// confirmExportPassword resolves the full DB user and, for local accounts, verifies +// the supplied password — mirroring v1's checkExportRequest. External-provider users +// cannot supply a password and are passed through, as in v1. +func confirmExportPassword(ctx context.Context, s *xorm.Session, password string) (*user.User, error) { + u, err := authUserFromCtx(ctx, s) + if err != nil { + return nil, err + } + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, password); err != nil { + return nil, translateDomainError(err) + } + } + return u, nil +} + +func userExportRequest(ctx context.Context, in *userExportPasswordBody) (*messageBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchOnCommit(s, &models.UserDataExportRequestedEvent{User: u}) + + if err := s.Commit(); err != nil { + _ = s.Rollback() + events.CleanupPending(s) + return nil, translateDomainError(err) + } + events.DispatchPending(ctx, s) + + out := &messageBody{} + out.Body.Message = "Successfully requested data export. We will send you an email when it's ready." + return out, nil +} + +func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma.StreamResponse, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + exportFile, err := models.GetUserDataExportFile(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = exportFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteFileDownload((*c).Response(), (*c).Request(), exportFile) + }}, nil +} + +func userExportStatus(ctx context.Context, _ *struct{}) (*userExportStatusBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + status, err := models.GetUserDataExportStatus(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userExportStatusBody{Body: status}, nil +} diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go new file mode 100644 index 000000000..4140f8aa6 --- /dev/null +++ b/pkg/webtests/huma_user_export_test.go @@ -0,0 +1,125 @@ +// 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 . + +package webtests + +import ( + "bytes" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/files" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserExport covers the v2 data-export endpoints. Fixture topology +// (pkg/db/fixtures/users.yml + files.yml): +// - user1: local, password 12345678, export_file_id 1 (file row exists, no bytes). +// - user14: non-local (OIDC), no password to confirm. +// - user15: local, no export. +func TestHumaUserExport(t *testing.T) { + t.Run("Request with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "requested data export") + }) + + t.Run("Request with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, + "a wrong password must not start an export; body: %s", rec.Body.String()) + }) + + t.Run("Request as a non-local user skips the password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{}`, humaTokenFor(t, &testuser14), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download streams the export bytes", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's export points at file 1; setupTestEnv resets storage, so write + // real bytes for it (size matches the fixture's declared 100 bytes). + content := bytes.Repeat([]byte("v"), 100) + require.NoError(t, (&files.File{ID: 1, Size: uint64(len(content))}).Save(bytes.NewReader(content))) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match") + assert.Contains(t, rec.Header().Get("Content-Disposition"), "test") + }) + + t.Run("Download with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download without an export returns 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download with a missing physical file returns 404", func(t *testing.T) { + // user1 has export_file_id 1, but setupTestEnv leaves its bytes unwritten. + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Status returns the export metadata", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"expires"`) + }) + + t.Run("Status without an export returns null", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.JSONEq(t, "null", rec.Body.String()) + }) + + t.Run("Unauthenticated request is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} From ee8dbf82ba24ac22256f14167594ca32b62a2db5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:08:53 +0200 Subject: [PATCH 062/111] fix(api/v2): close export reader when commit fails before streaming If s.Commit() fails after loading the export file, the StreamResponse callback that would close the reader never runs, leaking the open object-storage/file handle. Close it explicitly on that error path. --- pkg/routes/api/v2/user_export.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/routes/api/v2/user_export.go b/pkg/routes/api/v2/user_export.go index 820e883d0..952f8127b 100644 --- a/pkg/routes/api/v2/user_export.go +++ b/pkg/routes/api/v2/user_export.go @@ -146,6 +146,8 @@ func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma. // valid after the commit; the StreamResponse callback runs after this returns. if err := s.Commit(); err != nil { _ = s.Rollback() + // The stream callback (which closes the reader) won't run on this error path. + _ = exportFile.File.Close() return nil, translateDomainError(err) } From 4b92f2332962216ec80484987a5f36b46a8eb712 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 14:24:41 +0200 Subject: [PATCH 063/111] fix(files): never cache file downloads in v1 or v2 Move the Cache-Control: no-cache header into the shared WriteFileDownload so every export and attachment download carries it, and add it to the standalone v1 export download writer too. Downloads must never be cached. --- pkg/routes/api/v1/user_export.go | 4 ++++ pkg/web/files/file.go | 4 ++++ pkg/web/files/task_attachment.go | 6 +++--- pkg/webtests/huma_user_export_test.go | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 405f98a29..1c2fd4117 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -132,6 +132,10 @@ func DownloadUserDataExport(c *echo.Context) error { return err } + // Downloads must never be cached; no-cache overrides the global no-store + // directive while still allowing revalidation. + c.Response().Header().Set("Cache-Control", "no-cache") + if config.FilesType.GetString() == "s3" { c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"") c.Response().Header().Set("Content-Type", exportFile.Mime) diff --git a/pkg/web/files/file.go b/pkg/web/files/file.go index fa2b4a334..461fe2780 100644 --- a/pkg/web/files/file.go +++ b/pkg/web/files/file.go @@ -30,6 +30,10 @@ import ( // (Range + If-Modified-Since for free), a manual 304 + io.Copy otherwise. It does // not close the reader; the caller owns it. func WriteFileDownload(w http.ResponseWriter, r *http.Request, f *files.File) { + // Downloads must never be cached. no-cache overrides the global no-store + // directive so revalidation (If-Modified-Since) still works. + w.Header().Set("Cache-Control", "no-cache") + mimeToReturn := f.Mime if mimeToReturn == "" { mimeToReturn = "application/octet-stream" diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go index 09306f59b..55945fe23 100644 --- a/pkg/web/files/task_attachment.go +++ b/pkg/web/files/task_attachment.go @@ -62,18 +62,18 @@ func toAttachmentUploadError(err error) AttachmentUploadError { // WriteAttachmentDownload streams the attachment (or its inline image preview) to // the response and closes the file reader. The non-preview path delegates to -// WriteFileDownload, adding the cache override that lets browsers cache attachments. +// WriteFileDownload, which sets Cache-Control: no-cache; the preview branch returns +// early, so it sets the same header itself. func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { defer func() { _ = ta.File.File.Close() }() if preview != nil { + w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Length", strconv.Itoa(len(preview))) _, _ = w.Write(preview) return } - // Override the global no-store directive so browsers can cache attachments. - w.Header().Set("Cache-Control", "no-cache") WriteFileDownload(w, r, ta.File) } diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go index 4140f8aa6..ee9104d5b 100644 --- a/pkg/webtests/huma_user_export_test.go +++ b/pkg/webtests/huma_user_export_test.go @@ -72,6 +72,7 @@ func TestHumaUserExport(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match") assert.Contains(t, rec.Header().Get("Content-Disposition"), "test") + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), "downloads must never be cached") }) t.Run("Download with a wrong password is refused", func(t *testing.T) { From 02e7a134cccd3a7e12b2ebf2965de8bd406f4722 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 14:25:03 +0200 Subject: [PATCH 064/111] fix(api): close the user data export reader after download DownloadUserDataExport obtained an open file reader from GetUserDataExportFile but never closed it on either the s3 io.Copy or the http.ServeContent branch, leaking a file descriptor on every download. Defer the close right after the file is obtained so both branches and the error paths cover it. --- pkg/routes/api/v1/user_export.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 1c2fd4117..b01b1fdf3 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -131,6 +131,7 @@ func DownloadUserDataExport(c *echo.Context) error { } return err } + defer func() { _ = exportFile.File.Close() }() // Downloads must never be cached; no-cache overrides the global no-store // directive while still allowing revalidation. From 55ca06ca3d34e23c1033b1019b473e1ab88bc857 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 14:25:40 +0200 Subject: [PATCH 065/111] fix(export): treat a missing export meta row as no export in the status GetUserDataExportStatus propagated the raw LoadFileMetaByID error when the meta row was gone, so /user/export could 500. The download path already maps that case to ErrUserDataExportDoesNotExist (404); make status consistent by returning nil (no export), matching the documented contract. --- pkg/models/export.go | 5 ++++ pkg/models/export_test.go | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 pkg/models/export_test.go diff --git a/pkg/models/export.go b/pkg/models/export.go index 65d1f2fae..2d9b57651 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -438,6 +438,11 @@ func GetUserDataExportStatus(u *user.User) (*UserExportStatus, error) { 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 } diff --git a/pkg/models/export_test.go b/pkg/models/export_test.go new file mode 100644 index 000000000..f2d0fa2fb --- /dev/null +++ b/pkg/models/export_test.go @@ -0,0 +1,53 @@ +// 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 . + +package models + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetUserDataExportStatus(t *testing.T) { + t.Run("no export", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + status, err := GetUserDataExportStatus(&user.User{ID: 15}) + require.NoError(t, err) + assert.Nil(t, status) + }) + + t.Run("with export", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + status, err := GetUserDataExportStatus(&user.User{ID: 1, ExportFileID: 1}) + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, int64(1), status.ID) + }) + + t.Run("export points at a missing file", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + // A dangling ExportFileID must read as "no export" rather than erroring, + // matching the download path which 404s the same case. + status, err := GetUserDataExportStatus(&user.User{ID: 15, ExportFileID: 9999}) + require.NoError(t, err) + assert.Nil(t, status) + }) +} From 434b5d9fe3366c1322de5242841dba4cd0ef0e17 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:43:06 +0000 Subject: [PATCH 066/111] chore(deps): update dev-dependencies to v10.5.0 --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d2ce35838..fdafc836a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,7 +105,7 @@ "zhyswan-vuedraggable": "4.1.3" }, "devDependencies": { - "@faker-js/faker": "10.4.0", + "@faker-js/faker": "10.5.0", "@histoire/plugin-screenshot": "1.0.0-beta.1", "@histoire/plugin-vue": "1.0.0-beta.1", "@playwright/test": "1.58.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5d8174584..8fa9e43e6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -182,8 +182,8 @@ importers: version: 4.1.3(vue@3.5.27(typescript@5.9.3)) devDependencies: '@faker-js/faker': - specifier: 10.4.0 - version: 10.4.0 + specifier: 10.5.0 + version: 10.5.0 '@histoire/plugin-screenshot': specifier: 1.0.0-beta.1 version: 1.0.0-beta.1(histoire@1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3))(typescript@5.9.3) @@ -1542,8 +1542,8 @@ packages: '@exodus/crypto': optional: true - '@faker-js/faker@10.4.0': - resolution: {integrity: sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==} + '@faker-js/faker@10.5.0': + resolution: {integrity: sha512-bsxD8WLS5lIj7aaoCx1YJkktqYj5vlBUE6HWzu2Q51ksrGJ0H737ECCKlFU7Yf8Br45z9t99frBp/J7kzbMPAg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} '@floating-ui/core@1.7.3': @@ -8176,7 +8176,7 @@ snapshots: '@exodus/bytes@1.8.0': {} - '@faker-js/faker@10.4.0': {} + '@faker-js/faker@10.5.0': {} '@floating-ui/core@1.7.3': dependencies: From 59a5a2c1e761fa4b44186d1fbacf59af5a03ff17 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Wed, 17 Jun 2026 19:43:01 +0000 Subject: [PATCH 067/111] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 36 ++++++++++++++++++------------------ pkg/swagger/swagger.json | 36 ++++++++++++++++++------------------ pkg/swagger/swagger.yaml | 24 ++++++++++++------------ 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 8f6f0e673..e976b4d74 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -7342,7 +7342,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.UserExportStatus" + "$ref": "#/definitions/models.UserExportStatus" } } } @@ -10579,6 +10579,23 @@ const docTemplate = `{ } } }, + "models.UserExportStatus": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "expires": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "size": { + "type": "integer" + } + } + }, "models.UserGeneralSettings": { "type": "object", "properties": { @@ -11141,23 +11158,6 @@ const docTemplate = `{ } } }, - "v1.UserExportStatus": { - "type": "object", - "properties": { - "created": { - "type": "string" - }, - "expires": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "size": { - "type": "integer" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 4cab9fb5e..98f528565 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -7334,7 +7334,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/v1.UserExportStatus" + "$ref": "#/definitions/models.UserExportStatus" } } } @@ -10571,6 +10571,23 @@ } } }, + "models.UserExportStatus": { + "type": "object", + "properties": { + "created": { + "type": "string" + }, + "expires": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "size": { + "type": "integer" + } + } + }, "models.UserGeneralSettings": { "type": "object", "properties": { @@ -11133,23 +11150,6 @@ } } }, - "v1.UserExportStatus": { - "type": "object", - "properties": { - "created": { - "type": "string" - }, - "expires": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "size": { - "type": "integer" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index af2b209dd..a926e18b8 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1292,6 +1292,17 @@ definitions: this value. type: string type: object + models.UserExportStatus: + properties: + created: + type: string + expires: + type: string + id: + type: integer + size: + type: integer + type: object models.UserGeneralSettings: properties: default_project_id: @@ -1708,17 +1719,6 @@ definitions: token: type: string type: object - v1.UserExportStatus: - properties: - created: - type: string - expires: - type: string - id: - type: integer - size: - type: integer - type: object v1.UserPassword: properties: new_password: @@ -6860,7 +6860,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/v1.UserExportStatus' + $ref: '#/definitions/models.UserExportStatus' security: - JWTKeyAuth: [] summary: Get current user data export From 78f79accb5cfacb70d596adf9ee115144977db08 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:28:21 +0200 Subject: [PATCH 068/111] refactor(auth): extract transport-agnostic login, logout and OIDC cores Pull the credential/TOTP check, session deletion, user-token issuance and OIDC callback flow out of the v1 echo handlers and into reusable helpers so both /api/v1 and the upcoming /api/v2 share one implementation: - auth.IssueUserToken + auth.WriteUserAuthCookies split the token/cookie machinery from the echo response; NewUserAuthTokenResponse now wraps them. - auth.SessionIDFromContext reads the sid claim for logout. - shared.AuthenticateUserCredentials, shared.DeleteSession hold the login and logout cores. - openid.AuthenticateCallback holds the OIDC exchange/getOrCreate/TOTP/team sync, returning the user; HandleCallback issues the token as before. v1 behaviour is unchanged on the wire. --- pkg/modules/auth/auth.go | 77 ++++++++++++++---- pkg/modules/auth/openid/openid.go | 81 +++++++++++-------- pkg/routes/api/shared/auth.go | 125 ++++++++++++++++++++++++++++++ pkg/routes/api/v1/login.go | 97 +---------------------- 4 files changed, 241 insertions(+), 139 deletions(-) diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 97429aa13..f94537158 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -100,46 +100,75 @@ func ClearRefreshTokenCookie(c *echo.Context) { SetRefreshTokenCookie(c, "", -1) } -// NewUserAuthTokenResponse creates a new user auth token response from a user object. -func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { +// IssuedUserToken bundles a freshly minted access token with the matching +// refresh token and the cookie max-age both v1 and v2 use to set the +// HttpOnly refresh cookie. +type IssuedUserToken struct { + AccessToken string + RefreshToken string + CookieMaxAge int +} + +// IssueUserToken creates a session for the user and mints a JWT access token plus +// a refresh token for it. It is the transport-agnostic core both v1 (which writes +// the echo response) and v2 (Huma) call; callers set the refresh cookie and the +// Cache-Control header themselves via WriteUserAuthCookies. +func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool) (*IssuedUserToken, error) { s := db.NewSession() defer s.Close() - deviceInfo := c.Request().UserAgent() - ipAddress := c.RealIP() - session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long) if err != nil { _ = s.Rollback() - return err + return nil, err } t, err := NewUserJWTAuthtoken(u, session.ID) if err != nil { _ = s.Rollback() - return err + return nil, err } if err := s.Commit(); err != nil { _ = s.Rollback() - return err + return nil, err } - if err := events.DispatchWithContext(c.Request().Context(), &user.LoginSucceededEvent{User: u}); err != nil { + if err := events.DispatchWithContext(ctx, &user.LoginSucceededEvent{User: u}); err != nil { log.Errorf("Could not dispatch login succeeded event: %s", err) } - // Set the refresh token as an HttpOnly cookie. The cookie is path-scoped - // to the refresh endpoint, so the browser only sends it there. JavaScript - // never sees the refresh token — this protects it from XSS. cookieMaxAge := int(config.ServiceJWTTTL.GetInt64()) if long { cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) } - SetRefreshTokenCookie(c, session.RefreshToken, cookieMaxAge) + return &IssuedUserToken{ + AccessToken: t, + RefreshToken: session.RefreshToken, + CookieMaxAge: cookieMaxAge, + }, nil +} + +// WriteUserAuthCookies sets the HttpOnly refresh-token cookie and the +// Cache-Control: no-store header on a response. The cookie is path-scoped to the +// refresh endpoint, so the browser only sends it there; JavaScript never sees the +// refresh token, which protects it from XSS. Shared by the v1 echo handlers and +// the v2 Huma handlers (which reach the echo context via humaecho5.Unwrap). +func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) { + SetRefreshTokenCookie(c, token.RefreshToken, token.CookieMaxAge) c.Response().Header().Set("Cache-Control", "no-store") - return c.JSON(http.StatusOK, Token{Token: t}) +} + +// NewUserAuthTokenResponse creates a new user auth token response from a user object. +func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error { + token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long) + if err != nil { + return err + } + + WriteUserAuthCookies(c, token) + return c.JSON(http.StatusOK, Token{Token: token.AccessToken}) } // NewUserJWTAuthtoken generates and signs a new short-lived jwt token for a user. @@ -392,6 +421,26 @@ func RefreshSession(rawRefreshToken string) (*RefreshResult, error) { }, nil } +// SessionIDFromContext reads the session id (the `sid` claim) off the user JWT +// in the echo context. It returns "" when there is no user JWT or no sid claim +// (API tokens and link shares carry no session), which callers treat as a no-op. +func SessionIDFromContext(c *echo.Context) string { + raw := c.Get("user") + if raw == nil { + return "" + } + jwtinf, ok := raw.(*jwt.Token) + if !ok { + return "" + } + claims, ok := jwtinf.Claims.(jwt.MapClaims) + if !ok { + return "" + } + sid, _ := claims["sid"].(string) + return sid +} + // GetAuthFromContext retrieves the authenticated web.Auth from a plain // context.Context, bridging Huma handlers to Vikunja's echo JWT flow. The // humaecho5 adapter stashes the *echo.Context under EchoContextKey first. diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index b1fa3961a..47ade7dc9 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -168,8 +168,12 @@ func enforceTOTPIfRequired(s *xorm.Session, u *user.User, totpPasscode string) e // @Failure 500 {object} models.Message "Internal error" // @Router /auth/openid/{provider}/callback [post] func HandleCallback(c *echo.Context) error { + cb := &Callback{} + if err := c.Bind(cb); err != nil { + return &models.ErrOpenIDBadRequest{Message: "Bad data"} + } - provider, cb, oauthToken, idToken, err := getProviderAndOidcTokens(c) + u, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider")) if err != nil { var detailedErr *models.ErrOpenIDBadRequestWithDetails if errors.As(err, &detailedErr) { @@ -181,9 +185,29 @@ func HandleCallback(c *echo.Context) error { return err } - cl, err := getClaims(provider, oauthToken, idToken) + // Create token + return auth.NewUserAuthTokenResponse(u, c, false) +} + +// AuthenticateCallback resolves an OpenID Connect callback to an authenticated +// user: it exchanges the auth code, verifies the ID token, creates or updates the +// matching local user, enforces the account-status and TOTP gates, and syncs the +// user's external teams. It is the transport-agnostic core shared by the v1 echo +// handler and the v2 Huma handler; the caller issues the auth token. The +// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render +// its bespoke body and v2 can map it to RFC 9457. +func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, error) { + // ctx is threaded through only to dispatch the login event; the OIDC token + // exchange, claim verification and user/avatar sync run on their own + // background contexts, exactly as the v1 callback always did. + provider, oauthToken, idToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck if err != nil { - return err + return nil, err + } + + cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck + if err != nil { + return nil, err } s := db.NewSession() @@ -193,20 +217,20 @@ func HandleCallback(c *echo.Context) error { defer events.CleanupPending(s) // Check if we have seen this user before - u, err := getOrCreateUser(s, cl, provider, idToken) + u, err := getOrCreateUser(s, cl, provider, idToken) //nolint:contextcheck if err != nil { _ = s.Rollback() log.Errorf("Error creating new user for provider %s: %v", provider.Name, err) - return err + return nil, err } if u.Status == user.StatusDisabled { _ = s.Rollback() - return &user.ErrAccountDisabled{UserID: u.ID} + return nil, &user.ErrAccountDisabled{UserID: u.ID} } if u.Status == user.StatusAccountLocked { _ = s.Rollback() - return &user.ErrAccountLocked{UserID: u.ID} + return nil, &user.ErrAccountLocked{UserID: u.ID} } // Must run before team sync so a failed 2FA attempt cannot mutate team @@ -218,32 +242,31 @@ func HandleCallback(c *echo.Context) error { log.Errorf("Error committing session after failed OIDC TOTP attempt for user %d: %v", u.ID, commitErr) } else { // The user creation above was committed, so its events are real. - events.DispatchPending(c.Request().Context(), s) + events.DispatchPending(ctx, s) } if user.IsErrInvalidTOTPPasscode(err) { user.HandleFailedTOTPAuth(u) } - return err + return nil, err } teamData := getTeamDataFromToken(cl.VikunjaGroups, provider) err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name) if err != nil { - return err + return nil, err } err = s.Commit() if err != nil { _ = s.Rollback() log.Errorf("Error creating new team for provider %s: %v", provider.Name, err) - return err + return nil, err } - events.DispatchPending(c.Request().Context(), s) + events.DispatchPending(ctx, s) - // Create token - return auth.NewUserAuthTokenResponse(u, c, false) + return u, nil } func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) { @@ -516,21 +539,17 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo return cl, nil } -func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.Token, *oidc.IDToken, error) { - - cb := &Callback{} - if err := c.Bind(cb); err != nil { - return nil, nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Bad data"} - } - - // Check if the provider exists - providerKey := c.Param("provider") +// exchangeOidcTokens resolves the provider, exchanges the callback's auth code, +// and verifies the returned ID token. It takes an already-bound Callback so it +// can be shared by the v1 echo handler (which binds from the request) and the v2 +// Huma handler (which binds via its typed body). +func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, error) { provider, err := GetProvider(providerKey) if err != nil { - return nil, cb, nil, nil, err + return nil, nil, nil, err } if provider == nil { - return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"} + return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"} } log.Debugf("Trying to authenticate user using provider: %s", provider.Key) @@ -546,25 +565,25 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To if err := json.Unmarshal(rerr.Body, &details); err != nil { log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err) log.Debugf("Raw token value is %s", rerr.Body) - return nil, cb, nil, nil, err + return nil, nil, nil, err } log.Errorf("Error retrieving token: %s", err) log.Debugf("Raw token value is %s", rerr.Body) - return nil, cb, nil, nil, &models.ErrOpenIDBadRequestWithDetails{ + return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{ Message: "Could not authenticate against third party.", Details: details, } } - return nil, cb, nil, nil, err + return nil, nil, nil, err } // Extract the ID Token from OAuth2 token. rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { log.Debugf("Could not get id_token, raw token is %v", oauth2Token) - return nil, cb, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"} + return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"} } verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID}) @@ -573,8 +592,8 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *Callback, *oauth2.To idToken, err := verifier.Verify(context.Background(), rawIDToken) if err != nil { log.Errorf("Error verifying token for provider %s: %v", provider.Name, err) - return nil, cb, nil, nil, err + return nil, nil, nil, err } - return provider, cb, oauth2Token, idToken, nil + return provider, oauth2Token, idToken, nil } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index 925a533d8..acfa47ce0 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -26,7 +26,11 @@ import ( "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/ldap" + "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" ) // UserRegister carries the fields accepted by the public registration endpoint: @@ -78,6 +82,127 @@ func RegisterUser(ctx context.Context, in *UserRegister) (*user.User, error) { return newUser, nil } +// AuthenticateUserCredentials verifies a login against local (and, if configured, +// LDAP) credentials and enforces the account-status and TOTP gates, returning the +// authenticated user on success. It is the transport-agnostic core of the login +// flow shared by v1 and v2; the caller issues the token and sets the cookie. The +// returned errors carry their own HTTP semantics (wrong credentials, disabled +// account, missing/invalid TOTP) so both APIs surface them identically. +func AuthenticateUserCredentials(ctx context.Context, login *user.Login) (*user.User, error) { + s := db.NewSession() + defer s.Close() + // Discards events queued during a rolled-back transaction (e.g. LDAP user + // creation); a no-op once DispatchPending has run. + defer events.CleanupPending(s) + + u, err := resolveLoginUser(ctx, s, login) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.Status == user.StatusDisabled || u.Status == user.StatusAccountLocked { + _ = s.Rollback() + return nil, &user.ErrAccountDisabled{UserID: u.ID} + } + + if err := enforceLoginTOTP(s, u, login.TOTPPasscode); err != nil { + return nil, err + } + + if err := keyvalue.Del(u.GetFailedTOTPAttemptsKey()); err != nil { + return nil, err + } + if err := keyvalue.Del(u.GetFailedPasswordAttemptsKey()); err != nil { + return nil, err + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchPending(ctx, s) + + return u, nil +} + +// resolveLoginUser authenticates the credentials against LDAP (when enabled) and +// then against local accounts, mirroring v1's order so local users keep working +// alongside LDAP. Bots are rejected before bcrypt runs because they have no +// password hash. +func resolveLoginUser(ctx context.Context, s *xorm.Session, login *user.Login) (*user.User, error) { + if config.AuthLdapEnabled.GetBool() { + u, err := ldap.AuthenticateUserInLDAP(s, login.Username, login.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString()) + if err != nil && !user.IsErrWrongUsernameOrPassword(err) { + return nil, err + } + if u != nil { + return u, nil + } + } + + existingUser, lookupErr := user.GetUserByUsername(s, login.Username) + if lookupErr == nil && existingUser.IsBot() { + return nil, &user.ErrAccountIsBot{UserID: existingUser.ID} + } + + return user.CheckUserCredentials(ctx, s, login) +} + +// enforceLoginTOTP runs the TOTP gate for users who have it enabled, mirroring +// v1: a missing passcode is rejected, and a wrong one trips the failed-attempt +// lockout via HandleFailedTOTPAuth. The session is rolled back before +// HandleFailedTOTPAuth so its dedicated session can acquire a write lock on +// SQLite shared-cache (the lockout write is decoupled from this transaction — +// see GHSA-fgfv-pv97-6cmj). +func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error { + totpEnabled, err := user.TOTPEnabledForUser(s, u) + if err != nil { + _ = s.Rollback() + return err + } + if !totpEnabled { + return nil + } + + if passcode == "" { + _ = s.Rollback() + return user.ErrInvalidTOTPPasscode{} + } + + _, err = user.ValidateTOTPPasscode(s, &user.TOTPPasscode{User: u, Passcode: passcode}) + if err != nil { + _ = s.Rollback() + if user.IsErrInvalidTOTPPasscode(err) { + user.HandleFailedTOTPAuth(u) + } + return err + } + + return nil +} + +// DeleteSession removes the session with the given id, logging the user out +// server-side. An empty sid is a no-op (the token carried no session, e.g. an +// API token or a link share), matching v1. Shared by v1 and v2; the caller is +// responsible for clearing the refresh cookie. +func DeleteSession(sid string) error { + if sid == "" { + return nil + } + + s := db.NewSession() + defer s.Close() + + if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil { + _ = s.Rollback() + return err + } + + return s.Commit() +} + // ResetPassword resets a user's password from a previously issued reset token // and invalidates all of that user's sessions, so a leaked password cannot be // used after a reset. Shared by v1 and v2. diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index ae92e1d72..2d740ffdf 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -25,8 +25,7 @@ import ( "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/modules/auth/ldap" - "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/routes/api/shared" user2 "code.vikunja.io/api/pkg/user" "github.com/golang-jwt/jwt/v5" @@ -51,87 +50,11 @@ func Login(c *echo.Context) (err error) { return c.JSON(http.StatusBadRequest, models.Message{Message: "Please provide a username and password."}) } - s := db.NewSession() - defer s.Close() - // Discards events queued during a rolled-back transaction (e.g. LDAP user - // creation); a no-op once DispatchPending has run. - defer events.CleanupPending(s) - - var user *user2.User - if config.AuthLdapEnabled.GetBool() { - user, err = ldap.AuthenticateUserInLDAP(s, u.Username, u.Password, config.AuthLdapGroupSyncEnabled.GetBool(), config.AuthLdapAvatarSyncAttribute.GetString()) - if err != nil && !user2.IsErrWrongUsernameOrPassword(err) { - _ = s.Rollback() - return err - } - } - - if user == nil { - // Check if the user is a bot before attempting password verification, - // because bots have no password hash and bcrypt would fail with a - // misleading error. - existingUser, lookupErr := user2.GetUserByUsername(s, u.Username) - if lookupErr == nil && existingUser.IsBot() { - _ = s.Rollback() - return &user2.ErrAccountIsBot{UserID: existingUser.ID} - } - - // This allows us to still have local users while ldap is enabled - user, err = user2.CheckUserCredentials(c.Request().Context(), s, &u) - if err != nil { - _ = s.Rollback() - return err - } - } - - if user.Status == user2.StatusDisabled || user.Status == user2.StatusAccountLocked { - _ = s.Rollback() - return &user2.ErrAccountDisabled{UserID: user.ID} - } - - totpEnabled, err := user2.TOTPEnabledForUser(s, user) + user, err := shared.AuthenticateUserCredentials(c.Request().Context(), &u) if err != nil { - _ = s.Rollback() return err } - if totpEnabled { - if u.TOTPPasscode == "" { - _ = s.Rollback() - return user2.ErrInvalidTOTPPasscode{} - } - - _, err = user2.ValidateTOTPPasscode(s, &user2.TOTPPasscode{ - User: user, - Passcode: u.TOTPPasscode, - }) - if err != nil { - // Rollback before HandleFailedTOTPAuth so its dedicated session - // can acquire a write lock on SQLite shared-cache. The lockout - // write is decoupled from this handler's transaction — see - // GHSA-fgfv-pv97-6cmj. - _ = s.Rollback() - if user2.IsErrInvalidTOTPPasscode(err) { - user2.HandleFailedTOTPAuth(user) - } - return err - } - } - - if err := keyvalue.Del(user.GetFailedTOTPAttemptsKey()); err != nil { - return err - } - if err := keyvalue.Del(user.GetFailedPasswordAttemptsKey()); err != nil { - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() - return err - } - - events.DispatchPending(c.Request().Context(), s) - // Create token return auth.NewUserAuthTokenResponse(user, c, u.LongToken) } @@ -254,21 +177,7 @@ func Logout(c *echo.Context) (err error) { } } - if sid == "" { - return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."}) - } - - s := db.NewSession() - defer s.Close() - - _, err = s.Where("id = ?", sid).Delete(&models.Session{}) - if err != nil { - _ = s.Rollback() - return err - } - - if err := s.Commit(); err != nil { - _ = s.Rollback() + if err := shared.DeleteSession(sid); err != nil { return err } From d4ab43807323725cf31c9e0f7385bf5614267611 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:28:29 +0200 Subject: [PATCH 069/111] feat(api/v2): add login and logout on /api/v2 Port the cookie-setting login and logout endpoints to Huma. Both reuse the shared auth cores; the HttpOnly refresh cookie and Cache-Control: no-store header are set via the unwrapped echo context (the cookie stays out of the OpenAPI schema, matching v1). The token response inlines the JWT to avoid a schema-name collision with user.Token. login is public (LDAP-only deployments log in here too); logout inherits the global JWT auth and no-ops for tokens that carry no session. --- pkg/routes/api/v2/auth_login.go | 129 ++++++++++++++++++++++++++++++++ pkg/routes/api/v2/oauth.go | 11 ++- pkg/routes/routes.go | 14 ++-- 3 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 pkg/routes/api/v2/auth_login.go diff --git a/pkg/routes/api/v2/auth_login.go b/pkg/routes/api/v2/auth_login.go new file mode 100644 index 000000000..d6ff0ff19 --- /dev/null +++ b/pkg/routes/api/v2/auth_login.go @@ -0,0 +1,129 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// authTokenBody wraps the issued user JWT. The token is inlined rather than +// embedding auth.Token because Huma derives schema names from the bare Go type +// name and a top-level auth.Token body would collide with user.Token (the +// caldav-token schema, also named "Token"). The refresh token is delivered out +// of band as an HttpOnly cookie, so it is intentionally absent from the schema. +type authTokenBody struct { + // Cache-Control: no-store keeps the access token out of any shared cache. + CacheControl string `header:"Cache-Control"` + Body struct { + Token string `json:"token" readOnly:"true" doc:"The short-lived JWT auth token. Send it as a bearer token on subsequent requests."` + } +} + +// logoutBody confirms a successful logout. +type logoutBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."` + } +} + +func init() { AddRouteRegistrar(RegisterLoginRoutes) } + +// RegisterLoginRoutes wires the local/LDAP login and logout endpoints. Login is +// always registered (LDAP-only deployments still log in here); logout inherits +// the global JWT auth. +func RegisterLoginRoutes(api huma.API) { + tags := []string{"auth"} + + Register(api, huma.Operation{ + OperationID: "auth-login", + Summary: "Login", + Description: "Logs a user in with username and password (and a TOTP passcode when 2FA is enabled), returning a short-lived JWT. A long-lived refresh token is set as an HttpOnly cookie scoped to the refresh endpoint.", + Method: http.MethodPost, + Path: "/login", + DefaultStatus: http.StatusOK, + Tags: tags, + Security: publicSecurity, + }, authLogin) + + Register(api, huma.Operation{ + OperationID: "auth-logout", + Summary: "Logout", + Description: "Destroys the current session server-side and clears the refresh-token cookie. A no-op for API tokens and link shares, which carry no session.", + Method: http.MethodPost, + Path: "/logout", + DefaultStatus: http.StatusOK, + Tags: tags, + }, authLogout) +} + +func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBody, error) { + u, err := shared.AuthenticateUserCredentials(ctx, &in.Body) + if err != nil { + return nil, translateDomainError(err) + } + + deviceInfo, ipAddress := requestClientInfo(ctx) + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken) + if err != nil { + return nil, translateDomainError(err) + } + + if ec := echoContextFromCtx(ctx); ec != nil { + auth.WriteUserAuthCookies(ec, token) + } + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = token.AccessToken + return out, nil +} + +func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) { + var sid string + if ec := echoContextFromCtx(ctx); ec != nil { + auth.ClearRefreshTokenCookie(ec) + sid = auth.SessionIDFromContext(ec) + } + + if err := shared.DeleteSession(sid); err != nil { + return nil, translateDomainError(err) + } + + out := &logoutBody{} + out.Body.Message = "Successfully logged out." + return out, nil +} + +// echoContextFromCtx pulls the underlying *echo.Context off a Huma request +// context so a handler can set cookies and headers the OpenAPI schema does not +// model (the refresh-token cookie). Returns nil when the context carries no echo +// context (it always does under the humaecho5 adapter). +func echoContextFromCtx(ctx context.Context) *echo.Context { + ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok || ec == nil { + return nil + } + return ec +} diff --git a/pkg/routes/api/v2/oauth.go b/pkg/routes/api/v2/oauth.go index 45d1efe57..a67441ad3 100644 --- a/pkg/routes/api/v2/oauth.go +++ b/pkg/routes/api/v2/oauth.go @@ -21,11 +21,9 @@ import ( "net/http" "code.vikunja.io/api/pkg/modules/auth/oauth2server" - "code.vikunja.io/api/pkg/modules/humaecho5" "code.vikunja.io/api/pkg/user" "github.com/danielgtaylor/huma/v2" - "github.com/labstack/echo/v5" ) // oauthTokenBody wraps the OAuth 2.0 token response. @@ -101,11 +99,12 @@ func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.Authorize } // requestClientInfo pulls the user agent and client IP off the underlying Echo -// request so the authorization_code grant can record them on the session it -// creates, mirroring v1. Both fall back to "" when the context is unavailable. +// request so the authorization_code grant (and login) can record them on the +// session they create, mirroring v1. Both fall back to "" when the context is +// unavailable. func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) { - ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) - if !ok || ec == nil { + ec := echoContextFromCtx(ctx) + if ec == nil { return "", "" } return (*ec).Request().UserAgent(), (*ec).RealIP() diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index e232cbbfa..d20b17f29 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -354,12 +354,14 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/schemas/:schema": true, "/api/v2/info": true, - "/api/v2/register": true, - "/api/v2/user/password/token": true, - "/api/v2/user/password/reset": true, - "/api/v2/user/confirm": true, - "/api/v2/shares/:share/auth": true, - "/api/v2/oauth/token": true, + "/api/v2/register": true, + "/api/v2/user/password/token": true, + "/api/v2/user/password/reset": true, + "/api/v2/user/confirm": true, + "/api/v2/shares/:share/auth": true, + "/api/v2/oauth/token": true, + "/api/v2/login": true, + "/api/v2/auth/openid/:provider/callback": true, // Testing endpoints authenticate with the testing token via a custom // Authorization header, not a JWT; mounted only when that token is set. From 422d504a0727036f2952cdfe4daf4d59f315fcd2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:28:36 +0200 Subject: [PATCH 070/111] feat(api/v2): add OpenID Connect callback on /api/v2 Port the OIDC callback to Huma, reusing openid.AuthenticateCallback. The route is only registered when OpenID is enabled; unknown providers still 404 per request. v1's bespoke {message, details} error body is replaced by standard RFC 9457, folding the provider detail into the structured error. --- pkg/routes/api/v2/auth_openid.go | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pkg/routes/api/v2/auth_openid.go diff --git a/pkg/routes/api/v2/auth_openid.go b/pkg/routes/api/v2/auth_openid.go new file mode 100644 index 000000000..b52d7dca1 --- /dev/null +++ b/pkg/routes/api/v2/auth_openid.go @@ -0,0 +1,93 @@ +// 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 . + +package apiv2 + +import ( + "context" + "errors" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/modules/auth/openid" + + "github.com/danielgtaylor/huma/v2" +) + +func init() { AddRouteRegistrar(RegisterOpenIDRoutes) } + +// RegisterOpenIDRoutes wires the OpenID Connect callback endpoint. It is only +// registered when OpenID is enabled; individual providers are still resolved per +// request, so an unknown provider key 404s even when others are configured. +func RegisterOpenIDRoutes(api huma.API) { + if !config.AuthOpenIDEnabled.GetBool() { + return + } + + Register(api, huma.Operation{ + OperationID: "auth-openid-callback", + Summary: "Authenticate with OpenID Connect", + Description: "Exchanges the authorization code returned by an OpenID Connect provider for a Vikunja JWT, creating or updating the matching user. A long-lived refresh token is set as an HttpOnly cookie. When the resolved user has 2FA enabled, the call returns 412 and must be retried with totp_passcode set.", + Method: http.MethodPost, + Path: "/auth/openid/{provider}/callback", + DefaultStatus: http.StatusOK, + Tags: []string{"auth"}, + Security: publicSecurity, + }, authOpenIDCallback) +} + +func authOpenIDCallback(ctx context.Context, in *struct { + Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."` + Body openid.Callback `doc:"The provider callback, carrying the authorization code."` +}) (*authTokenBody, error) { + u, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback. + if err != nil { + return nil, translateOpenIDError(err) + } + + deviceInfo, ipAddress := requestClientInfo(ctx) + // OIDC logins are not "remember me" sessions; v1 always issues a short one. + token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false) + if err != nil { + return nil, translateDomainError(err) + } + + if ec := echoContextFromCtx(ctx); ec != nil { + auth.WriteUserAuthCookies(ec, token) + } + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = token.AccessToken + return out, nil +} + +// translateOpenIDError maps OIDC callback errors to RFC 9457 responses. +// ErrOpenIDBadRequestWithDetails carries no HTTP semantics of its own (v1 renders +// it with a bespoke {message, details} body), so v2 maps it to a 400 with the +// provider detail attached as a structured error detail rather than porting the +// bespoke shape. Everything else flows through translateDomainError. +func translateOpenIDError(err error) error { + var detailedErr *models.ErrOpenIDBadRequestWithDetails + if errors.As(err, &detailedErr) { + return huma.Error400BadRequest(detailedErr.Message, &huma.ErrorDetail{ + Message: "The identity provider rejected the request.", + Value: detailedErr.Details, + }) + } + return translateDomainError(err) +} From 9aa0687288dfdfe4ccd6b44e517f6f04598c7c45 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:28:36 +0200 Subject: [PATCH 071/111] test(api/v2): cover v2 login, logout and OIDC gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login asserts the token, the HttpOnly refresh cookie, the no-store header and the credential/TOTP gates. Logout asserts the session is deleted and the cookie cleared. OIDC coverage is the registrar gate (404 when disabled, public route when enabled) — the full provider flow needs a live OIDC server, as the existing openid package tests show. --- pkg/webtests/huma_auth_login_test.go | 184 +++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 pkg/webtests/huma_auth_login_test.go diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go new file mode 100644 index 000000000..423c66071 --- /dev/null +++ b/pkg/webtests/huma_auth_login_test.go @@ -0,0 +1,184 @@ +// 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 . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// refreshCookie returns the Set-Cookie value for the refresh-token cookie, or "" +// if the response set no such cookie. +func refreshCookie(rec *httptest.ResponseRecorder) *http.Cookie { + for _, c := range rec.Result().Cookies() { + if c.Name == auth.RefreshTokenCookieName { + return c + } + } + return nil +} + +// TestHumaLogin ports the v1 login coverage to /api/v2: it asserts the token +// response, the HttpOnly refresh cookie, the no-store header, and the credential +// and TOTP gates. +func TestHumaLogin(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + login := func(body string) *httptest.ResponseRecorder { + return humaRequest(t, e, http.MethodPost, "/api/v2/login", body, "", "") + } + + t.Run("normal login", func(t *testing.T) { + rec := login(`{"username":"user1","password":"12345678"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "login must set the refresh-token cookie") + assert.NotEmpty(t, cookie.Value) + assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly") + }) + + t.Run("wrong password", func(t *testing.T) { + rec := login(`{"username":"user1","password":"wrong"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec)) + assert.Nil(t, refreshCookie(rec), "a failed login must not set a refresh cookie") + }) + + t.Run("nonexistent user", func(t *testing.T) { + rec := login(`{"username":"userWhichDoesNotExist","password":"12345678"}`) + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, user.ErrCodeWrongUsernameOrPassword, problemCode(t, rec)) + }) + + t.Run("unconfirmed email", func(t *testing.T) { + rec := login(`{"username":"user5","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeEmailNotConfirmed, problemCode(t, rec)) + }) + + t.Run("TOTP required but missing", func(t *testing.T) { + rec := login(`{"username":"user10","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec)) + }) + + t.Run("TOTP wrong", func(t *testing.T) { + rec := login(`{"username":"user10","password":"12345678","totp_passcode":"000000"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeInvalidTOTPPasscode, problemCode(t, rec)) + }) + + t.Run("TOTP correct", func(t *testing.T) { + code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now()) + require.NoError(t, err) + rec := login(`{"username":"user10","password":"12345678","totp_passcode":"` + code + `"}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.NotNil(t, refreshCookie(rec)) + }) +} + +// TestHumaLogout proves the v2 logout deletes the session server-side and clears +// the refresh-token cookie. +func TestHumaLogout(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + // Create a session so logout has something to delete, then mint a JWT whose + // sid claim points at it. + s := db.NewSession() + session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false) + require.NoError(t, err) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + token, err := auth.NewUserJWTAuthtoken(&testuser1, session.ID) + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/logout", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "Successfully logged out.") + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "logout must clear the refresh cookie") + assert.Empty(t, cookie.Value, "cleared cookie has no value") + assert.Negative(t, cookie.MaxAge, "cleared cookie is expired") + + // The session must be gone. + check := db.NewSession() + defer check.Close() + exists, err := check.Where("id = ?", session.ID).Exist(&models.Session{}) + require.NoError(t, err) + assert.False(t, exists, "logout must delete the session") +} + +// TestHumaLoginUnauthenticated proves login needs no token (it is a public op). +func TestHumaLoginUnauthenticated(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/login", `{"username":"user1","password":"12345678"}`, "", "") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) +} + +// TestHumaOpenIDGating proves the OIDC callback route only exists when OpenID is +// enabled, mirroring the registrar gate. +func TestHumaOpenIDGating(t *testing.T) { + body := `{"code":"abc","redirect_url":"https://example.com"}` + + t.Run("disabled returns 404", func(t *testing.T) { + config.AuthOpenIDEnabled.Set(false) + + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/test/callback", body, "", "") + assert.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("enabled does not require auth", func(t *testing.T) { + config.AuthOpenIDEnabled.Set(true) + defer config.AuthOpenIDEnabled.Set(false) + + e, err := setupTestEnv() + require.NoError(t, err) + + // No provider is configured, so the call fails downstream — but it must + // not 404 as an unknown route nor 401 for missing auth, which proves the + // public route is registered. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/auth/openid/doesnotexist/callback", body, "", "") + assert.NotEqual(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusUnauthorized, rec.Code) + }) +} From a32d8d6492c5b270ddf2ac901071031c573b9669 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:05:14 +0200 Subject: [PATCH 072/111] fix(auth): roll back on commit failure in DeleteSession Restore the rollback-on-commit-failure that v1's Logout handler had before this session-deletion logic was extracted, so a failed commit does not leave the transaction open longer than the deferred Close. --- pkg/routes/api/shared/auth.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index acfa47ce0..d43deb358 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -200,7 +200,12 @@ func DeleteSession(sid string) error { return err } - return s.Commit() + if err := s.Commit(); err != nil { + _ = s.Rollback() + return err + } + + return nil } // ResetPassword resets a user's password from a previously issued reset token From 5b7924b1f6eef04679175265781133695658fd56 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:08:36 +0200 Subject: [PATCH 073/111] fix(auth): return ErrAccountLocked for locked accounts on login The login status check mapped a locked account to ErrAccountDisabled, surfacing the disabled-account error code and message even though a dedicated ErrAccountLocked exists (and the OIDC flow already uses it). Map the locked status to ErrAccountLocked so credential login is consistent with OIDC across both /api/v1 and /api/v2. Disabled accounts still return ErrAccountDisabled. This changes the v1 login error code for locked accounts on the wire (1020 -> 1026); the change is intentional and approved. --- pkg/routes/api/shared/auth.go | 6 +++++- pkg/webtests/huma_auth_login_test.go | 12 ++++++++++++ pkg/webtests/login_test.go | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index d43deb358..153d851e8 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -101,10 +101,14 @@ func AuthenticateUserCredentials(ctx context.Context, login *user.Login) (*user. return nil, err } - if u.Status == user.StatusDisabled || u.Status == user.StatusAccountLocked { + if u.Status == user.StatusDisabled { _ = s.Rollback() return nil, &user.ErrAccountDisabled{UserID: u.ID} } + if u.Status == user.StatusAccountLocked { + _ = s.Rollback() + return nil, &user.ErrAccountLocked{UserID: u.ID} + } if err := enforceLoginTOTP(s, u, login.TOTPPasscode); err != nil { return nil, err diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go index 423c66071..ad83fc811 100644 --- a/pkg/webtests/huma_auth_login_test.go +++ b/pkg/webtests/huma_auth_login_test.go @@ -87,6 +87,18 @@ func TestHumaLogin(t *testing.T) { assert.Equal(t, user.ErrCodeEmailNotConfirmed, problemCode(t, rec)) }) + t.Run("disabled account", func(t *testing.T) { + rec := login(`{"username":"user17","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeAccountDisabled, problemCode(t, rec)) + }) + + t.Run("locked account", func(t *testing.T) { + rec := login(`{"username":"user18","password":"12345678"}`) + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) + assert.Equal(t, user.ErrCodeAccountLocked, problemCode(t, rec)) + }) + t.Run("TOTP required but missing", func(t *testing.T) { rec := login(`{"username":"user10","password":"12345678"}`) assert.Equal(t, http.StatusPreconditionFailed, rec.Code) diff --git a/pkg/webtests/login_test.go b/pkg/webtests/login_test.go index f271e1727..17b0f9b07 100644 --- a/pkg/webtests/login_test.go +++ b/pkg/webtests/login_test.go @@ -68,6 +68,22 @@ func TestLogin(t *testing.T) { require.Error(t, err) assertHandlerErrorCode(t, err, user.ErrCodeEmailNotConfirmed) }) + t.Run("disabled account", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ + "username": "user17", + "password": "12345678" +}`, nil, nil) + require.Error(t, err) + assertHandlerErrorCode(t, err, user.ErrCodeAccountDisabled) + }) + t.Run("locked account", func(t *testing.T) { + _, err := newTestRequest(t, http.MethodPost, apiv1.Login, `{ + "username": "user18", + "password": "12345678" +}`, nil, nil) + require.Error(t, err) + assertHandlerErrorCode(t, err, user.ErrCodeAccountLocked) + }) } func TestLoginTOTPLockout(t *testing.T) { From 2cc7c0b6f01264b7b97b860b9dada79877008f51 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:42:21 +0200 Subject: [PATCH 074/111] fix(frontend): auto-refresh relative dates as time passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relative dates ("5 minutes ago", "in 2 hours") were computed once via dayjs().fromNow() and never recomputed, so a view left open kept showing the value from the moment it was rendered. Compute the relative string against the shared, ticking `now` from useGlobalNow() instead. This makes every reactive caller — , direct formatDateSince() calls, and formatDisplayDate() when the user's date display is set to relative — re-render on the existing 60s tick. Absolute date formats don't read `now`, so they never needlessly re-render. useGlobalNow can now be initialised from a plain helper rather than only from a component, so its route-update hook is guarded with getCurrentInstance(). --- frontend/src/composables/useGlobalNow.ts | 14 +++++++++----- frontend/src/helpers/time/formatDate.ts | 8 +++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/composables/useGlobalNow.ts b/frontend/src/composables/useGlobalNow.ts index 83d9cf9ef..c5e3510e7 100644 --- a/frontend/src/composables/useGlobalNow.ts +++ b/frontend/src/composables/useGlobalNow.ts @@ -1,4 +1,4 @@ -import { ref } from 'vue' +import { getCurrentInstance, ref } from 'vue' import { createGlobalState, useIntervalFn } from '@vueuse/core' import { onBeforeRouteUpdate } from 'vue-router' @@ -18,10 +18,14 @@ export const useGlobalNow = createGlobalState(() => { useIntervalFn(update, GLOBAL_NOW_INTERVAL, { immediate: true }) - // ensure the now value is refreshed when the route changes - onBeforeRouteUpdate(() => { - update() - }) + // 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() + }) + } return { now, diff --git a/frontend/src/helpers/time/formatDate.ts b/frontend/src/helpers/time/formatDate.ts index 4ff9a4da5..ed7f4a3d7 100644 --- a/frontend/src/helpers/time/formatDate.ts +++ b/frontend/src/helpers/time/formatDate.ts @@ -5,6 +5,7 @@ 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' @@ -49,8 +50,13 @@ 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).fromNow() + ? dayjs(date).locale(locale).from(now.value) : '' } From 20d8d2347439aa6f99a76c46a0b9ec88daaa0328 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:33:24 +0200 Subject: [PATCH 075/111] chore(agents): remove CRUSH.md crush actually checks the AGENTS.md as well --- CRUSH.md | 1 - 1 file changed, 1 deletion(-) delete mode 120000 CRUSH.md diff --git a/CRUSH.md b/CRUSH.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/CRUSH.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file From 7c11c2dc29d1f4703c4d663dbf182f1e9e474cc9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:11:03 +0200 Subject: [PATCH 076/111] feat(api/v2): port refresh-token endpoint to /api/v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v2/user/token/refresh reads the HttpOnly refresh cookie, rotates the session, mints a new JWT, and sets the new cookie — reusing the shared auth.RefreshSession core (no v1 change) and the #2912 cookie helpers / authTokenBody response shape. The cookie is set via the unwrapped echo ctx, not the OpenAPI spec. translateDomainError now maps *echo.HTTPError (which RefreshSession returns for missing/invalid/expired/replayed tokens) so those land as the right status instead of a 500. Completes the v1→v2 REST migration. --- pkg/routes/api/v2/auth_refresh.go | 75 +++++++++++++++++++++ pkg/routes/api/v2/errors.go | 12 ++++ pkg/routes/routes.go | 1 + pkg/webtests/huma_auth_refresh_test.go | 92 ++++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 pkg/routes/api/v2/auth_refresh.go create mode 100644 pkg/webtests/huma_auth_refresh_test.go diff --git a/pkg/routes/api/v2/auth_refresh.go b/pkg/routes/api/v2/auth_refresh.go new file mode 100644 index 000000000..d264da7c1 --- /dev/null +++ b/pkg/routes/api/v2/auth_refresh.go @@ -0,0 +1,75 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/modules/auth" + user2 "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +func init() { AddRouteRegistrar(RegisterRefreshTokenRoutes) } + +// RegisterRefreshTokenRoutes wires the refresh-token endpoint. It authenticates +// via the HttpOnly refresh cookie rather than a JWT, so it is a public operation. +func RegisterRefreshTokenRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "auth-refresh-token", + Summary: "Refresh user token", + Description: "Exchanges the refresh-token cookie for a new short-lived JWT. The refresh token is rotated on every call, so the previous one stops working. A new HttpOnly refresh cookie is set on the response.", + Method: http.MethodPost, + Path: "/user/token/refresh", + DefaultStatus: http.StatusOK, + Tags: []string{"auth"}, + Security: publicSecurity, + }, authRefreshToken) +} + +func authRefreshToken(ctx context.Context, _ *struct{}) (*authTokenBody, error) { + ec := echoContextFromCtx(ctx) + if ec == nil { + return nil, huma.Error401Unauthorized("No refresh token provided.") + } + + cookie, err := ec.Cookie(auth.RefreshTokenCookieName) + if err != nil || cookie.Value == "" { + return nil, huma.Error401Unauthorized("No refresh token provided.") + } + + result, err := auth.RefreshSession(cookie.Value) + if err != nil { + if user2.IsErrUserStatusError(err) { + auth.ClearRefreshTokenCookie(ec) + } + return nil, translateDomainError(err) + } + + cookieMaxAge := int(config.ServiceJWTTTL.GetInt64()) + if result.IsLongSession { + cookieMaxAge = int(config.ServiceJWTTTLLong.GetInt64()) + } + auth.SetRefreshTokenCookie(ec, result.NewRefreshToken, cookieMaxAge) + + out := &authTokenBody{CacheControl: "no-store"} + out.Body.Token = result.AccessToken + return out, nil +} diff --git a/pkg/routes/api/v2/errors.go b/pkg/routes/api/v2/errors.go index 3292b2e2b..24b73a19d 100644 --- a/pkg/routes/api/v2/errors.go +++ b/pkg/routes/api/v2/errors.go @@ -28,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/web" "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" ) // authFromCtx retrieves the authed user from a Huma handler context, @@ -80,6 +81,17 @@ func translateDomainError(err error) error { } return se } + // Shared transport-agnostic cores (e.g. auth.RefreshSession) signal HTTP + // semantics with *echo.HTTPError. v1 lets echo's error handler render it; + // without this it would fall through as a 500 on v2. + var he *echo.HTTPError + if errors.As(err, &he) { + msg := he.Message + if msg == "" { + msg = http.StatusText(he.Code) + } + return huma.NewError(he.Code, msg) + } return err } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index d20b17f29..a01e12be7 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -361,6 +361,7 @@ var unauthenticatedAPIPaths = map[string]bool{ "/api/v2/shares/:share/auth": true, "/api/v2/oauth/token": true, "/api/v2/login": true, + "/api/v2/user/token/refresh": true, "/api/v2/auth/openid/:provider/callback": true, // Testing endpoints authenticate with the testing token via a custom diff --git a/pkg/webtests/huma_auth_refresh_test.go b/pkg/webtests/huma_auth_refresh_test.go new file mode 100644 index 000000000..48fad31c6 --- /dev/null +++ b/pkg/webtests/huma_auth_refresh_test.go @@ -0,0 +1,92 @@ +// 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 . + +package webtests + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// refreshRequest posts to the v2 refresh endpoint with the given refresh-token +// cookie value (empty value omits the cookie entirely), driving the full +// echo+Huma stack so cookie reading and Set-Cookie writing are exercised. +func refreshRequest(e *echo.Echo, refreshToken string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v2/user/token/refresh", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + if refreshToken != "" { + req.AddCookie(&http.Cookie{Name: auth.RefreshTokenCookieName, Value: refreshToken}) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaRefreshToken ports the v1 refresh-token coverage to /api/v2: a valid +// cookie yields a new JWT and a rotated HttpOnly cookie, the old token then stops +// working, and missing/invalid cookies map to the same 401 v1 returns. +func TestHumaRefreshToken(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("valid refresh token", func(t *testing.T) { + rec := refreshRequest(e, "testtoken_session1") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"token":"`) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + cookie := refreshCookie(rec) + require.NotNil(t, cookie, "refresh must set a new refresh-token cookie") + assert.NotEmpty(t, cookie.Value) + assert.NotEqual(t, "testtoken_session1", cookie.Value, "refresh token must be rotated") + assert.True(t, cookie.HttpOnly, "refresh cookie must be HttpOnly") + }) + + t.Run("rotation invalidates the old token", func(t *testing.T) { + // session2 is a separate session so this case does not depend on the + // one above. The first refresh succeeds and rotates the token. + first := refreshRequest(e, "testtoken_session2") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + newCookie := refreshCookie(first) + require.NotNil(t, newCookie) + + // Replaying the now-rotated token must fail. + replay := refreshRequest(e, "testtoken_session2") + assert.Equal(t, http.StatusUnauthorized, replay.Code) + + // The freshly rotated token still works. + next := refreshRequest(e, newCookie.Value) + assert.Equal(t, http.StatusOK, next.Code, next.Body.String()) + }) + + t.Run("missing cookie", func(t *testing.T) { + rec := refreshRequest(e, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("invalid cookie", func(t *testing.T) { + rec := refreshRequest(e, "garbage") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} From 1a4f03bbc8e7ea28208866a4c1bebf665cc005fc Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:37:48 +0200 Subject: [PATCH 077/111] feat(api/v2): expose healthcheck as a documented endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/v2/health as a Huma operation so it appears in the v2 OpenAPI spec with a clean JSON schema ({"status": "OK"}). It runs the same health.Check() probe as the v1 healthcheck and is public — it opts out of the global bearer auth and is listed in unauthenticatedAPIPaths. --- pkg/routes/api/v2/health.go | 61 +++++++++++++++++++++++++++++++++++++ pkg/routes/routes.go | 3 ++ 2 files changed, 64 insertions(+) create mode 100644 pkg/routes/api/v2/health.go diff --git a/pkg/routes/api/v2/health.go b/pkg/routes/api/v2/health.go new file mode 100644 index 000000000..674dc7b85 --- /dev/null +++ b/pkg/routes/api/v2/health.go @@ -0,0 +1,61 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/health" + + "github.com/danielgtaylor/huma/v2" +) + +type healthBody struct { + Body struct { + Status string `json:"status" doc:"\"OK\" when the service and its dependencies are reachable." example:"OK"` + } +} + +// RegisterHealthRoutes wires the public healthcheck endpoint onto the Huma API. +func RegisterHealthRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "health", + Summary: "Healthcheck", + Description: "Reports whether the service and its dependencies (database) are reachable. Returns 200 with status \"OK\" when healthy, 500 otherwise. Public — no authentication required.", + Method: http.MethodGet, + Path: "/health", + Tags: []string{"service"}, + // Public: opt out of the globally-applied auth. The path is also listed + // in unauthenticatedAPIPaths so the token middleware lets it through. + Security: []map[string][]string{}, + }, healthcheck) +} + +func init() { AddRouteRegistrar(RegisterHealthRoutes) } + +func healthcheck(_ context.Context, _ *struct{}) (*healthBody, error) { + //nolint:contextcheck // health.Check is the shared v1/v2 probe; it takes no context and uses background contexts for its own pings. + if err := health.Check(); err != nil { + // Mirror v1: a failed check is an internal error; the cause is logged, + // not leaked to the client. + return nil, huma.Error500InternalServerError("Internal server error", err) + } + out := &healthBody{} + out.Body.Status = "OK" + return out, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index a01e12be7..0b98918a0 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -368,6 +368,9 @@ var unauthenticatedAPIPaths = map[string]bool{ // Authorization header, not a JWT; mounted only when that token is set. "/api/v2/test/all": true, "/api/v2/test/:table": true, + + // Public infra healthcheck (a Huma op that opts out of the global auth). + "/api/v2/health": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. From 4614e18e7a5f0e3d3ce515a86a14596e7f6edbca Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:37:54 +0200 Subject: [PATCH 078/111] refactor(feeds): extract atom feed builder + basic-auth validator for reuse Splits the transport-agnostic cores out of the v1 echo handlers so the v2 Huma endpoints can share them: - AuthenticateFeedToken(s, username, password) holds the token validation (prefix/length guard, owner match, feeds scope, bot rejection); BasicAuth now creates the session and delegates to it. - BuildNotificationsAtomFeed(s, u) renders the Atom XML; NotificationsAtomFeed reads the context user and delegates to it. - AtomContentType is shared so both transports set the same header. The v1 handlers keep identical observable behavior. --- pkg/routes/feeds/auth.go | 39 ++++++++++++++++++++++++------------- pkg/routes/feeds/handler.go | 38 +++++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/pkg/routes/feeds/auth.go b/pkg/routes/feeds/auth.go index 419fd2ccd..a0142317b 100644 --- a/pkg/routes/feeds/auth.go +++ b/pkg/routes/feeds/auth.go @@ -23,9 +23,9 @@ import ( "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/user" - "xorm.io/xorm" "github.com/labstack/echo/v5" + "xorm.io/xorm" ) func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) { @@ -50,35 +50,48 @@ func checkAPIToken(s *xorm.Session, username, token string) (*user.User, error) return u, nil } -// BasicAuth authenticates feed requests. Only API tokens are accepted — -// password and LDAP credentials are rejected outright because feed URLs are -// commonly exported, shared, or cached by feed readers. -func BasicAuth(c *echo.Context, username, password string) (bool, error) { +// AuthenticateFeedToken validates feed credentials against an existing session. +// Only API tokens are accepted — password and LDAP credentials are rejected +// outright because feed URLs are commonly exported, shared, or cached by feed +// readers. It returns the authenticated user, or nil for any rejection so +// callers can treat "invalid" and "unknown" identically. +func AuthenticateFeedToken(s *xorm.Session, username, password string) (*user.User, error) { if !strings.HasPrefix(password, models.APITokenPrefix) { - return false, nil + return nil, nil } // GetTokenFromTokenString slices password[len-8:] without a length check, // so a stray "tk_" or other short prefix-only string would panic before // the credentials could be rejected. Real tokens are far longer than // prefix+8, so anything shorter is invalid by construction. if len(password) < len(models.APITokenPrefix)+8 { - return false, nil + return nil, nil } - s := db.NewSession() - defer s.Close() - u, err := checkAPIToken(s, username, password) if err != nil { log.Errorf("Error during API token auth for feeds: %v", err) - return false, nil + return nil, nil } if u == nil { - return false, nil + return nil, nil } if u.IsBot() { log.Warningf("Feed auth rejected for bot user %d", u.ID) - return false, nil + return nil, nil + } + + return u, nil +} + +// BasicAuth authenticates feed requests for echo's BasicAuth middleware. The +// validation logic is shared with the v2 handler via AuthenticateFeedToken. +func BasicAuth(c *echo.Context, username, password string) (bool, error) { + s := db.NewSession() + defer s.Close() + + u, err := AuthenticateFeedToken(s, username, password) + if err != nil || u == nil { + return false, err } c.Set("userBasicAuth", u) diff --git a/pkg/routes/feeds/handler.go b/pkg/routes/feeds/handler.go index 9d0794c76..2a5ce289d 100644 --- a/pkg/routes/feeds/handler.go +++ b/pkg/routes/feeds/handler.go @@ -30,24 +30,22 @@ import ( "github.com/gorilla/feeds" "github.com/labstack/echo/v5" + "xorm.io/xorm" ) const feedItemLimit = 50 -// NotificationsAtomFeed serves the authenticated user's notifications as an -// Atom feed. Notifications are not marked as read by being fetched here. -func NotificationsAtomFeed(c *echo.Context) error { - u, ok := c.Get("userBasicAuth").(*user.User) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) - } - - s := db.NewSession() - defer s.Close() +// AtomContentType is the content type of the notifications Atom feed. Shared so +// the v1 echo handler and the v2 Huma op set the same header. +const AtomContentType = "application/atom+xml; charset=utf-8" +// BuildNotificationsAtomFeed renders the user's latest notifications as Atom XML +// against an existing session. Notifications are not marked as read by being +// fetched here. Shared by the v1 echo handler and the v2 Huma op. +func BuildNotificationsAtomFeed(s *xorm.Session, u *user.User) (string, error) { rows, _, _, err := notifications.GetNotificationsForUser(s, u.ID, feedItemLimit, 0) if err != nil { - return err + return "", err } publicURL := config.ServicePublicURL.GetString() @@ -85,11 +83,25 @@ func NotificationsAtomFeed(c *echo.Context) error { }) } - atom, err := feed.ToAtom() + return feed.ToAtom() +} + +// NotificationsAtomFeed serves the authenticated user's notifications as an +// Atom feed. Notifications are not marked as read by being fetched here. +func NotificationsAtomFeed(c *echo.Context) error { + u, ok := c.Get("userBasicAuth").(*user.User) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) + } + + s := db.NewSession() + defer s.Close() + + atom, err := BuildNotificationsAtomFeed(s, u) if err != nil { return err } - c.Response().Header().Set(echo.HeaderContentType, "application/atom+xml; charset=utf-8") + c.Response().Header().Set(echo.HeaderContentType, AtomContentType) return c.String(http.StatusOK, atom) } From 40f2900e9dd7707fd4a47295cfbc2270ecb0fc7e Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:38:16 +0200 Subject: [PATCH 079/111] feat(api/v2): expose notifications atom feed in the OpenAPI spec Adds GET /api/v2/notifications.atom as a Huma operation producing application/atom+xml, so the feed shows in the v2 OpenAPI spec with an opaque XML body schema. It mirrors /feeds/notifications.atom on the wire. Feed readers can't carry a bearer header, so the op declares an HTTP Basic security scheme (BasicAuth) and authenticates inside the handler: it parses the Authorization: Basic header and validates the API token via the shared feeds.AuthenticateFeedToken, returning a 401 with a Basic challenge on failure, then streams feeds.BuildNotificationsAtomFeed. The path is in unauthenticatedAPIPaths so the JWT middleware lets it through. --- pkg/routes/api/v2/huma.go | 8 ++ pkg/routes/api/v2/notifications_feed.go | 103 ++++++++++++++++++++++++ pkg/routes/routes.go | 4 + 3 files changed, 115 insertions(+) create mode 100644 pkg/routes/api/v2/notifications_feed.go diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index 7a7dc3514..9c674c1a7 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -104,6 +104,14 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { Scheme: "bearer", Description: "Vikunja API token (tk_ prefix) with scoped permissions. Created via /api/v1/tokens.", } + // HTTP Basic, used only by the notifications Atom feed: feed readers can't + // carry a bearer header, so the feed accepts the API token as the Basic + // password (username = token owner). See notifications_feed.go. + oapi.Components.SecuritySchemes["BasicAuth"] = &huma.SecurityScheme{ + Type: "http", + Scheme: "basic", + Description: "HTTP Basic auth used by the notifications Atom feed: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix).", + } // Applied globally; public endpoints (spec, docs) opt out with an empty Security list. oapi.Security = []map[string][]string{ {"JWTKeyAuth": {}}, diff --git a/pkg/routes/api/v2/notifications_feed.go b/pkg/routes/api/v2/notifications_feed.go new file mode 100644 index 000000000..d6195def2 --- /dev/null +++ b/pkg/routes/api/v2/notifications_feed.go @@ -0,0 +1,103 @@ +// 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 . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/routes/feeds" + + "github.com/danielgtaylor/huma/v2" + "github.com/labstack/echo/v5" +) + +// RegisterNotificationsFeedRoutes wires the Atom notifications feed onto the +// Huma API. It documents HTTP Basic auth (a feeds-scoped API token) because +// feed readers can't carry a bearer header. +func RegisterNotificationsFeedRoutes(api huma.API) { + Register(api, huma.Operation{ + OperationID: "notifications-atom-feed", + Summary: "Notifications Atom feed", + Description: "Returns the authenticated user's latest notifications as an Atom feed. Authenticated with HTTP Basic auth: the username is the token owner and the password is a feeds-scoped Vikunja API token (tk_ prefix) — password and LDAP credentials are rejected because feed URLs are commonly shared or cached. Fetching the feed does not mark notifications as read.", + Method: http.MethodGet, + Path: "/notifications.atom", + Tags: []string{"service"}, + // This op carries its own HTTP Basic auth instead of the global bearer + // schemes; the path is in unauthenticatedAPIPaths so the JWT middleware + // lets it through and the handler authenticates itself. + Security: []map[string][]string{{"BasicAuth": {}}}, + Responses: map[string]*huma.Response{ + "200": { + Description: "The notifications Atom feed.", + Content: map[string]*huma.MediaType{ + "application/atom+xml": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, notificationsAtomFeed) +} + +func init() { AddRouteRegistrar(RegisterNotificationsFeedRoutes) } + +// notificationsAtomFeed authenticates with HTTP Basic (sharing the feeds +// validator) and streams the Atom feed; there is no handler.Do* for a non-JSON +// body and the auth can't ride the group's JWT middleware. +func notificationsAtomFeed(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) { + c, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context) + if !ok { + return nil, huma.Error500InternalServerError("could not resolve request context") + } + + username, password, ok := (*c).Request().BasicAuth() + if !ok { + return nil, basicAuthChallenge(c) + } + + s := db.NewSession() + defer s.Close() + + u, err := feeds.AuthenticateFeedToken(s, username, password) + if err != nil { + return nil, translateDomainError(err) + } + if u == nil { + return nil, basicAuthChallenge(c) + } + + atom, err := feeds.BuildNotificationsAtomFeed(s, u) + if err != nil { + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + ec := humaecho5.Unwrap(hctx) + (*ec).Response().Header().Set(echo.HeaderContentType, feeds.AtomContentType) + _, _ = (*ec).Response().Write([]byte(atom)) + }}, nil +} + +// basicAuthChallenge returns a 401 carrying a WWW-Authenticate Basic challenge, +// mirroring v1's BasicAuth middleware so feed readers prompt for credentials. +func basicAuthChallenge(c *echo.Context) error { + (*c).Response().Header().Set(echo.HeaderWWWAuthenticate, `Basic realm="Restricted"`) + return huma.Error401Unauthorized(http.StatusText(http.StatusUnauthorized)) +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 0b98918a0..3cdc47f1b 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -371,6 +371,10 @@ var unauthenticatedAPIPaths = map[string]bool{ // Public infra healthcheck (a Huma op that opts out of the global auth). "/api/v2/health": true, + + // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a + // feeds-scoped API token), like its /feeds counterpart, not a JWT. + "/api/v2/notifications.atom": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. From 9cad4f388ce300f1d15fc3f54e3045db48e67d3c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:38:27 +0200 Subject: [PATCH 080/111] feat(api/v2): expose websocket endpoint under /api/v2 Adds GET /api/v2/ws as a raw echo route reusing the v1 upgrade handler. WebSockets can't be modeled in OpenAPI and Huma has no WS support, so it stays outside the Huma spec; it authenticates via its first message, so unauthenticatedAPIPaths exempts it from the group's JWT middleware. Also adds webtests covering all three /api/v2 non-CRUD endpoints: health returns OK, ws is reachable without a JWT, and the atom feed is basic-auth-gated. A spec test asserts /health and /notifications.atom appear in the generated OpenAPI paths (atom with its application/atom+xml response and BasicAuth security) while /ws is absent. --- pkg/routes/routes.go | 11 ++ pkg/webtests/huma_non_crud_aliases_test.go | 151 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 pkg/webtests/huma_non_crud_aliases_test.go diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 3cdc47f1b..bcdea1bdb 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -375,6 +375,10 @@ var unauthenticatedAPIPaths = map[string]bool{ // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a // feeds-scoped API token), like its /feeds counterpart, not a JWT. "/api/v2/notifications.atom": true, + + // WebSocket upgrade (a raw echo route — OpenAPI can't model WebSockets); + // it authenticates via its first message, so the upgrade needs no JWT. + "/api/v2/ws": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. @@ -447,6 +451,13 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { a.GET("/docs", apiv2.ScalarUI) a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS) + // WebSockets can't be modeled in OpenAPI and Huma has no WS support, so the + // upgrade endpoint stays a raw echo route (outside the Huma spec). It + // authenticates via its first message, so unauthenticatedAPIPaths exempts it + // from the group's JWT middleware. Health and the Atom feed are Huma ops and + // self-register via init()/RegisterAll. + a.GET("/ws", ws.UpgradeHandler) + // Resources self-register via init(); RegisterAll runs them all + AutoPatch. apiv2.RegisterAll(api) } diff --git a/pkg/webtests/huma_non_crud_aliases_test.go b/pkg/webtests/huma_non_crud_aliases_test.go new file mode 100644 index 000000000..1377507cd --- /dev/null +++ b/pkg/webtests/huma_non_crud_aliases_test.go @@ -0,0 +1,151 @@ +// 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 . + +package webtests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// feedsTokenUser13 is a feeds-scoped API token for user 13 (see the feeds +// fixtures); it authenticates the v2 notifications Atom feed via HTTP Basic. +const feedsTokenUser13 = "tk_feeds_access_token_user_0013_feed0013" + +// TestHumaNonCRUDAliases covers the three non-REST endpoints mounted under +// /api/v2. Health and the Atom feed are Huma operations (so they appear in the +// OpenAPI spec); the WebSocket upgrade stays a raw echo route (OpenAPI can't +// model WebSockets). Each authenticates itself, so the group's JWT middleware +// must let them through. +func TestHumaNonCRUDAliases(t *testing.T) { + t.Run("health is public and returns OK", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/health", "", "", "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "OK") + }) + + t.Run("ws is reachable without a JWT", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + // A plain GET without the upgrade headers makes websocket.Accept reject + // the request (typically 400). The point is that it reaches the handler + // at all — not a 401 from the JWT middleware nor a 404 for an unmounted + // route. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/ws", "", "", "") + assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "ws must not be blocked by v2 JWT auth") + assert.NotEqual(t, http.StatusNotFound, rec.Code, "ws must be mounted under /api/v2") + }) + + t.Run("atom feed is basic-auth-gated, not JWT-gated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("without credentials returns a basic-auth challenge", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // The JWT middleware skips this path, so the handler's own HTTP Basic + // auth gates it instead: a 401 carrying a Basic challenge, not the JWT + // middleware's JSON error. + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, strings.ToLower(rec.Header().Get(echo.HeaderWWWAuthenticate)), "basic", + "expected a Basic auth challenge, got %q", rec.Header().Get(echo.HeaderWWWAuthenticate)) + }) + + t.Run("with a feeds API token returns an atom feed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + req.SetBasicAuth("user13", feedsTokenUser13) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.True(t, strings.HasPrefix(rec.Header().Get(echo.HeaderContentType), "application/atom+xml"), + "expected atom content type, got %q", rec.Header().Get(echo.HeaderContentType)) + assert.Contains(t, rec.Body.String(), " Date: Wed, 17 Jun 2026 15:52:15 +0200 Subject: [PATCH 081/111] fix(tasks): prevent duplicate task_positions rows and stale identifiers A task could end up with more than one task_positions row for the same (task_id, project_view_id): rapid/concurrent creation raced the check-then-insert paths, and the create path could insert a position that a triggered RecalculateTaskPositions had already persisted for the new task. The table had no unique constraint, so the duplicates were stored silently (#2844). In the table view this made the LEFT JOIN on task_positions emit the task twice; getTasksForProjects enriched only the map entry, so the duplicate slice row kept an empty identifier and rendered as "#N" instead of "PREFIX-N" (#2725). - Add a unique index on task_positions(task_id, project_view_id) via a dedup migration (mirrors the task_buckets fix in 20250624092830) plus the unique(task_view) struct tag so fresh installs get it too. - Harden the create path: only queue a position insert when one does not already exist for the task+view, and dedupe within the batch. - Dedupe the task slice returned by getTasksForProjects by id, returning the enriched entry, so duplicate position rows can never surface a task twice or with a missing identifier. Fixes #2844 Fixes #2725 --- pkg/migration/20260617153629.go | 120 ++++++++++++++++++++++ pkg/models/kanban.go | 2 +- pkg/models/project.go | 2 +- pkg/models/saved_filter_positions_test.go | 11 +- pkg/models/task_collection_test.go | 2 +- pkg/models/task_position.go | 38 ++++++- pkg/models/task_search_bench_test.go | 2 +- pkg/models/tasks.go | 37 +++++-- 8 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 pkg/migration/20260617153629.go diff --git a/pkg/migration/20260617153629.go b/pkg/migration/20260617153629.go new file mode 100644 index 000000000..f6e0cc9d7 --- /dev/null +++ b/pkg/migration/20260617153629.go @@ -0,0 +1,120 @@ +// 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 . + +package migration + +import ( + "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 { + continue + } + 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 + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 01a6a145f..29b0b5705 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -257,7 +257,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, projects []*Pr } } - ts, _, total, err := getRawTasksForProjects(s, projects, auth, opts) + ts, total, err := getRawTasksForProjects(s, projects, auth, opts) if err != nil { return nil, err } diff --git a/pkg/models/project.go b/pkg/models/project.go index a799d1815..554f9d920 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1370,7 +1370,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { // Delete all tasks on that project // Using the loop to make sure all related entities to all tasks are properly deleted as well. - tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) + tasks, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) if err != nil { return } diff --git a/pkg/models/saved_filter_positions_test.go b/pkg/models/saved_filter_positions_test.go index d233460d2..91bf09d9c 100644 --- a/pkg/models/saved_filter_positions_test.go +++ b/pkg/models/saved_filter_positions_test.go @@ -79,8 +79,17 @@ func TestCronInsertsNonZeroPosition(t *testing.T) { require.NoError(t, err) require.True(t, exists) + // Force the task to a zero position in this view to simulate the unhealed + // state. A task only ever has one position row per view, so update it if it + // already exists (e.g. created with the filter) instead of inserting a duplicate. tp := &TaskPosition{TaskID: task.ID, ProjectViewID: view.ID, Position: 0} - _, err = s.Insert(tp) + hasPosition, err := s.Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).Exist(&TaskPosition{}) + require.NoError(t, err) + if hasPosition { + _, err = s.Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).Cols("position").Update(tp) + } else { + _, err = s.Insert(tp) + } require.NoError(t, err) _, err = calculateNewPositionForTask(s, u, task, view) diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index e66d945b2..db918175e 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -1898,7 +1898,7 @@ func TestTaskSearchWithExpandSubtasks(t *testing.T) { expand: []TaskCollectionExpandable{TaskCollectionExpandSubtasks}, } - tasks, _, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts) + tasks, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts) require.NoError(t, err) require.NotEmpty(t, tasks) } diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 07c2839cc..8ec58a3dd 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01 type TaskPosition struct { // The ID of the task this position is for - TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."` + TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."` // The project view this task is related to - ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."` + ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."` // The position of the task - any task project can be sorted as usual by this parameter. // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). @@ -341,6 +341,40 @@ func calculateNewPositionForTask(s *xorm.Session, a web.Auth, t *Task, view *Pro }, nil } +type taskPositionKey struct { + taskID int64 + viewID int64 +} + +// filterNewTaskPositions returns the positions whose (task_id, project_view_id) +// row does not exist yet, also deduplicating within the slice. Position creation +// during task creation can trigger a full recalculation (calculateNewPositionForTask +// or moveTaskToDoneBuckets) that already persists rows for the new task, so inserting +// the queued positions unconditionally would violate the unique index on +// (task_id, project_view_id). +func filterNewTaskPositions(s *xorm.Session, positions []*TaskPosition) ([]*TaskPosition, error) { + filtered := make([]*TaskPosition, 0, len(positions)) + seen := make(map[taskPositionKey]bool, len(positions)) + for _, p := range positions { + key := taskPositionKey{taskID: p.TaskID, viewID: p.ProjectViewID} + if seen[key] { + continue + } + seen[key] = true + + exists, err := s. + Where("task_id = ? AND project_view_id = ?", p.TaskID, p.ProjectViewID). + Exist(&TaskPosition{}) + if err != nil { + return nil, err + } + if !exists { + filtered = append(filtered, p) + } + } + return filtered, nil +} + // DeleteOrphanedTaskPositions removes task position records that reference // tasks or project views that no longer exist. // If dryRun is true, it counts the orphaned records without deleting them. diff --git a/pkg/models/task_search_bench_test.go b/pkg/models/task_search_bench_test.go index 142a58e41..443cf179e 100644 --- a/pkg/models/task_search_bench_test.go +++ b/pkg/models/task_search_bench_test.go @@ -139,7 +139,7 @@ func BenchmarkTaskSearch(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { s := db.NewSession() - resultSlice, _, _, err := getRawTasksForProjects(s, projects, auth, opts) + resultSlice, _, err := getRawTasksForProjects(s, projects, auth, opts) if len(resultSlice) == 0 { b.Fatalf("no results found for needle %q", needle) } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index eb2989694..d57b98b85 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -288,11 +288,11 @@ func getTaskIndexFromSearchString(s string) (index int64) { return } -func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, resultCount int, totalItems int64, err error) { +func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, totalItems int64, err error) { // If the user does not have any projects, don't try to get any tasks if len(projects) == 0 { - return nil, 0, 0, nil + return nil, 0, nil } // Get all project IDs and get the tasks @@ -324,17 +324,18 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op } tasks, totalItems, err = dbSearcher.Search(opts) - return tasks, len(tasks), totalItems, err + return tasks, totalItems, err } func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions, view *ProjectView) (tasks []*Task, resultCount int, totalItems int64, err error) { - tasks, resultCount, totalItems, err = getRawTasksForProjects(s, projects, a, opts) + tasks, totalItems, err = getRawTasksForProjects(s, projects, a, opts) if err != nil { return nil, 0, 0, err } - taskMap := make(map[int64]*Task, len(tasks)) - for _, t := range tasks { + rawTasks := tasks + taskMap := make(map[int64]*Task, len(rawTasks)) + for _, t := range rawTasks { taskMap[t.ID] = t } @@ -343,7 +344,22 @@ func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts return nil, 0, 0, err } - return tasks, resultCount, totalItems, err + // A task can appear more than once in the raw result when it has duplicate + // task_positions rows for the view (the LEFT JOIN multiplies it). Return one + // entry per task, in the original sort order, referencing the enriched map + // value so its identifier and other data are set. totalItems already counts + // distinct tasks, so this also aligns the page size with it. + tasks = make([]*Task, 0, len(taskMap)) + seen := make(map[int64]bool, len(taskMap)) + for _, t := range rawTasks { + if seen[t.ID] { + continue + } + seen[t.ID] = true + tasks = append(tasks, taskMap[t.ID]) + } + + return tasks, len(tasks), totalItems, err } // GetTaskByIDSimple returns a raw task without extra data by the task ID @@ -984,6 +1000,13 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, setB return err } + if len(positions) > 0 { + positions, err = filterNewTaskPositions(s, positions) + if err != nil { + return err + } + } + if len(positions) > 0 { _, err = s.Insert(&positions) if err != nil { From 647f1f4def822d1d52f28b1a04d334792dbe3d81 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:55:27 +0200 Subject: [PATCH 082/111] fix(migration): fail loudly if a deduplicated position pair has no row A pair returned by the GroupBy was just reported as duplicated, so a row must exist. Continuing on !has would let the delete loop drop every row for that pair without re-inserting one, silently losing positions. Abort the migration instead. --- pkg/migration/20260617153629.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/migration/20260617153629.go b/pkg/migration/20260617153629.go index f6e0cc9d7..d5ae3d772 100644 --- a/pkg/migration/20260617153629.go +++ b/pkg/migration/20260617153629.go @@ -17,6 +17,8 @@ package migration import ( + "fmt" + "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "xorm.io/xorm/schemas" @@ -74,7 +76,12 @@ func init() { return err } if !has { - continue + // 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) } From 99d025399c780b6b3d7382d7756fdab159fa0768 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:55:32 +0200 Subject: [PATCH 083/111] perf(tasks): batch task position existence check into one query filterNewTaskPositions ran one Exist query per position. createTask calls it in loops (bulk import, project duplication), so this was O(tasks * views) queries. Fetch all existing rows for the involved tasks once and filter in memory instead. --- pkg/models/task_position.go | 39 ++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 8ec58a3dd..bcc09884b 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -353,24 +353,41 @@ type taskPositionKey struct { // the queued positions unconditionally would violate the unique index on // (task_id, project_view_id). func filterNewTaskPositions(s *xorm.Session, positions []*TaskPosition) ([]*TaskPosition, error) { + if len(positions) == 0 { + return positions, nil + } + + taskIDs := make([]int64, 0, len(positions)) + seenTask := make(map[int64]bool, len(positions)) + for _, p := range positions { + if seenTask[p.TaskID] { + continue + } + seenTask[p.TaskID] = true + taskIDs = append(taskIDs, p.TaskID) + } + + // Fetch all existing rows for the involved tasks in one query so this stays + // cheap when createTask runs in a loop (bulk import, project duplication). + existing := []*TaskPosition{} + err := s.In("task_id", taskIDs).Find(&existing) + if err != nil { + return nil, err + } + + seen := make(map[taskPositionKey]bool, len(positions)+len(existing)) + for _, e := range existing { + seen[taskPositionKey{taskID: e.TaskID, viewID: e.ProjectViewID}] = true + } + filtered := make([]*TaskPosition, 0, len(positions)) - seen := make(map[taskPositionKey]bool, len(positions)) for _, p := range positions { key := taskPositionKey{taskID: p.TaskID, viewID: p.ProjectViewID} if seen[key] { continue } seen[key] = true - - exists, err := s. - Where("task_id = ? AND project_view_id = ?", p.TaskID, p.ProjectViewID). - Exist(&TaskPosition{}) - if err != nil { - return nil, err - } - if !exists { - filtered = append(filtered, p) - } + filtered = append(filtered, p) } return filtered, nil } From 7b7c850dd85292b379ad821c9eed6fa30be77bd5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:55:37 +0200 Subject: [PATCH 084/111] refactor(tasks): drop in-memory task dedup, rely on unique index The duplicate task rows getTasksForProjects deduplicated came from the LEFT JOIN multiplying when duplicate task_positions rows existed. The new unique index on (task_id, project_view_id) removes the root cause at the SQL layer (the migration also runs before serving), so the join can no longer multiply. Revert getTasksForProjects and getRawTasksForProjects to their pre-dedup shape. --- pkg/models/kanban.go | 2 +- pkg/models/project.go | 2 +- pkg/models/task_collection_test.go | 2 +- pkg/models/task_search_bench_test.go | 2 +- pkg/models/tasks.go | 30 +++++++--------------------- 5 files changed, 11 insertions(+), 27 deletions(-) diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 29b0b5705..01a6a145f 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -257,7 +257,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, projects []*Pr } } - ts, total, err := getRawTasksForProjects(s, projects, auth, opts) + ts, _, total, err := getRawTasksForProjects(s, projects, auth, opts) if err != nil { return nil, err } diff --git a/pkg/models/project.go b/pkg/models/project.go index 554f9d920..a799d1815 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1370,7 +1370,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { // Delete all tasks on that project // Using the loop to make sure all related entities to all tasks are properly deleted as well. - tasks, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) + tasks, _, _, err := getRawTasksForProjects(s, []*Project{p}, a, &taskSearchOptions{}) if err != nil { return } diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index db918175e..e66d945b2 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -1898,7 +1898,7 @@ func TestTaskSearchWithExpandSubtasks(t *testing.T) { expand: []TaskCollectionExpandable{TaskCollectionExpandSubtasks}, } - tasks, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts) + tasks, _, _, err := getRawTasksForProjects(s, []*Project{project}, &user.User{ID: 15}, opts) require.NoError(t, err) require.NotEmpty(t, tasks) } diff --git a/pkg/models/task_search_bench_test.go b/pkg/models/task_search_bench_test.go index 443cf179e..142a58e41 100644 --- a/pkg/models/task_search_bench_test.go +++ b/pkg/models/task_search_bench_test.go @@ -139,7 +139,7 @@ func BenchmarkTaskSearch(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { s := db.NewSession() - resultSlice, _, err := getRawTasksForProjects(s, projects, auth, opts) + resultSlice, _, _, err := getRawTasksForProjects(s, projects, auth, opts) if len(resultSlice) == 0 { b.Fatalf("no results found for needle %q", needle) } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index d57b98b85..a49e4175e 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -288,11 +288,11 @@ func getTaskIndexFromSearchString(s string) (index int64) { return } -func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, totalItems int64, err error) { +func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions) (tasks []*Task, resultCount int, totalItems int64, err error) { // If the user does not have any projects, don't try to get any tasks if len(projects) == 0 { - return nil, 0, nil + return nil, 0, 0, nil } // Get all project IDs and get the tasks @@ -324,18 +324,17 @@ func getRawTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, op } tasks, totalItems, err = dbSearcher.Search(opts) - return tasks, totalItems, err + return tasks, len(tasks), totalItems, err } func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts *taskSearchOptions, view *ProjectView) (tasks []*Task, resultCount int, totalItems int64, err error) { - tasks, totalItems, err = getRawTasksForProjects(s, projects, a, opts) + tasks, resultCount, totalItems, err = getRawTasksForProjects(s, projects, a, opts) if err != nil { return nil, 0, 0, err } - rawTasks := tasks - taskMap := make(map[int64]*Task, len(rawTasks)) - for _, t := range rawTasks { + taskMap := make(map[int64]*Task, len(tasks)) + for _, t := range tasks { taskMap[t.ID] = t } @@ -344,22 +343,7 @@ func getTasksForProjects(s *xorm.Session, projects []*Project, a web.Auth, opts return nil, 0, 0, err } - // A task can appear more than once in the raw result when it has duplicate - // task_positions rows for the view (the LEFT JOIN multiplies it). Return one - // entry per task, in the original sort order, referencing the enriched map - // value so its identifier and other data are set. totalItems already counts - // distinct tasks, so this also aligns the page size with it. - tasks = make([]*Task, 0, len(taskMap)) - seen := make(map[int64]bool, len(taskMap)) - for _, t := range rawTasks { - if seen[t.ID] { - continue - } - seen[t.ID] = true - tasks = append(tasks, taskMap[t.ID]) - } - - return tasks, len(tasks), totalItems, err + return tasks, resultCount, totalItems, err } // GetTaskByIDSimple returns a raw task without extra data by the task ID From 7f53be410567269eaa3ec24333d0fcdd51f57436 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:35:25 +0200 Subject: [PATCH 085/111] fix(notifications): refresh embedded users when reading notifications Notifications stored before the acting user was resolved with its full profile (#2720) were serialized with only id+username, so they kept rendering the auto-generated username instead of the display name. Reload every embedded user from the database when reading a user's notifications, healing already-stored rows at read time. The refresh is not persisted; a per-page cache fetches each user once. --- .golangci.yml | 3 + pkg/models/notifications_database.go | 8 +- pkg/models/notifications_refresh.go | 122 +++++++++++++++++++++++ pkg/models/notifications_refresh_test.go | 109 ++++++++++++++++++++ 4 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 pkg/models/notifications_refresh.go create mode 100644 pkg/models/notifications_refresh_test.go diff --git a/.golangci.yml b/.golangci.yml index 19ee2f531..7c75f08de 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -80,6 +80,9 @@ linters: - linters: - exhaustive path: pkg/models/task_collection_filter\.go + - linters: + - exhaustive + path: pkg/models/notifications_refresh\.go - linters: - gosec path: pkg/utils/random_string\.go diff --git a/pkg/models/notifications_database.go b/pkg/models/notifications_database.go index aaa71103c..75beef580 100644 --- a/pkg/models/notifications_database.go +++ b/pkg/models/notifications_database.go @@ -53,7 +53,13 @@ func (d *DatabaseNotifications) ReadAll(s *xorm.Session, a web.Auth, _ string, p } limit, start := getLimitFromPageIndex(page, perPage) - return notifications.GetNotificationsForUser(s, a.GetID(), limit, start) + ns, resultCount, total, err := notifications.GetNotificationsForUser(s, a.GetID(), limit, start) + if err != nil { + return nil, 0, 0, err + } + + refreshNotificationsUsers(s, ns) + return ns, resultCount, total, nil } // CanUpdate checks if a user can mark a notification as read. diff --git a/pkg/models/notifications_refresh.go b/pkg/models/notifications_refresh.go new file mode 100644 index 000000000..81e8a9609 --- /dev/null +++ b/pkg/models/notifications_refresh.go @@ -0,0 +1,122 @@ +// 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 . + +package models + +import ( + "encoding/json" + "reflect" + + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/notifications" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// maxNotificationUserRefreshDepth bounds the reflection walk so an unexpectedly +// deep payload cannot recurse without end. +const maxNotificationUserRefreshDepth = 8 + +// refreshNotificationsUsers reloads every embedded user of each notification +// from the database. Notifications serialized before the acting user was +// resolved with its full profile (#2720) stored only id+username, so without +// this they keep rendering the auto-generated username instead of the display +// name. It runs at read time and is not persisted; one cache is shared across +// the batch so a user recurring across notifications is fetched only once. +func refreshNotificationsUsers(s *xorm.Session, dbNotifications []*notifications.DatabaseNotification) { + cache := make(map[int64]*user.User) + for _, dbn := range dbNotifications { + refreshNotificationUsers(s, dbn, cache) + } +} + +func refreshNotificationUsers(s *xorm.Session, dbn *notifications.DatabaseNotification, cache map[int64]*user.User) { + typed, ok := notifications.Lookup(dbn.Name) + if !ok { + return + } + + raw, err := json.Marshal(dbn.Notification) + if err != nil { + log.Errorf("Could not marshal notification %d to refresh its users: %v", dbn.ID, err) + return + } + if err := json.Unmarshal(raw, typed); err != nil { + log.Errorf("Could not unmarshal notification %d to refresh its users: %v", dbn.ID, err) + return + } + + refreshUsersInValue(s, reflect.ValueOf(typed), cache, 0) + dbn.Notification = typed +} + +func refreshUsersInValue(s *xorm.Session, v reflect.Value, cache map[int64]*user.User, depth int) { + if depth > maxNotificationUserRefreshDepth || !v.IsValid() { + return + } + + switch v.Kind() { + case reflect.Ptr: + if v.IsNil() { + return + } + if u, is := v.Interface().(*user.User); is { + refreshUser(s, u, cache) + return + } + refreshUsersInValue(s, v.Elem(), cache, depth+1) + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if !v.Type().Field(i).IsExported() { + continue + } + refreshUsersInValue(s, v.Field(i), cache, depth+1) + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + refreshUsersInValue(s, v.Index(i), cache, depth+1) + } + case reflect.Map: + for _, key := range v.MapKeys() { + refreshUsersInValue(s, v.MapIndex(key), cache, depth+1) + } + } +} + +// refreshUser overwrites the user in place with its current database row. A +// disabled or locked account is still returned fully populated, so only a +// missing user or a real database error leaves the stored value untouched. +func refreshUser(s *xorm.Session, u *user.User, cache map[int64]*user.User) { + if u == nil || u.ID == 0 { + return + } + + fresh, cached := cache[u.ID] + if !cached { + loaded, err := user.GetUserByID(s, u.ID) + if err != nil && !user.IsErrUserStatusError(err) { + cache[u.ID] = nil + return + } + fresh = loaded + cache[u.ID] = fresh + } + + if fresh != nil { + *u = *fresh + } +} diff --git a/pkg/models/notifications_refresh_test.go b/pkg/models/notifications_refresh_test.go new file mode 100644 index 000000000..b0cef2a43 --- /dev/null +++ b/pkg/models/notifications_refresh_test.go @@ -0,0 +1,109 @@ +// 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 . + +package models + +import ( + "encoding/json" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/notifications" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/require" + "xorm.io/xorm" +) + +// TestDatabaseNotifications_ReadAll_RefreshesUsers guards #2720 for notifications +// already in the database: those were serialized with a partial doer (id + +// username, no display Name), so reading them must reload the embedded users so +// the display name is shown. The fix in the dispatch path only helps new +// notifications; old rows are healed here at read time. +func TestDatabaseNotifications_ReadAll_RefreshesUsers(t *testing.T) { + t.Run("fills in the display name from the database", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // user12 has the display name "Name with spaces" in the fixtures. + insertStoredNotification(t, s, 1, &TaskAssignedNotification{ + Doer: &user.User{ID: 12, Username: "user12"}, + Assignee: &user.User{ID: 12, Username: "user12"}, + Task: &Task{ID: 1}, + }) + + got := readAssignedNotification(t, s, 1) + require.Equal(t, "Name with spaces", got.Doer.GetName()) + require.Equal(t, "Name with spaces", got.Assignee.GetName()) + }) + + t.Run("keeps the stored value when the user no longer exists", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + insertStoredNotification(t, s, 1, &TaskAssignedNotification{ + Doer: &user.User{ID: 999999, Username: "ghost"}, + Task: &Task{ID: 1}, + }) + + got := readAssignedNotification(t, s, 1) + require.Equal(t, "ghost", got.Doer.Username) + }) + + t.Run("refreshes a disabled user", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // user17 is disabled in the fixtures; the reload must still win over the + // stale stored value. + insertStoredNotification(t, s, 1, &TaskAssignedNotification{ + Doer: &user.User{ID: 17, Username: "stale"}, + Task: &Task{ID: 1}, + }) + + got := readAssignedNotification(t, s, 1) + require.Equal(t, "user17", got.Doer.Username) + }) +} + +func insertStoredNotification(t *testing.T, s *xorm.Session, notifiableID int64, n notifications.Notification) { + t.Helper() + content, err := json.Marshal(n) + require.NoError(t, err) + _, err = s.Insert(¬ifications.DatabaseNotification{ + NotifiableID: notifiableID, + Notification: json.RawMessage(content), + Name: n.Name(), + }) + require.NoError(t, err) +} + +func readAssignedNotification(t *testing.T, s *xorm.Session, notifiableID int64) *TaskAssignedNotification { + t.Helper() + result, _, _, err := (&DatabaseNotifications{}).ReadAll(s, &user.User{ID: notifiableID}, "", 1, 50) + require.NoError(t, err) + + for _, dbn := range result.([]*notifications.DatabaseNotification) { + if n, is := dbn.Notification.(*TaskAssignedNotification); is { + return n + } + } + t.Fatal("no task.assigned notification was returned") + return nil +} From aac4dd845e6265fe0d4a3e4a4374f6545dff1fdd Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:47:38 +0200 Subject: [PATCH 086/111] refactor(notifications): refresh users via an explicit type switch Reflection over reflect.Kind was overkill: only top-level doer/assignee/ member fields are ever rendered, and the walk forced an exhaustive linter exclusion. List the user fields per notification type instead, which drops the reflect dependency and the .golangci.yml carve-out. --- .golangci.yml | 3 -- pkg/models/notifications_refresh.go | 69 +++++++++++------------------ 2 files changed, 27 insertions(+), 45 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7c75f08de..19ee2f531 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -80,9 +80,6 @@ linters: - linters: - exhaustive path: pkg/models/task_collection_filter\.go - - linters: - - exhaustive - path: pkg/models/notifications_refresh\.go - linters: - gosec path: pkg/utils/random_string\.go diff --git a/pkg/models/notifications_refresh.go b/pkg/models/notifications_refresh.go index 81e8a9609..17af513f6 100644 --- a/pkg/models/notifications_refresh.go +++ b/pkg/models/notifications_refresh.go @@ -18,7 +18,6 @@ package models import ( "encoding/json" - "reflect" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/notifications" @@ -27,16 +26,12 @@ import ( "xorm.io/xorm" ) -// maxNotificationUserRefreshDepth bounds the reflection walk so an unexpectedly -// deep payload cannot recurse without end. -const maxNotificationUserRefreshDepth = 8 - -// refreshNotificationsUsers reloads every embedded user of each notification -// from the database. Notifications serialized before the acting user was -// resolved with its full profile (#2720) stored only id+username, so without -// this they keep rendering the auto-generated username instead of the display -// name. It runs at read time and is not persisted; one cache is shared across -// the batch so a user recurring across notifications is fetched only once. +// refreshNotificationsUsers reloads each notification's embedded users from the +// database. Notifications serialized before the acting user was resolved with +// its full profile (#2720) stored only id+username, so without this they keep +// rendering the auto-generated username instead of the display name. It runs at +// read time and is not persisted; one cache is shared across the batch so a +// user recurring across notifications is fetched only once. func refreshNotificationsUsers(s *xorm.Session, dbNotifications []*notifications.DatabaseNotification) { cache := make(map[int64]*user.User) for _, dbn := range dbNotifications { @@ -60,40 +55,30 @@ func refreshNotificationUsers(s *xorm.Session, dbn *notifications.DatabaseNotifi return } - refreshUsersInValue(s, reflect.ValueOf(typed), cache, 0) + for _, u := range notificationUsers(typed) { + refreshUser(s, u, cache) + } dbn.Notification = typed } -func refreshUsersInValue(s *xorm.Session, v reflect.Value, cache map[int64]*user.User, depth int) { - if depth > maxNotificationUserRefreshDepth || !v.IsValid() { - return - } - - switch v.Kind() { - case reflect.Ptr: - if v.IsNil() { - return - } - if u, is := v.Interface().(*user.User); is { - refreshUser(s, u, cache) - return - } - refreshUsersInValue(s, v.Elem(), cache, depth+1) - case reflect.Struct: - for i := 0; i < v.NumField(); i++ { - if !v.Type().Field(i).IsExported() { - continue - } - refreshUsersInValue(s, v.Field(i), cache, depth+1) - } - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - refreshUsersInValue(s, v.Index(i), cache, depth+1) - } - case reflect.Map: - for _, key := range v.MapKeys() { - refreshUsersInValue(s, v.MapIndex(key), cache, depth+1) - } +// notificationUsers returns the user fields a stored notification renders, so +// they can be reloaded. New notification types carrying a user belong here. +func notificationUsers(n notifications.Notification) []*user.User { + switch n := n.(type) { + case *TaskCommentNotification: + return []*user.User{n.Doer} + case *TaskAssignedNotification: + return []*user.User{n.Doer, n.Assignee} + case *TaskDeletedNotification: + return []*user.User{n.Doer} + case *ProjectCreatedNotification: + return []*user.User{n.Doer} + case *TeamMemberAddedNotification: + return []*user.User{n.Doer, n.Member} + case *UserMentionedInTaskNotification: + return []*user.User{n.Doer} + default: + return nil } } From 37a34cc5cf04a80a3d37b6e193c3c473a9b866ff Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 22:49:21 +0200 Subject: [PATCH 087/111] fix(notifications): log unexpected user refresh failures A transient database error while reloading a notification's user was swallowed silently, leaving stale names with no trace. Log everything except the expected "user was deleted" case. --- pkg/models/notifications_refresh.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/models/notifications_refresh.go b/pkg/models/notifications_refresh.go index 17af513f6..cc119aeae 100644 --- a/pkg/models/notifications_refresh.go +++ b/pkg/models/notifications_refresh.go @@ -94,6 +94,9 @@ func refreshUser(s *xorm.Session, u *user.User, cache map[int64]*user.User) { if !cached { loaded, err := user.GetUserByID(s, u.ID) if err != nil && !user.IsErrUserStatusError(err) { + if !user.IsErrUserDoesNotExist(err) { + log.Errorf("Could not refresh user %d for a notification: %v", u.ID, err) + } cache[u.ID] = nil return } From 86ec62d10b2885eede9aaa9db1afbab9d703892c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 18 Jun 2026 22:24:47 +0200 Subject: [PATCH 088/111] fix(frontend): scroll tall default modals instead of clipping their top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centered default/hint-modal content used translate(-50%, -50%) with no height cap, so a taller-than-viewport modal (e.g. project background settings with the Unsplash grid) pushed its top edge above the viewport where the container's overflow can't reach it — the upload button became unreachable on short screens. Cap the centered content to the viewport and scroll inside it, mirroring the height limit the .top (quick actions) variant already has. The mobile breakpoint resets both so the fullscreen layout keeps flowing in .modal-container. --- frontend/src/components/misc/Modal.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/components/misc/Modal.vue b/frontend/src/components/misc/Modal.vue index b654e0268..5450d0a99 100644 --- a/frontend/src/components/misc/Modal.vue +++ b/frontend/src/components/misc/Modal.vue @@ -272,6 +272,12 @@ $modal-width: 1024px; 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%); @@ -281,6 +287,9 @@ $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 { From 80bb9aadc1676a55d3396b85223ef0fb22dc295c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:50:47 +0000 Subject: [PATCH 089/111] chore(deps): update dev-dependencies to v20.10.6 --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index fdafc836a..8bf71e748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -132,7 +132,7 @@ "eslint": "9.39.4", "eslint-plugin-depend": "1.5.0", "eslint-plugin-vue": "10.9.2", - "happy-dom": "20.10.5", + "happy-dom": "20.10.6", "histoire": "1.0.0-beta.1", "otplib": "12.0.1", "postcss": "8.5.15", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 8fa9e43e6..5d3a0c1d6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -263,8 +263,8 @@ importers: specifier: 10.9.2 version: 10.9.2(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: - specifier: 20.10.5 - version: 20.10.5 + specifier: 20.10.6 + version: 20.10.6 histoire: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1(@types/node@24.13.2)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) @@ -330,7 +330,7 @@ importers: version: 5.1.1(vue@3.5.27(typescript@5.9.3)) vitest: specifier: 4.1.9 - version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.5)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) + version: 4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) vue-tsc: specifier: 3.3.5 version: 3.3.5(typescript@5.9.3) @@ -4098,8 +4098,8 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - happy-dom@20.10.5: - resolution: {integrity: sha512-0aA6BQoMnpcRE/c1E8ZyF2jXnET7MJskereWOXher4CJuYjrI5esN0Az/1NPMD4KeWUbampBGw2MGqabMPFIbg==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} hard-rejection@2.1.0: @@ -10943,7 +10943,7 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - happy-dom@20.10.5: + happy-dom@20.10.6: dependencies: '@types/node': 24.13.2 '@types/whatwg-mimetype': 3.0.2 @@ -13592,7 +13592,7 @@ snapshots: terser: 5.31.6 yaml: 2.8.3 - vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.5)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): + vitest@4.1.9(@types/node@24.13.2)(happy-dom@20.10.6)(jsdom@27.4.0)(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@24.13.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3)) @@ -13616,7 +13616,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.2 - happy-dom: 20.10.5 + happy-dom: 20.10.6 jsdom: 27.4.0 transitivePeerDependencies: - msw From 5236e0c306f77af6eb667959590132ccdb88a114 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 19:44:36 +0200 Subject: [PATCH 090/111] fix(notifications): use full user so notifications show display name Notifications and emails showed the acting user's auto-generated username instead of their display Name. The doer attached to notification events was built straight from the JWT via user.GetFromAuth, which only carries id + username (Name is never set in GetUserFromClaims). Notifications render n.Doer.GetName(), which falls back to the username when Name is empty, so every "assigned you", "mentioned you", task-deleted, project-created and team-member notification rendered the username. Resolve the full user from the database at the event-producing dispatch sites. doerFromAuth now re-fetches the user (with Name) and is reused by all the notification doers; account-status errors are swallowed so flows acting on behalf of disabled accounts (e.g. user deletion deleting that user's tasks) keep working while still carrying the display name. Fixes #2720 --- pkg/events/testing.go | 12 +++++ pkg/models/kanban_task_bucket.go | 4 +- pkg/models/project.go | 6 +-- pkg/models/project_team.go | 2 +- pkg/models/project_users.go | 2 +- pkg/models/task_assignees.go | 4 +- pkg/models/task_assignees_test.go | 80 +++++++++++++++++++++++++++++++ pkg/models/tasks.go | 9 ++-- pkg/models/team_members.go | 3 +- pkg/models/teams.go | 2 +- pkg/models/users.go | 20 ++++++-- 11 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 pkg/models/task_assignees_test.go diff --git a/pkg/events/testing.go b/pkg/events/testing.go index 2c969f057..302886747 100644 --- a/pkg/events/testing.go +++ b/pkg/events/testing.go @@ -76,6 +76,18 @@ 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 diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index 108b8a371..ae6757b30 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -21,7 +21,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/xorm" ) @@ -252,10 +251,9 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) { } if b.Task != nil { - doer, _ := user.GetFromAuth(a) events.DispatchOnCommit(s, &TaskUpdatedEvent{ Task: b.Task, - Doer: doer, + Doer: doerFromAuth(s, a), }) } return nil diff --git a/pkg/models/project.go b/pkg/models/project.go index a799d1815..b0ad5ee4c 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -1072,7 +1072,7 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl events.DispatchOnCommit(s, &ProjectCreatedEvent{ Project: project, - Doer: doer, + Doer: doerFromAuth(s, auth), }) return nil } @@ -1219,7 +1219,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje events.DispatchOnCommit(s, &ProjectUpdatedEvent{ Project: project, - Doer: doerFromAuth(auth), + Doer: doerFromAuth(s, auth), }) l, err := GetProjectSimpleByID(s, project.ID) @@ -1450,7 +1450,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectDeletedEvent{ Project: fullProject, - Doer: doerFromAuth(a), + Doer: doerFromAuth(s, a), }) childProjects := []*Project{} diff --git a/pkg/models/project_team.go b/pkg/models/project_team.go index e3571906c..e6fd75f96 100644 --- a/pkg/models/project_team.go +++ b/pkg/models/project_team.go @@ -112,7 +112,7 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{ Project: l, Team: team, - Doer: doerFromAuth(a), + Doer: doerFromAuth(s, a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/project_users.go b/pkg/models/project_users.go index 41254ac1d..0789220ce 100644 --- a/pkg/models/project_users.go +++ b/pkg/models/project_users.go @@ -118,7 +118,7 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{ Project: l, User: u, - Doer: doerFromAuth(a), + Doer: doerFromAuth(s, a), }) err = updateProjectLastUpdated(s, l) diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go index 517d9fc5d..662973e80 100644 --- a/pkg/models/task_assignees.go +++ b/pkg/models/task_assignees.go @@ -181,7 +181,7 @@ func (la *TaskAssginee) Delete(s *xorm.Session, a web.Auth) (err error) { return err } - doer, _ := user.GetFromAuth(a) + doer := doerFromAuth(s, a) task, err := GetTaskByIDSimple(s, la.TaskID) if err != nil { return err @@ -270,7 +270,7 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, project return err } - doer, _ := user.GetFromAuth(auth) + doer := doerFromAuth(s, auth) task, err := GetTaskSimple(s, &Task{ID: t.ID}) if err != nil { return err diff --git a/pkg/models/task_assignees_test.go b/pkg/models/task_assignees_test.go new file mode 100644 index 000000000..415913d1c --- /dev/null +++ b/pkg/models/task_assignees_test.go @@ -0,0 +1,80 @@ +// 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 . + +package models + +import ( + "context" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/require" +) + +// TestTaskAssignee_DoerHasDisplayName guards against the regression in #2720: the doer attached to +// notification events was built straight from the JWT (id + username only), so notifications and +// emails rendered the auto-generated username instead of the user's display Name. The dispatch sites +// now resolve the full user from the database, so the doer must carry the display Name even when the +// acting auth object only has id + username (as GetUserFromClaims produces). +func TestTaskAssignee_DoerHasDisplayName(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Mimics the partial user GetUserFromClaims builds from a JWT: id + username, no Name. + // user12 has the display name "Name with spaces" in the fixtures and owns project 23. + doer := &user.User{ID: 12, Username: "user12"} + require.Equal(t, "user12", doer.GetName(), "the auth doer must start without a display name") + + task := &Task{Title: "assign me", ProjectID: 23} + require.NoError(t, task.Create(s, doer)) + + events.ClearDispatchedEvents() + + ta := &TaskAssginee{TaskID: task.ID, UserID: 12} + require.NoError(t, ta.Create(s, doer)) + require.NoError(t, s.Commit()) + + events.DispatchPending(context.Background(), s) + + dispatched := events.GetDispatchedEvents((&TaskAssigneeCreatedEvent{}).Name()) + require.Len(t, dispatched, 1) + ev := dispatched[0].(*TaskAssigneeCreatedEvent) + require.NotNil(t, ev.Doer) + require.Equal(t, "Name with spaces", ev.Doer.GetName(), + "notification doer must carry the display Name, not the username") +} + +// TestDoerFromAuth_DisabledUser ensures resolving the event doer keeps working when acting on behalf +// of a disabled account (e.g. user deletion deletes that user's tasks). The full user is still +// returned with its display name, the disabled status error is swallowed. +func TestDoerFromAuth_DisabledUser(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // user17 is disabled in the fixtures. + _, err := user.GetUserByID(s, 17) + require.Error(t, err, "fixture user17 is expected to be disabled") + require.True(t, user.IsErrAccountDisabled(err)) + + doer := doerFromAuth(s, &user.User{ID: 17, Username: "user17"}) + require.NotNil(t, doer) + require.Equal(t, int64(17), doer.ID) +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index a49e4175e..ec6200c48 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1462,10 +1462,9 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e } t.Updated = nt.Updated - doer, _ := user.GetFromAuth(a) events.DispatchOnCommit(s, &TaskUpdatedEvent{ Task: t, - Doer: doer, + Doer: doerFromAuth(s, a), }) return updateProjectLastUpdated(s, &Project{ID: t.ProjectID}) @@ -1961,10 +1960,9 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) { return err } - doer, _ := user.GetFromAuth(a) events.DispatchOnCommit(s, &TaskDeletedEvent{ Task: fullTask, - Doer: doer, + Doer: doerFromAuth(s, a), }) err = updateProjectLastUpdated(s, &Project{ID: t.ProjectID}) @@ -2032,10 +2030,9 @@ func triggerTaskUpdatedEventForTaskID(s *xorm.Session, auth web.Auth, taskID int return err } - doer, _ := user.GetFromAuth(auth) events.DispatchOnCommit(s, &TaskUpdatedEvent{ Task: &t, - Doer: doer, + Doer: doerFromAuth(s, auth), }) return nil } diff --git a/pkg/models/team_members.go b/pkg/models/team_members.go index ac2654214..b31929277 100644 --- a/pkg/models/team_members.go +++ b/pkg/models/team_members.go @@ -69,11 +69,10 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) { return err } - doer, _ := user2.GetFromAuth(a) events.DispatchOnCommit(s, &TeamMemberAddedEvent{ Team: team, Member: member, - Doer: doer, + Doer: doerFromAuth(s, a), }) return nil } diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 6f73dc3ae..ab0a80846 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) { events.DispatchOnCommit(s, &TeamDeletedEvent{ Team: t, - Doer: doerFromAuth(a), + Doer: doerFromAuth(s, a), }) return nil } diff --git a/pkg/models/users.go b/pkg/models/users.go index 84a7101da..b5a79f510 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -22,14 +22,24 @@ import ( "xorm.io/xorm" ) -// doerFromAuth converts the authenticated principal into a user for event -// payloads without re-fetching it. A re-fetch would fail its status check in -// flows acting on behalf of disabled accounts (e.g. user deletion), and the -// event only needs the principal as it authenticated. -func doerFromAuth(a web.Auth) *user.User { +// doerFromAuth resolves the authenticated principal into a full user for event payloads. The JWT +// only carries id + username, so without a re-fetch notifications and emails render the +// auto-generated username instead of the display name (#2720). Status errors (disabled/locked) are +// swallowed because their user is still populated and some flows act on behalf of such accounts +// (e.g. user deletion deletes that user's tasks); the partial principal is used as a last resort. +func doerFromAuth(s *xorm.Session, a web.Auth) *user.User { if a == nil { return nil } + + doer, err := GetUserOrLinkShareUser(s, a) + if err != nil && !user.IsErrUserStatusError(err) { + doer = nil + } + if doer != nil && doer.ID != 0 { + return doer + } + if u, is := a.(*user.User); is { return u } From 1e1e733c36424cabeaf13a631d245f7bdb89f634 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:08:47 +0000 Subject: [PATCH 091/111] chore(deps): bump dompurify from 3.4.9 to 3.4.11 in /frontend Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.9 to 3.4.11. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.9...3.4.11) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.11 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 45 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 8bf71e748..b63579377 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,7 +82,7 @@ "bulma-css-variables": "0.9.33", "change-case": "5.4.4", "dayjs": "1.11.19", - "dompurify": "3.4.9", + "dompurify": "3.4.11", "fast-deep-equal": "3.1.3", "flatpickr": "4.6.13", "floating-vue": "5.2.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5d3a0c1d6..1059ecf19 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -118,8 +118,8 @@ importers: specifier: 1.11.19 version: 1.11.19 dompurify: - specifier: 3.4.9 - version: 3.4.9 + specifier: 3.4.11 + version: 3.4.11 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -1828,36 +1828,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -2017,66 +2023,79 @@ packages: resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.0': resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.0': resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.0': resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.0': resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.0': resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.0': resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.0': resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.0': resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.0': resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.0': resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.0': resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.0': resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.62.0': resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} @@ -2279,24 +2298,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} @@ -3571,8 +3594,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.9: - resolution: {integrity: sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4640,24 +4663,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5662,48 +5689,56 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: glibc sass-embedded-linux-arm@1.100.0: resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: glibc sass-embedded-linux-musl-arm64@1.100.0: resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] + libc: musl sass-embedded-linux-musl-arm@1.100.0: resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] + libc: musl sass-embedded-linux-musl-riscv64@1.100.0: resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: musl sass-embedded-linux-musl-x64@1.100.0: resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: musl sass-embedded-linux-riscv64@1.100.0: resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] + libc: glibc sass-embedded-linux-x64@1.100.0: resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] + libc: glibc sass-embedded-unknown-all@1.100.0: resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} @@ -10309,7 +10344,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.9: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 From 9d18ba236fd05420c185a72a7e5b1a307389e5e2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 18 Jun 2026 23:52:52 +0200 Subject: [PATCH 092/111] feat(time-tracking): add favicon indicator for active time tracking sessions (#2937) --- .../images/icons/favicon-tracking-32x32.png | Bin 0 -> 2089 bytes frontend/src/App.vue | 2 ++ .../src/composables/useTimeTrackingFavicon.ts | 32 ++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 frontend/public/images/icons/favicon-tracking-32x32.png create mode 100644 frontend/src/composables/useTimeTrackingFavicon.ts diff --git a/frontend/public/images/icons/favicon-tracking-32x32.png b/frontend/public/images/icons/favicon-tracking-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..d3867376f0a6999adc2c308a585d6b442df293dc GIT binary patch literal 2089 zcmV+^2-f$BP)T^# z1c{NFDj$d#5=l%zF$Rk~gXK{K5

S!CE?P>0=)EG3TDWmml_>>CAM>PEK;qx##Tl zU;qDlZDGk}!YINHi2!T1TUV>e`bM2=vy3bA3ZHf`f>A@jpowu9czdSJb0?Zw+ zOBw)-nz91}vBj3;8Fb*#MVd}=R`QQ5m6r-EwQA+{r-mQ&n# z3604HhyV5}&lkcUH(bD;J$Fg-Od#Y;#@F6q>)IvTP;M=ZRqM}!dv1k`*TU!{uA{7T+i3WP3Ye0ZB((JFaBGqwjjZYXj4fhFyC5v@MJ`)sO8vMN^_H~SLWodCC_}o;pqe8}R6BV>H?C;NKo=oK z5XaRXb`IZe*DVH-0L&WSYM&S39_QFdAXo$hH_(d=RNGLW#zLgghN6TJA(DW!+DVRD z7LlT0S*4VoE}TWMK#+_JMYw0h_w4dhAiyVGB#zu+>#XGGv&O|xQv`!r2Dz442#7{D zt%X_zhI_%gr2qRzp|2Zye1H%r@st4&RKN%X=h$c^cdz`GeS3U|e88xwX9u=syLE*b zw&A&oK*SJqPFkWRtwU70su^3iB;CEHFg8mdW=a@zMmgyjJ6_>mJJZN_t}fWwFgYSsxZfvE;e7mn4-`jA=|)$H`-oqJ{#CT9kD;k}IM zW@0E>Vgd*vI8(kkQSui=UeGn$tt$#Qi?WJHoS&K^>1L*cLUW{l2=R_+k!YNDg$PK7 z_Vgj@Fk6)ChFTtBB6y!O2>2= zf?+`Jg*8L-ygyZS1OrjTtsMaJhZ197ZY_=1{ol^M0g-p{@`t8eDg?=l1?rS9Ov&(Jz7^I`@TsTgeZYOEyU<`?Bf1W>C$<^?dSdCo-_ZlGGtu^2Fm#Apl+ z^wZPRi}z`xS}r(&_L(FwJg~}RbfM^J`p*IGcwp&n#qbcTMwS!Hf?{eMtc{_ZJu-;q zJt=VQ6nke}f=Z<3mukp!C{wTJ*c7x1pc_O4i-BVLC=(|>q}ga8A}k*+s1B?}eT6c> z_(Tdsm5&Fb1`Uk3_z&Y$r`vx7-aUNkzZe1%3nnI~dE)68Idu3KDv=mU1ej{Vzut#- znfk+$_Kj%JP&39&D3=^fEJgoE)9}O_so`jc6;$b|)fgHaU}VJ*5uJ;+7sue0eJS6U zWFYmz(u{Exog`6^4cJM1H+uNb`AXl%Nbm8 z7V0v_4#D1ii6*y{_lA*5d=upvA0khRa+|6RI|30+YM2qk&E;`B!2gb?;iKXqtJb(+ z<7Pg!{xhH+pH;yE?|=s!N=m!31(eiVH9}mXsLb=;B3R_hpSS_^h_lfgeZ!Ok%J-d$oRd|^bzFG zEqg+#enQdqP6fwg)Wmz{k4OrO*$q=6@2D)D}5EPs+!Hl=-iiz zSa+yL4Vb7<@$>cch|g2CnYa3DFw~R6BN{;z!Qo=b>n7ZF@D@%5 zqnx#FCIKtu)!s|ArC2T@_Em|1b!NN;cr7_#-4)`RL82&Tf(7ygmR}Z6rsGNSNbSda& zIefpoxa85yMp1e8rKbqR4Vdi(X<$sm5FYojV+g-Gv^t=^_KtW zA-m|rwEe)$9t*+7%xuccLg$)l7p<`{(|D{biy!VkY@faUKFd2h3Zr(0$^`!hhMMx4 T8oe2T00000NkvXXu0mjfxNY?I literal 0 HcmV?d00001 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 08688b1bb..760c18edc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -61,6 +61,7 @@ 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' @@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => { setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE) useColorScheme() +useTimeTrackingFavicon()