The hardcoded `static final String IMPORT_DIR = "/import"` was the only
non-`@Value` configurable input in MassImportService — every column
index next to it is wired through `app.import.col.*`. Lifts the
contract from infrastructure (compose bind mount) into application
config (`app.import.dir`), with `/import` as the default so the existing
bind-mount path keeps working.
Addresses review feedback from Markus and Felix on #526.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- frontend/login: derive cookie `secure` flag from request URL protocol.
Pre-PR the cookie was only read by SSR so the flag didn't matter; now
the cookie IS the API credential and must be Secure on HTTPS or it
leaks a 24h Basic token on plaintext networks. Dev runs over HTTP and
would silently lose the cookie if we hardcoded `secure: true`, so the
flag follows `event.url.protocol === 'https:'`.
- SecurityConfig: rewrite the CSRF-disabled comment. The old
"browsers block cross-origin custom headers" justification no longer
holds once /api/* is authenticated via the cookie. Make the
load-bearing dependencies explicit: SameSite=strict on the auth_token
cookie + Spring's default CORS rejection.
- AuthTokenCookieFilter:
- Scope to /api/* only. /actuator/health and similar must not be
cookie-authenticated.
- Refuse malformed percent-encoding (URLDecoder throws); forward the
request without a promoted Authorization rather than crash.
- Use isBlank() instead of isEmpty() per Nora.
- Javadoc warning: getHeaderNames/getHeaders exposes the Basic
credential; any future header-iterating logger must scrub
Authorization before logging.
- Tests: add `passes_through_unchanged_when_request_is_outside_api_scope`
(/actuator/health with cookie should NOT be wrapped) and
`passes_through_unchanged_when_cookie_value_is_malformed_percent_encoding`.
Tighten the explicit-header test to verify same-instance forwarding
rather than just header equality.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#520.
The login action stores `Basic <base64>` in an HttpOnly `auth_token`
cookie. SSR fetches from hooks.server.ts explicitly set the
Authorization header. Vite's dev proxy does the same on every
/api/* request. Caddy in production does NOT. So browser-side
fetch() and EventSource() calls reach the backend without auth,
get 401 + WWW-Authenticate: Basic, and the browser pops a native
auth dialog over the SPA.
Add AuthTokenCookieFilter (Ordered.HIGHEST_PRECEDENCE, before any
Spring Security filter) that promotes the cookie to a request
header when no explicit Authorization is present. URL-decodes the
cookie value because SvelteKit URL-encodes spaces ("Basic " ->
"Basic%20") when serializing the cookie. Works the same for REST,
SSE (/api/notifications/stream, /api/ocr/jobs/.../progress), and
any other browser-direct backend call.
5 tests in AuthTokenCookieFilterTest cover: URL-decoded promotion,
explicit-Authorization-wins precedence, no-cookies pass-through,
absent-auth-token pass-through, empty-value pass-through.
Also: add `@ActiveProfiles("test")` to ThumbnailServiceIntegrationTest,
the one remaining @SpringBootTest in the suite that wasn't annotated.
After #516 made UserDataInitializer fail-closed outside dev/test/e2e,
this test's context load was throwing. Restores green main.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#518.
UserDataInitializer.initAdminUser was doing groupRepository.save(adminGroup)
unconditionally. If a previous boot had seeded the group but failed
before creating the admin user (or if the operator deleted just the
admin row to retry with a corrected APP_ADMIN_USERNAME), the next
seed attempt violated user_groups_name_key and aborted the context.
Switch to the same findByName(...).orElseGet(...) pattern initE2EData
already uses for the "Leser" group.
Tests in AdminSeedFailClosedTest:
- reuses_existing_Administrators_group_when_seeding_a_new_admin
- creates_Administrators_group_when_seeding_admin_on_a_fresh_database
Plus updated existing tests to stub groupRepository.save now that the
seed path also exercises it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses Nora's review concern on #513/#516.
The previous fix only made env-vars take effect — it did NOT close the
fail-open default path. If an operator forgets APP_ADMIN_USERNAME /
APP_ADMIN_PASSWORD on first prod boot, the seeded admin is the
well-known `admin@familienarchiv.local` / `admin123` and is permanently
locked (UserDataInitializer only seeds when the row is missing).
Refuse to seed outside dev/test/e2e profiles when either credential
matches the documented default. The startup fails fast with a clear
message pointing at the env-var names and the permanence trap.
Also adds Markus/Felix/Sara's "pin the Java side" coverage: a
reflection test on the @Value placeholder catches a future rename
of `${app.admin.email:...}` back to `${app.admin.username:...}`,
which would otherwise pass the yaml-side test but silently break
the binding.
Tests:
- AdminSeedFailClosedTest pins fail-closed for non-local profiles
and verifies the dev/test/e2e bypass.
- AdminSeedPropertyKeyTest now also asserts the @Value placeholder
string on UserDataInitializer.adminEmail/adminPassword.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes#513.
UserDataInitializer reads `@Value("${app.admin.email:...}")` but
application.yaml mapped APP_ADMIN_USERNAME to `app.admin.username`.
The keys never connected — env vars APP_ADMIN_USERNAME and
APP_ADMIN_PASSWORD were silently ignored and the admin user got
seeded with the hardcoded defaults admin@familyarchive.local /
admin123.
For production this is HIGH severity: DEPLOYMENT.md §3.5 documents
the admin password as permanently locked on first deploy. The
bug locked the lock-in to dev defaults, not to whatever an operator
set in PROD_APP_ADMIN_PASSWORD.
Rename yaml key from `username:` to `email:` so the Spring property
`app.admin.email` actually exists. Keep env-var name
APP_ADMIN_USERNAME (matches the already-set Gitea secrets and
DEPLOYMENT.md §3.3). Default value updated to an email-shape.
Added AdminSeedPropertyKeyTest (Binder pattern, no Spring context):
verifies both `app.admin.email` and `app.admin.password` resolve
from the yaml. Confirmed red without the fix, green with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops @SpringBootTest + PostgresContainerConfig + @MockitoBean S3Client in
favour of Spring's Binder API against application.yaml. The new test binds
the property into the typed ServerProperties.ForwardHeadersStrategy enum,
so typos (`nativ`, `Native`, `framework `) and future enum renames fail
the build with BindException — addresses the silent-coercion concern that
the YAML-string assertion missed.
Verified the test goes red on a typo (BindException: Failed to convert
"nativ" → ForwardHeadersStrategy) and green on `native`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds server.forward-headers-strategy: native so that Jetty honours
X-Forwarded-{Proto,For,Host} from Caddy. Without this, getScheme(),
redirect URLs, and Spring Session "Secure" cookies reflect the
internal http hop instead of the original https client request.
Refs #497.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- searchDocuments_relevance_returns_empty_when_offset_exceeds_maxInt:
proves the long→int guard fires and findFtsPageRaw is never called
- searchDocuments_relevance_handles_string_uuid_from_jdbc_driver:
exercises the toFtsPage String fallback branch for JDBC drivers that
return UUID columns as String instead of java.util.UUID
Addresses Sara's review concerns on PR #488.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract isPureTextRelevance() private static method to replace the
7-clause inline boolean in searchDocuments
- Guard long→int cast in relevanceSortedPageFromSql to prevent silent
overflow at page ≥43M (CWE-190)
- resolvePersonName now uses the typed API client (createApiClient)
instead of raw fetch, aligning with project conventions
- Update DocumentServiceTest stubs to match new FTS path (findFtsPageRaw
+ findAllById instead of findAllMatchingIdsByFts)
- Rewrite page.server.spec.ts person-name tests to mock via path-based
API dispatch, matching the new api.GET call site
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentFtsPagedIntegrationTest: Testcontainers repo-level tests for
findFtsPageRaw (page size, window total, last page, no matches, stopword)
- DocumentServiceSortTest: rewritten to stub findFtsPageRaw + findAllById
for the pure-text RELEVANCE path; verifies filter-active path stays in-memory
- DocumentServiceTest: update two enrichment tests to use new SQL-path stubs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure-text RELEVANCE queries now use findFtsPageRaw (CTE + COUNT(*) OVER())
instead of loading all matching IDs into memory and sorting in-process.
Non-text paths (filters active, DATE sort) still use the in-memory path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Flyway V62 adds idx_documents_sender_id and idx_comments_author_id to speed up
FK-driven queries on the persons page and briefwechsel view. Closes#470.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Math.abs(Integer.MIN_VALUE) overflows back to Integer.MIN_VALUE (negative),
making the old pattern unsafe for any palette size that doesn't evenly divide
MIN_VALUE. Math.floorMod always returns a non-negative residue in [0, n-1],
eliminating the overflow edge case entirely.
Fixes SpotBugs RV_ABSOLUTE_VALUE_OF_HASHCODE (priority 1, CORRECTNESS).
Closes#471
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getBlockComments was missing documentId; replyToBlockComment was missing
blockId. Spring silently ignored undeclared path variables — the segments
were parsed but never bound. Now both parameters are explicitly declared so
Spring rejects non-UUID values with 400.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Null dto.permissions now produces an empty HashSet instead of propagating null
into the @ElementCollection — prevents a silent NPE after V64 adds NOT NULL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V63 deduplicates any phantom (group_id, permission) rows accumulated since
the initial schema. V64 sets NOT NULL on permission and adds pk_group_permissions.
V65 renames uq_tbmp_block_person to pk_tbmp for naming-convention consistency.
Integration tests confirm each constraint via pg_catalog.pg_constraint. Closes#469 (partial).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @Nora review: ?sort=documentCount&size=999999 could trigger a
full-table query and large serialization. Cap enforced at controller boundary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @Elicit review concern: stories stat tile was permanently showing
"—" because StatsDTO had no published-story count. Now wired end-to-end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PersonController GET /api/persons?sort=documentCount&size=N returns the top N
persons by combined sender+receiver document count for the reader dashboard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GeschichteService.list() now applies hasAuthor(currentUser()) whenever
status == DRAFT, so BLOG_WRITE users cannot read other users' unpublished stories.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces @DirtiesContext(AFTER_EACH_TEST_METHOD), which restarted
the full Spring context per test (≈10–15s × 7), with @Transactional
rollback. Each test still sees a clean slate via the spring-test
default rollback, but the context is shared across the class.
Wall time for this class dropped from 35s to 17.87s in local runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The empty-result case returns null for both bounds, which the TS
codegen surfaces as optional. Future contributors should not "fix"
the missing @Schema(REQUIRED) — it is deliberate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
YearMonth.from(d).toString() emits the same canonical YYYY-MM string
as the previous String.format("%04d-%02d", …) call but reads as a
single intent-revealing expression. Existing assertions on
"1915-08", "1916-01", … pin the output format unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The index was added in anticipation of a SQL GROUP BY aggregation,
but DocumentService.getDensity aggregates in memory via
findAll(spec).stream(). The index is never touched by the current
query plan. Per Markus's round-2 review: drop the unused migration
to avoid mismatched rationale-vs-implementation debt. Revisit when
the archive crosses 50k rows (TODO already in getDensity Javadoc).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the in-memory aggregation trade-off in getDensity so the next
perf audit knows the row-count threshold at which to revisit. Addresses
Markus's review concern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Density bars now recompute when other filters change so the chart always
matches the list it sits above. Selectable filters: q, senderId, receiverId,
tag (multi), tagQ, status, tagOp. Date bounds (from/to) are deliberately
omitted — the chart is the surface for picking those, so it must always
span the broader space the user is selecting within.
Architectural shift: drop the native SQL GROUP BY in favour of in-memory
grouping over the existing Specification-driven findAll. This composes for
free with all the search predicates (FTS-rank-then-filter, sender/receiver,
tag-with-descendants, tagQ partial match, status, tagOp) and keeps the
density implementation a thin layer on top of searchDocuments. At the
current archive size (~5k docs) this stays well under the p95 200ms target;
Cache-Control: max-age=300 absorbs repeated browse loads.
- Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection.
- Replaces DocumentService.getDensity(LocalDate, LocalDate) with the
filter-aware overload.
- Endpoint accepts the same query params as /api/documents/search minus
paging+sort+from+to.
- DocumentDensityIntegrationTest rewritten as @SpringBootTest covering
no-filter / sender / tag / status / sender+tag combos via real PostgreSQL.
- DocumentServiceTest unit tests updated to the new signature.
- DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Maps the repository's Object[] rows into a DocumentDensityResult and pairs
them with the archive-wide min/max meta_date range. Read-only, no
@Transactional needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Response shape for the upcoming GET /api/documents/density endpoint.
minDate and maxDate are nullable (null on empty archive); buckets is always
present.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #385 introduces GET /api/documents/density which aggregates documents
by month via date_trunc. Adding the index now keeps the query cheap as the
archive grows and removes a future-investigation tax.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CommentData.java: add @Nullable on annotationId to match codebase convention
- DashboardService: isEmpty() → isBlank() for commentPreview null-guard
- ChronikRow.svelte: always set aria-label on comment rows (not only when preview present)
- ChronikRow.svelte.spec.ts: add test for aria-label on comment row without preview
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the nested `CommentData` record out of `CommentService` into its own
`document/comment/CommentData.java` file, removing the cross-domain coupling
where `DashboardService` depended on an inner type of `CommentService`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove `findAnnotationIdsByIds` from CommentService — no production caller exists now
that DashboardService uses `findDataByIds` directly; along with its test coverage
- Fix aria-label construction in ChronikRow: pass actorName to i18n message function
instead of manually prepending the actor, so all locales render correctly
- Rename `findDataByIds_does_not_truncate_at_exactly_120_chars` →
`findDataByIds_preserves_content_at_exactly_120_chars` for accurate description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ActivityFeedItemDTO gains a nullable commentPreview field (plain-text, 120 chars max).
DashboardService.getActivity() now calls findDataByIds() once instead of
findAnnotationIdsByIds(), halving DB round-trips for the Chronik page load.
Empty-string previews are normalised to null so the frontend can use ?? cleanly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single-purpose findAnnotationIdsByIds() (kept as delegation shim).
Introduces CommentData record (annotationId + preview) and stripAndTruncate()
using Jsoup.parse().text() for DOM-safe HTML stripping. Truncates to 120 chars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLEANUP-2 (#413): convert two actionable TODOs to issue-referenced stubs
- +layout.server.ts:29 → TODO(#453) for dedicated admin stats endpoint
- ChronikRow.svelte: TODO(#454) for commentPreview; keep SECURITY line
as standalone comment (XSS guard stays co-located with the risk)
CLEANUP-3 (#414): add one-line justification comments to both naming
violators — SecurityUtils and GlobalExceptionHandler are both justified
by framework convention; no rename needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docs/README.md: remove duplicate infrastructure/ entry at end of folder tree
- ocr-service/CLAUDE.md: add **LLM reminder:** prefix to ALLOWED_PDF_HOSTS
SSRF warning (consistent with all other machine-readable instructions)
- backend/CLAUDE.md: restore ResponseStatusException note for simple controller
validation — avoids LLMs reaching for DomainException for trivial checks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- person/README.md: findAll(String q) and findByName(String firstName, String lastName)
- notification/README.md: replace 'None inbound' with actual outbound dep on DocumentService.findTitlesByIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>