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.
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.
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
- 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.
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.