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.
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#2844Fixes#2725
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 = <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
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.