mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-07 15:42:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e3c74673 | ||
|
|
4a447c7aef | ||
|
|
79df032f10 | ||
|
|
97966054ac | ||
|
|
e787e4488e | ||
|
|
1256f09c8f | ||
|
|
5eb1d9e7bd | ||
|
|
4544edde6f | ||
|
|
b89ceb4e00 | ||
|
|
61d9bcc328 | ||
|
|
8f770371fb | ||
|
|
8cbe5ce262 | ||
|
|
9b77cca771 | ||
|
|
82ae5e289d | ||
|
|
642b6020c3 | ||
|
|
09b2acf4fe | ||
|
|
51924229da | ||
|
|
64979ef66a | ||
|
|
ef620c9cb5 | ||
|
|
332b5711ff | ||
|
|
9bf55a16f3 | ||
|
|
2c7e5361cc | ||
|
|
98de13aaa0 | ||
|
|
1558b0dc6b | ||
|
|
ce82aa5f3b | ||
|
|
43dda9c962 | ||
|
|
69b08116cf | ||
|
|
419fa4cda0 | ||
|
|
68ab7924f2 | ||
|
|
d5f9da6777 | ||
|
|
0ef5d08fec | ||
|
|
7b7a589062 | ||
|
|
0cf851475f | ||
|
|
db09620a58 | ||
|
|
374fa35fbd | ||
|
|
bb6ad7d5d2 | ||
|
|
7fee10478d | ||
|
|
f990e1c5de | ||
|
|
1565552141 | ||
|
|
568db606da | ||
|
|
24e7ea1994 | ||
|
|
e0f7eb4913 | ||
|
|
e2468f6d39 | ||
|
|
e596887a44 | ||
|
|
473414744d | ||
|
|
0269b97efd | ||
|
|
cd6dfc6955 | ||
|
|
f595af581a | ||
|
|
b803f6291d | ||
|
|
1b13b093ba | ||
|
|
c1734501ae | ||
|
|
b444c1ad39 | ||
|
|
04dc7f0e88 | ||
|
|
1c78c043bd | ||
|
|
cd93a470b8 | ||
|
|
b39d1f68e8 | ||
|
|
d013c8b8fc | ||
|
|
3dfc8625e9 | ||
|
|
1a65f28b21 | ||
|
|
cc59b7190f | ||
|
|
ba6f63ff09 | ||
|
|
4e9c0171dd | ||
|
|
1fae061a2d | ||
|
|
e39c33f977 | ||
|
|
cdb75655dd | ||
|
|
c47f0ee459 | ||
|
|
8c2c740efe | ||
|
|
0762602cdb | ||
|
|
51ac0de755 | ||
|
|
983c50159d | ||
|
|
f8a0826dfb | ||
|
|
4375fb6ba3 | ||
|
|
379102d3fe | ||
|
|
debe51c313 | ||
|
|
dbc811d722 | ||
|
|
7b08996b09 | ||
|
|
fcfb7d1494 | ||
|
|
c45bcb0463 | ||
|
|
2b81a2a2d8 | ||
|
|
e2199b0445 | ||
|
|
3327a344ff | ||
|
|
8ef87c7163 | ||
|
|
154f8219c5 | ||
|
|
0f98b8b507 | ||
|
|
a11b71a1f4 | ||
|
|
1925624e2a | ||
|
|
5b9e4e2685 | ||
|
|
a8f37b33d2 | ||
|
|
048ce96f9d | ||
|
|
291dde8073 | ||
|
|
ec4b0a1428 | ||
|
|
d7eac40cff | ||
|
|
53737b0718 | ||
|
|
c78a102d08 | ||
|
|
14f62aee90 | ||
|
|
d5d446bc32 | ||
|
|
c712ca8a15 | ||
|
|
16e765c231 | ||
|
|
b978b96864 | ||
|
|
0132ecb342 | ||
|
|
b866b36ca9 | ||
|
|
e1b25265dd | ||
|
|
a0afcfeafe | ||
|
|
2aab008766 | ||
|
|
dbc6338f9b | ||
|
|
913de99f9e | ||
|
|
0b429e78a9 | ||
|
|
00b880708d | ||
|
|
3a18ac9eab | ||
|
|
a2a2a81a12 | ||
|
|
59d3c89b1c | ||
|
|
42e4ed63bb | ||
|
|
b9f301619b | ||
|
|
20aded7ee0 | ||
|
|
3aad835908 | ||
|
|
1af0e6842e | ||
|
|
cc01059c47 | ||
|
|
ef503c570d | ||
|
|
f1b4ad8db2 | ||
|
|
856a80d111 | ||
|
|
3bfacc5b1a | ||
|
|
124ceb1c3e | ||
|
|
00aff350f1 | ||
|
|
dcc5fe74f7 | ||
|
|
a6b37bca22 | ||
|
|
bb6ec09fd1 | ||
|
|
0f06f5a9f0 | ||
|
|
bc5ab7899e | ||
|
|
01ed08d26f | ||
|
|
5b21021aca | ||
|
|
b6eaa6da78 | ||
|
|
cb1dfb39a6 | ||
|
|
8875f2baf0 | ||
|
|
fdc4254cdd | ||
|
|
3c4eef1c92 | ||
|
|
52a3ccd466 | ||
|
|
b753e91fb4 | ||
|
|
f49875f821 | ||
|
|
cd844f12a7 | ||
|
|
1f7ed7d96f | ||
|
|
c530875775 | ||
|
|
7ed0117890 | ||
|
|
4829fd77f4 | ||
|
|
f034adf761 | ||
|
|
ba6e4cbdf2 | ||
|
|
54535cb596 | ||
|
|
1d04f9ac3d | ||
|
|
2f60df2ed9 | ||
|
|
82c895d10a | ||
|
|
73be6a7cfd | ||
|
|
5ebfa8337d | ||
|
|
6e42a1f12d | ||
|
|
0c74adb012 | ||
|
|
0feb6c55ab | ||
|
|
ddc2231a8a | ||
|
|
eebb8f2cb1 | ||
|
|
d89da4c7f8 | ||
|
|
01aeb69aff | ||
|
|
da680bceea | ||
|
|
c207d25b60 | ||
|
|
b9faa2a4b4 | ||
|
|
83e8282d94 | ||
|
|
b61ce5c8ad | ||
|
|
f192665f17 | ||
|
|
3814a192f4 | ||
|
|
7c9ea3c95e | ||
|
|
f9fefd83e1 | ||
|
|
d0ab96d634 | ||
|
|
bff3f54b00 | ||
|
|
aa50123e9f | ||
|
|
9221f8b63f | ||
|
|
07d69896ae | ||
|
|
ac9c33d43f | ||
|
|
5be134e0e6 | ||
|
|
befe8fe19a | ||
|
|
230ec2ae05 | ||
|
|
3c9031e6b2 | ||
|
|
387029f4e7 | ||
|
|
c35d13885a | ||
|
|
ccd9cd0936 | ||
|
|
07b10559e6 | ||
|
|
6030b1ff6f | ||
|
|
aa22ff1422 | ||
|
|
2345dd5505 | ||
|
|
8fe2f53b34 | ||
|
|
4817cea93b | ||
|
|
93a5e41794 | ||
|
|
e91c3206f2 | ||
|
|
fb176c0cc6 | ||
|
|
d579c4a34b | ||
|
|
4ff34705c1 | ||
|
|
09c1d2337d | ||
|
|
2e5db68969 | ||
|
|
e9d57e5954 | ||
|
|
64821c9c99 | ||
|
|
03f172b59b | ||
|
|
831aa4c0ac | ||
|
|
4796d921a2 | ||
|
|
5a7cb03a01 | ||
|
|
dd0eb93965 | ||
|
|
754fd62689 | ||
|
|
06d0404903 | ||
|
|
bf1d1c1a3e | ||
|
|
01b6f47b1f | ||
|
|
f65273e95a | ||
|
|
8c684847ca | ||
|
|
bfda31f2b4 | ||
|
|
af9f81bbc2 | ||
|
|
e064ee5040 | ||
|
|
0a0668410e | ||
|
|
bfa8d2e01a | ||
|
|
0ff143f2c1 | ||
|
|
1d57c64f70 | ||
|
|
e6bb549be5 | ||
|
|
f07903c0be | ||
|
|
0fb4aeec0d | ||
|
|
25b33ca056 | ||
|
|
6bb0a832d2 | ||
|
|
65a1333d56 | ||
|
|
d55ea6ce4a | ||
|
|
fb791b6752 | ||
|
|
ae77105cd0 |
No files matched your search
@@ -40,6 +40,20 @@ CORS_ORIGIN=http://localhost
|
||||
# de passe. Une instance sans aucun compte accepte quand même le premier (le vôtre) :
|
||||
# créez-le juste après le premier démarrage, avant de donner l'adresse.
|
||||
# ALLOW_REGISTRATION=true
|
||||
# À true, l'app invite à créer son propre arbre : « Faire le mien » dans le menu Ajouter de la
|
||||
# famille, l'inscription proposée dans les messages de la démo, un message d'invitation pour Noël
|
||||
# (du 1er décembre au 6 janvier). Éteint par défaut, allumé sur app.parenthese.io.
|
||||
# SIGNUP_PROMPTS=false
|
||||
|
||||
# ── Chercher quelqu'un dans des sources publiques ────────────────────────────
|
||||
# Sources que le serveur interroge pour le panneau « Quelqu'un ailleurs » (menu +) :
|
||||
# deces (décès en France depuis 1970, fichier de l'INSEE par matchID), wikitree.
|
||||
# Vide : aucun nom ne quitte l'instance, le panneau ne propose que des liens ouverts
|
||||
# par le navigateur. Seuls le prénom, le nom et l'année de naissance sont envoyés.
|
||||
# SEARCH_SOURCES=deces,wikitree
|
||||
# Clé gratuite de deces.matchid.io (valable 30 jours) : sans elle, les recherches
|
||||
# anonymes s'arrêtent après une centaine.
|
||||
# MATCHID_TOKEN=
|
||||
|
||||
# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ──
|
||||
# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback.
|
||||
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Schéma Prisma aligné sur les migrations
|
||||
run: npx prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code
|
||||
run: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -63,16 +63,21 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
persist-credentials: false
|
||||
# Tout l'historique et les tags, pour lire la version (git describe)
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Short commit
|
||||
# Version lisible dans le label OCI (#207) : 1.5.9 sur un tag, 1.5.9-11-gd5f9da6 sur main après la 1.5.9
|
||||
- name: Short commit and version
|
||||
id: commit
|
||||
run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(git describe --tags --match 'v*' | sed 's/^v//')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -89,6 +94,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'workflow_run' }}
|
||||
type=raw,value=sha-${{ steps.commit.outputs.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.commit.outputs.version }}
|
||||
org.opencontainers.image.revision=${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
|
||||
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
# Tant que les paquets ne sont pas publics, le pull demande une connexion.
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+299
@@ -2,6 +2,305 @@
|
||||
|
||||
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
|
||||
|
||||
## 1.6.0 (2026-11-04)
|
||||
|
||||
### Adding people
|
||||
|
||||
- Several first names in the First name field, separated by commas, add several children or siblings at once. "Paul, Marie, Jean" creates three people with the same links, last name and dates. Empty names and duplicates are skipped, up to 20 names. For a parent or a partner the field still takes one name. On a phone no profile opens after a group add, and the view frames the whole family (#203).
|
||||
|
||||
### Life tab
|
||||
|
||||
- Photos of a marriage show for both partners. The marriage was already in both Life tabs, but its photos only showed on the profile they were added from. A partner's photo opens in their name (#209).
|
||||
|
||||
### Words
|
||||
|
||||
- English relationship names go as far as the tree does. Great-great-grandfather, great-great-uncle, grandnephew, second cousin, first cousin twice removed, 4th cousin. "Partner" replaces "Spouse" everywhere, on the profile and the tree too, and "Nibling" is the neutral form of nephew and niece. Thanks to Arc676 (#204).
|
||||
|
||||
### Photos
|
||||
|
||||
- Portrait thumbnails follow the saved crop everywhere, not only on the tree and the profile. That covers the viewer, "In this photo", search, the profile links, adding a relative, the account and memory picking (#205).
|
||||
|
||||
### Shared links
|
||||
|
||||
- A link to the app pasted in WhatsApp, Messenger or iMessage shows a title, a sentence and an image. They are the same for every address, so no name or photo from a tree reaches the preview bots (#201).
|
||||
- A link with `?source=` and no session opens the demo again, not the welcome screen (#211).
|
||||
|
||||
### Hosted instance
|
||||
|
||||
- New `SIGNUP_PROMPTS` setting for the API, off by default. When it's on and registration is open, the app invites people to make their own tree in places that already exist: the Add menu of a shared tree, the demo messages, a "Create my tree" button in the demo bar, and a Christmas wording in the family invitation from December 1 to January 6. A self-hosted instance sees none of this unless it turns it on (#202, #212).
|
||||
- In the demo, the dialog that closes editing no longer says changes stay on this device. The demo saves nothing and the sentence now says so (#202).
|
||||
|
||||
### Docker
|
||||
|
||||
- `org.opencontainers.image.version` carries the real version: `1.6.0` on a release tag, and for example `1.5.9-11-gd5f9da6` on `latest` between releases. Container watchers like What's Up Docker can show it instead of a digest (#207).
|
||||
|
||||
## 1.5.9 (2026-10-29)
|
||||
|
||||
### Tree
|
||||
|
||||
- A child added to someone who has a single partner becomes the couple's child, next to their brothers and sisters, instead of hanging under one parent and an "Unknown" one. With no partner, several partners, or an adoption, the child stays with the parent chosen (#200).
|
||||
|
||||
### Phone
|
||||
|
||||
- No more zoom slider: it opened by itself and covered a corner of the tree. Pinch to zoom, the magnifier zooms in one step. On a computer the slider still shows on hover (#199).
|
||||
|
||||
### Profile
|
||||
|
||||
- "– 1995" no longer breaks after the dash at the end of a line. The magnifier button is labelled "Zoom in" for screen readers.
|
||||
|
||||
## 1.5.8 (2026-10-29)
|
||||
|
||||
### Family tabs
|
||||
|
||||
- A tab is named after the surname most of its members carry (birth name, otherwise current name), not after the person it starts from. When two tabs would share a name, the smaller one takes its next most common surname (#192).
|
||||
- Switching tabs keeps the person who joins the two families at the same place and zoom, only the families around change. Hovering a tab lights that person up in the tree (#192).
|
||||
- The tab bar follows the shape of the tree, grouped by branch, with a line between a tab and the one it comes from. The way-back badge sits where the way-in badge was (#192).
|
||||
|
||||
### Profile
|
||||
|
||||
- The age counts the birthday: with a full birth date, a person born in December is not a year older from January. Same for the age at death and the ages in the Life tab. With only a year, the age stays the difference between the years (#198).
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@node-rs/bcrypt` 2.0: the cost is passed as an option. Stored passwords still verify, new hashes are `$2b$` with the same cost (#197).
|
||||
- Minor updates for the frontend, the landing, the backend and the CI (#193, #194, #195, #196).
|
||||
|
||||
## 1.5.7 (2026-10-28)
|
||||
|
||||
### Phone
|
||||
|
||||
- Opening a tree no longer opens the start person's profile, which covered the bottom of the screen and the start person with it. The start person now sits at 40% of the height instead of 56% (#189, closes #188).
|
||||
- Tapping someone in the tree moves them into the top third, above their profile (#189).
|
||||
|
||||
### Life tab
|
||||
|
||||
- A couple reads "Together with X", and "Married X" only when it is said: a date, a place, marriage documents, or the box ticked. Before, every couple read "Marries X", because a couple is stored as a marriage as soon as a child has two parents (#190).
|
||||
- The date and place of a couple can be set from the Life tab: tap the couple's line, then "They were married", "When?" (year, "about", exact date) and "Where?". `PATCH /trees/:id/unions/:unionId` accepts `startPlace` and `startDatePrecision` (#190).
|
||||
- The names of a partner, a child, a brother or sister, or a parent open that person's profile and move the tree to them (#190).
|
||||
|
||||
## 1.5.6 (2026-10-28)
|
||||
|
||||
### Tree
|
||||
|
||||
- Optional last name under the first name: the maiden name when it is known, otherwise the current last name. In the tree settings, under Display. Saved in this browser (#186, closes #168).
|
||||
- Opening a tree: the camera now stops close enough to read the names, and on a phone the tree is centred in the space beside the zoom slider (#187).
|
||||
|
||||
### Undo bar
|
||||
|
||||
- The message agrees with the person's sex in French ("supprimée", "supprimé"), and "supprimé·e" only when it is unknown (#185).
|
||||
- On phones and tablets the bar no longer covers the zoom slider. On a computer the zoom button is hidden while a profile is open (#185).
|
||||
|
||||
### Shortcuts
|
||||
|
||||
- Ctrl+K (⌘K on Mac) opens the person search from anywhere. The search tooltip and the Edit menu show their shortcuts (#184).
|
||||
|
||||
## 1.5.5 (2026-10-27)
|
||||
|
||||
### Undo instead of confirm
|
||||
|
||||
- Deleting a person no longer asks for confirmation. A bar with "Undo" stays for 6 seconds, and Ctrl+Z works too (#169).
|
||||
- Same for a memory and for a moment in the Life tab. Two fixes to #169 on the way: an open profile no longer closes when the deletion starts, and the tree's internal lists are up to date after an undo (#172).
|
||||
|
||||
### Motion
|
||||
|
||||
- Profile card: it appears at once on a computer, with no fade or blur. On a phone it still rises as a sheet in 240 ms, resists when pulled up, and has continuous corners where the browser supports `corner-shape` (#170).
|
||||
- A memory grows from its thumbnail when it opens (280 ms, transform only). The viewer now respects reduced motion (#171).
|
||||
- On a phone, dragging a memory's photo down closes it, like the profile card (#176).
|
||||
- Opening a tree for the first time, the camera starts on the start person and pulls back to the family in 1.1 s while the tree draws. After an edit or a tab change, the tree shows without animation. With reduced motion, a plain fade (#181).
|
||||
|
||||
### Faces on photos
|
||||
|
||||
- Face markers are hidden by default. They show on hover with a mouse, when a name is hovered in the list, in tagging mode, or after a tap on the photo. The circles are thinner (#173).
|
||||
|
||||
### Phone
|
||||
|
||||
- Edit mode: the family tabs move to the top so the "You suggest" bar no longer covers them. The exit button reads "Done", at the height of the "+", and the bar's own close button is gone (#175).
|
||||
- The family bar only moves aside when the profile card reaches it (#180).
|
||||
- Tap targets of at least 44 px on touch screens, with no visible change (#182).
|
||||
|
||||
### Loading
|
||||
|
||||
- Big trees open faster with the same placement, and the list of family tabs is computed in a worker. A 3,010-person tree is usable in 1.6 s instead of 8.7 s. Tools in `tools/layout-bench` (`layout-time.mjs`, `load-probe.mjs`) (#179).
|
||||
- Blurred previews (BlurHash) of photos and portraits while they load. Migration `20261027_001_apercus_flous` adds two optional columns, and a background task fills them for existing photos when the API starts (#177).
|
||||
- Grey blocks while loading, instead of "Loading…" or a false empty state: the Life tab of a person with no known date, and the tree's panels (#178).
|
||||
|
||||
### Reliability
|
||||
|
||||
- Uploads from the family no longer create duplicates on a weak connection, through an `Idempotency-Key` header (#174).
|
||||
|
||||
### Landing
|
||||
|
||||
- Pages are prefetched when a link is hovered (`speculationrules`). The landing CSP in `deploy/nginx.conf` gets a second script hash for it (#183).
|
||||
|
||||
## 1.5.4 (2026-10-26)
|
||||
|
||||
### A link per family tab (#167)
|
||||
|
||||
- `?famille=<hinge person id>` opens the tree on that family's tab. The tab opens once the tree and its tabs are known, and again if the start person arrives later (as it does for a visitor), unless the person already picked a tab. With tabs turned off in that browser, the whole tree opens framed on the hinge person. An unknown key is ignored.
|
||||
- The page address follows the open tab. In the share panel, the link to send and the invitation message follow it too, and the title names the family ("The link to send to the Garnier family").
|
||||
- Same password and same access: the link picks the landing tab and restricts nothing.
|
||||
|
||||
## 1.5.3 (2026-10-26)
|
||||
|
||||
### Videos in Safari (#162)
|
||||
|
||||
- Media files are streamed and answer byte-range requests (`Accept-Ranges: bytes`, `206` with `Content-Range`, `416` outside the file). Safari on iOS, iPadOS and macOS needs this to play a video. Before, the whole file was read into memory and sent in one block.
|
||||
|
||||
### Accounts
|
||||
|
||||
- A password account on an OIDC instance can link its provider identity from its account ("Also sign in with …"), through a two-minute, single-use ticket (`POST /auth/oidc/link`, then `/auth/oidc/start?link=`). The identity joins that account whatever its email, and an identity already linked to another account is never moved. This is the way to switch an instance to `PASSWORD_LOGIN=false` without locking out password accounts. `/auth/me` returns `oidcLinked` (#161).
|
||||
- Signing out, or deleting the account, now survives an API restart: closed sessions are kept in the `revoked_sessions` table until their token expires, and the API refuses to start if it can't load them. Migration `20261026_001_deconnexions` (#163).
|
||||
|
||||
### Security
|
||||
|
||||
- A media waiting for review is readable only by a tree admin or by the account that uploaded it. Anyone else gets the same 404 as a missing media (#160).
|
||||
- Passwords are hashed with native bcrypt (`@node-rs/bcrypt`) off the event loop. Existing hashes keep working, no migration (#160).
|
||||
- Frontend lockfile: zod 4.6.7, a dev dependency (#166).
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Prisma 7 with the pg driver adapter. The client is generated in `backend/src/generated` and `backend/prisma.config.ts` holds the database URL for the CLI. Without Docker, Node must be ^20.19, ^22.12 or 24+, and `deploy/deploy.sh` stops early on an older Node (#164).
|
||||
- Tailwind 4 for the app and the landing, through the Vite plugin. The v3 base styles the app relied on are kept, and nothing changes on screen (#165).
|
||||
|
||||
## 1.5.2 (2026-10-26)
|
||||
|
||||
### Dates follow the browser's region (#152)
|
||||
|
||||
- Dates follow the browser's regional variant of the interface language (en-US, en-AU, fr-CA…), falling back to fr-FR or en-GB. Before, English always used the British format. This covers every date and time shown in the app, e.g. `10/26/2026, 10:57 AM` in the review panel on an en-US browser.
|
||||
- Typed dates follow the region's order and separator: `06/12/1924` in en-US, `1924-06-12` in fr-CA, `12/06/1924` in France and the UK. A year-first date is read everywhere.
|
||||
- The date field also accepts a written month, in French or English: `12 juin 1924`, `1er août 1950`, `June 12, 1924`, `3 sept. 1901`. An ambiguous month (`jui`) is refused.
|
||||
|
||||
### Names in non-Latin scripts
|
||||
|
||||
- Initials for scripts without capital letters (Chinese, Japanese, Korean, Arabic, Hebrew…) are a single character: the family name for Chinese, Japanese and Korean, the first name for Arabic and Hebrew. Latin and Cyrillic keep two initials. This applies to profiles, suggestion cards, the first-photos panel and the tree.
|
||||
- A birth place or a nickname in a right-to-left script no longer flips the order of the line under the name.
|
||||
|
||||
### Suggestion cards
|
||||
|
||||
- The action label (New, Edit, Delete) sits on the top edge of the card, so the name keeps the full width and both buttons stay inside the card on a phone.
|
||||
- A change whose profile changed after the suggestion was sent shows "Needs checking" in place of "Edit". Any other action keeps its word ("Delete, needs checking").
|
||||
|
||||
### Fixes
|
||||
|
||||
- The quick buttons to add a parent, partner, child or sibling in a profile's Info tab are dashed chips, like "+ Nickname" in edit mode (#154).
|
||||
- The notice after adding someone or sending changes takes the width it needs on a phone, instead of four lines in a pill, and appears without animation (#156).
|
||||
- The counter of pending suggestions on the account picture is round for a single digit.
|
||||
- Backend dependencies: ip-address 10.7.4 (#144) and fast-uri (#155), for known security issues.
|
||||
|
||||
### For self-hosters
|
||||
|
||||
- No database change.
|
||||
|
||||
## 1.5.1 (2026-10-25)
|
||||
|
||||
### Suggestions show on the tree
|
||||
|
||||
- A suggestion waiting for review now shows on the tree, where it will go: a suggested person is drawn faded, with a dotted circle and a "Suggested" tag, and their links are dotted. Words, drawings and photos placed on the tree show see-through. A person with a suggested change gets a small dot.
|
||||
- Whoever manages the tree opens a suggested person to see who suggested it and what else the suggestion holds, and accepts or rejects it right there. A person with a suggested change shows the fields in their profile, with the same buttons. "See details" opens the review panel to sort change by change.
|
||||
- The family sees what they are adding as a "Draft" before sending it, then as "Waiting" until it is reviewed. This is kept in their browser only, and cleared once the suggestion is reviewed.
|
||||
- After adding a person, the send window no longer opens by itself. The family can add several people, then send once with Send.
|
||||
|
||||
### Photos placed on the tree by the family
|
||||
|
||||
- A family member can now place a photo on the tree. Before, the upload was refused without a word and the photo never showed. The photo waits for review: it is kept if accepted, deleted if rejected, and an upload never sent is deleted after 7 days. At most 50 photos wait on a tree at once.
|
||||
- Accepted words, drawings and photos are now placed by the server when the suggestion is accepted. Before, the reviewer's browser placed them afterwards and could lose them, and none appeared when the family's changes apply directly.
|
||||
|
||||
### Adding relatives from a profile
|
||||
|
||||
- "Someone elsewhere": relatives can add a person found in public records too. It goes into their changes and the tree manager reviews it, like any other proposal. Places, the "needs checking" mark and the source link now travel with a proposed new person.
|
||||
- In a profile's Links tab, searching for a parent, child or partner who is not in the tree yet now offers "Create …", which opens the add panel already filled in.
|
||||
- Profiles have a Siblings section, with "Add a sibling".
|
||||
- The Info tab has quick buttons to add a parent, partner, child or sibling without going into edit mode first.
|
||||
- The add panel opened from a profile comes with the link already set, and suggests the family name for a child or a sibling.
|
||||
- Adding someone whose first and last name match a person already in the tree shows a warning, with a button to open that person's profile.
|
||||
|
||||
### Fixes
|
||||
|
||||
- The "needs checking" mark sits on the corner of the portrait, half outside, so it no longer covers the face.
|
||||
- "Update the tree" no longer clears your changes when part of them fails to save: what failed stays in your edits, with a message.
|
||||
- A suggestion already reviewed elsewhere (another tab, a co-manager) now says so, instead of a message about emails, and the tree updates.
|
||||
- After the family sends their changes, the tree reloads: a photo just sent no longer looks accepted.
|
||||
- Zooming with the mouse wheel no longer logs an error in the browser console.
|
||||
|
||||
### For self-hosters
|
||||
|
||||
- New table `annotation_photo_uploads` (migration `20261025_003`, additive).
|
||||
- New route `GET /trees/:id/contributions/sessions/:sessionId/status`, which returns only the status of a suggestion to someone who can read the tree.
|
||||
- Anonymous counters for editing (person added and from where, suggestion sent or reviewed, edits saved), with no content from the tree. As before, nothing is sent without `VITE_POSTHOG_KEY`.
|
||||
|
||||
## 1.5.0 (2026-10-25)
|
||||
|
||||
### Several managers for one tree
|
||||
|
||||
- The owner can invite co-managers from the tree settings, in a new Managers tab. Parenthèse sends no email: the owner gets a link to pass on. The link works once, lasts 14 days, and can be cancelled. Copying it again creates a new link and the old one stops working (#145, asked by @milan616).
|
||||
- A co-manager edits the tree and reviews the family's suggestions like the owner. Only the owner invites or removes a co-manager and deletes the tree. A co-manager can leave from the Advanced tab.
|
||||
- Immich stays with the owner. Its key opens the owner's whole library, so co-managers do not see it or change it.
|
||||
- With a password account, the link is needed and the account must use the invited address. With OIDC, a co-manager whose provider marks the invited address as verified is added at sign-in, without the link. An unverified address is never matched.
|
||||
- An invitation opens an account even when `ALLOW_REGISTRATION=false`, at the invited address only.
|
||||
- Removing a co-manager takes effect on their next request, and also drops any access they had saved with the sharing password. The settings then offer to change that password, since they may have seen it.
|
||||
- Deleting your account now names the co-managers of the trees you own, which are deleted for them too.
|
||||
- In the tree menu, "Admin" is now "Co-manager".
|
||||
|
||||
### Search for someone elsewhere
|
||||
|
||||
- "Someone elsewhere" in the bottom-right menu opens a panel on the right of the tree: type a name, birth year and place, and search public records. Links to Gallica (old newspapers), FranceArchives (military registers), Mémoire des hommes, Léonore, Geneanet, Open Archives, WikiTree, FreeBMD and FamilySearch open in the browser with the name typed in when the site allows it.
|
||||
- The server can query deaths in France since 1970 (INSEE records via matchID) and WikiTree, once listed in `SEARCH_SOURCES`. Each source says what it covers and what is sent. The owner adds a result to a person in the tree: empty fields only, marked as needing checking, source kept in the note.
|
||||
|
||||
### Trees
|
||||
|
||||
- A tree can have an icon, picked from fifteen emoji in Settings > Your tree. It replaces the first letter in the tree menu, on the account page and on the invitation page.
|
||||
- For whoever manages the tree, the button at the bottom right now reads Edit, with a pencil. The family keeps Add.
|
||||
- A tree name starting with an emoji no longer shows a broken letter in the menus.
|
||||
|
||||
### Fixes
|
||||
|
||||
- An admin can switch a person's profile to edit mode from a new Edit button on the profile.
|
||||
- The "?" of "needs checking" shows whole on round portraits. It was cut by the frame.
|
||||
- On iPad, the tree no longer stops short of the bottom of the screen. The canvas follows its real size instead of the window height read at load.
|
||||
|
||||
## 1.4.0 (2026-10-23)
|
||||
|
||||
### Family tabs
|
||||
|
||||
- In another family's tab, the person you marked as "That's me" stays on screen, linked to that family by their direct line (the shortest kinship path, both parents at each step, no siblings around), drawn faded. Without a choice, the tree's starting person is used (#138).
|
||||
- Visitors without an account can say who they are in the tree from their menu. The choice is kept in their browser for that tree (#138).
|
||||
|
||||
### Notes on the tree
|
||||
|
||||
- A note is attached to the nearest person when it is placed. It only shows in the tabs where that person is drawn, and stays next to them when the layout changes. Notes placed before this version are attached the first time the tree opens (#141).
|
||||
- A placed text can be edited again. With the select tool, click it twice (#141).
|
||||
|
||||
### People
|
||||
|
||||
- A person can be marked as needing checking, for a date or a link you are not sure of. A "?" shows on the portrait and a pill on the profile, the family can propose it like any other field, and searching for "à vérifier" lists them (French only for now) (#140).
|
||||
|
||||
### Fixes and maintenance
|
||||
|
||||
- Memory thumbnails around a portrait are cropped instead of stretched to a square (#142).
|
||||
- DOMPurify 3.4.17, pulled in by posthog-js, for a critical advisory published on 2026-10-23 (GHSA-cg2g-xh64-v4q7) (#139).
|
||||
- Minor dependency updates for the app, the API and the landing page (#133, #134, #136).
|
||||
|
||||
## 1.3.0 (2026-10-21)
|
||||
|
||||
### Memories from Immich
|
||||
|
||||
- Admins with Immich configured can add photos and videos from their library, from a person's memories or when adding a moment. Search by context, filter by photos or videos or by face, open a larger preview and pick several at once (#103 by @Askrosm, #128).
|
||||
- Photos come in as Immich's preview, so iPhone HEIC pictures work, and downloads stop as soon as they pass the size limit (#103).
|
||||
- Several memories picked at once are added in one go, each titled from its file name (#128).
|
||||
|
||||
## 1.2.1 (2026-10-21)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Clicking a person without a photo opens their profile again. The file picker only opens from "+ photo" in edit mode (#124, reported by @coutadeurf, #125).
|
||||
- GEDCOM export stays valid 5.5.1. Sex "other" is written as `SEX U`, and `SEX X` is still read on import (#126).
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- With `PASSWORD_LOGIN=false`, an account that had a password and was linked to OIDC can delete itself by retyping its email after a recent sign-in (#126).
|
||||
- An email already linked to an OIDC identity can't be claimed by a different subject at the provider (#126).
|
||||
- HEIC and AVIF files are recognised as images instead of video, and are refused cleanly until they can be converted (#126).
|
||||
|
||||
## 1.2.0 (2026-10-20)
|
||||
|
||||
### Tree layout
|
||||
|
||||
+4
-4
@@ -18,12 +18,12 @@ RUN apt-get update \
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
COPY backend/prisma ./prisma
|
||||
RUN npm ci --no-audit --no-fund
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY backend/tsconfig.json ./
|
||||
COPY backend/prisma ./prisma
|
||||
COPY backend/prisma.config.ts backend/tsconfig.json ./
|
||||
COPY backend/src ./src
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_mod
|
||||
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
|
||||
&& npm cache clean --force
|
||||
|
||||
COPY --from=api-build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY backend/prisma ./prisma
|
||||
COPY backend/prisma.config.ts ./
|
||||
COPY --from=api-build /app/dist ./dist
|
||||
|
||||
# nginx : la configuration du conteneur web de compose, l'API étant ici sur la même machine.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
!deploy/aio
|
||||
backend/node_modules
|
||||
backend/dist
|
||||
backend/src/generated
|
||||
backend/.local
|
||||
backend/storage
|
||||
backend/test
|
||||
|
||||
@@ -59,6 +59,24 @@ Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HT
|
||||
|
||||
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
|
||||
|
||||
Votre instance ne pousse personne à y créer un compte. Sur app.parenthese.io, l'instance hébergée, l'application invite à créer son propre arbre : la famille qui ouvre un arbre partagé trouve « Faire le mien » dans le menu Ajouter, les messages de la démo proposent l'inscription, et du 1er décembre au 6 janvier le message d'invitation parle de Noël. Ces invitations sont éteintes par défaut. Pour les allumer, mettez `SIGNUP_PROMPTS=true` dans `.env` (elles restent coupées si l'inscription est fermée).
|
||||
|
||||
#### Chercher quelqu'un dans des sources publiques
|
||||
|
||||
Le menu « + » propose « Quelqu'un ailleurs » : un panneau à droite de l'arbre où l'on tape un nom pour voir ce que les sources publiques disent de cette personne. Les liens vers des sites sans recherche interrogeable d'ici (presse ancienne sur Gallica, registres matricules sur FranceArchives, Geneanet, FamilySearch et quelques autres, la France d'abord) marchent toujours : votre navigateur ouvre le site avec le nom déjà tapé, et rien ne passe par le serveur.
|
||||
|
||||
Le serveur peut aussi interroger lui-même deux sources et afficher leurs résultats dans le panneau. Elles restent éteintes tant que vous ne les nommez pas dans `.env` :
|
||||
|
||||
```
|
||||
# deces : décès en France depuis 1970 (fichier de l'INSEE, cherché par matchID)
|
||||
# wikitree : profils publics de WikiTree
|
||||
SEARCH_SOURCES=deces,wikitree
|
||||
# Facultatif, gratuit sur deces.matchid.io : sans clé, les recherches s'arrêtent après une centaine
|
||||
MATCHID_TOKEN=
|
||||
```
|
||||
|
||||
Seuls le prénom, le nom et l'année de naissance quittent le serveur, et le panneau l'écrit à côté de chaque source. Un résultat ajouté à l'arbre ne remplit que les champs vides, passe la personne « à vérifier » et garde le lien de la source dans sa note. Le propriétaire de l'arbre ajoute directement ; la famille voit les résultats et les liens. Une clé matchID vaut 30 jours : renouvelez-la depuis votre compte matchID.
|
||||
|
||||
#### Connexion par votre fournisseur d'identité (OIDC)
|
||||
|
||||
*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.*
|
||||
|
||||
@@ -65,9 +65,27 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox
|
||||
|
||||
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
|
||||
|
||||
Your instance doesn't push anyone to create an account. On app.parenthese.io, the hosted instance, the app suggests starting your own tree: relatives who open a shared tree find « Faire le mien » ("Make my own") in the Add menu, the demo's messages offer to sign up, and from December 1 to January 6 the invitation message mentions Christmas. These prompts are off by default. To turn them on, set `SIGNUP_PROMPTS=true` in `.env` (they stay off while registration is closed).
|
||||
|
||||
#### Search for someone in public records
|
||||
|
||||
The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server.
|
||||
|
||||
The server can also query two sources itself and show the results in the panel. They stay off until you list them in `.env`:
|
||||
|
||||
```
|
||||
# deces: deaths in France since 1970 (INSEE death records, searched through matchID)
|
||||
# wikitree: WikiTree public profiles
|
||||
SEARCH_SOURCES=deces,wikitree
|
||||
# Optional, free from deces.matchid.io: anonymous searches stop after about a hundred
|
||||
MATCHID_TOKEN=
|
||||
```
|
||||
|
||||
Only the first name, last name and birth year leave the server, and the panel says so next to each source. A result added to the tree only fills empty fields, marks the person as needing checking and keeps the source link in their note. The tree owner adds directly; relatives see the results and the links. A matchID key is valid for 30 days: renew it from your matchID account.
|
||||
|
||||
#### Sign in with your identity provider (OIDC)
|
||||
|
||||
*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.*
|
||||
*Used with Pocket ID on a real setup (#153).*
|
||||
|
||||
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
|
||||
|
||||
@@ -79,9 +97,11 @@ OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # shown on the button
|
||||
```
|
||||
|
||||
The `api` container talks to the provider itself, to read its configuration and exchange the sign-in code. If your Docker network is marked `internal`, give `api` a network that can reach the provider, not just `web`.
|
||||
|
||||
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
|
||||
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
|
||||
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
src/generated
|
||||
.local
|
||||
storage
|
||||
test
|
||||
|
||||
@@ -14,6 +14,9 @@ IMMICH_ALLOWED_API_URLS=""
|
||||
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
|
||||
# compte accepte quand même le premier, celui du propriétaire.
|
||||
ALLOW_REGISTRATION=true
|
||||
# Invitations à créer son propre arbre (famille, démo, message de Noël). Éteintes par défaut, allumées sur
|
||||
# l'instance hébergée.
|
||||
SIGNUP_PROMPTS=false
|
||||
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
|
||||
# les mots de passe Parenthèse.
|
||||
OIDC_ISSUER=""
|
||||
|
||||
@@ -30,6 +30,11 @@ MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
|
||||
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
|
||||
IMMICH_ALLOWED_API_URLS=""
|
||||
|
||||
# Panneau « Quelqu'un ailleurs » : sources interrogées par le serveur (deces, wikitree), vide = aucune.
|
||||
# MATCHID_TOKEN : clé gratuite de deces.matchid.io, indispensable au-delà d'une centaine de recherches.
|
||||
SEARCH_SOURCES=""
|
||||
MATCHID_TOKEN=""
|
||||
|
||||
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
|
||||
# compte accepte quand même le premier, celui du propriétaire.
|
||||
ALLOW_REGISTRATION=true
|
||||
|
||||
@@ -3,3 +3,6 @@ dist/
|
||||
.env
|
||||
.local/
|
||||
*.log
|
||||
|
||||
# Client Prisma généré (prisma generate)
|
||||
src/generated/
|
||||
+7
-8
@@ -1,12 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# API Parenthèse (Fastify + Prisma). Contexte de build : le dossier backend/.
|
||||
# Étape 1 : dépendances complètes, client Prisma, compilation TypeScript.
|
||||
# Étape 1 : dépendances complètes, client Prisma (généré dans src/generated), compilation TypeScript.
|
||||
# Étape 2 : image finale avec les seules dépendances de production, plus la CLI Prisma
|
||||
# installée à part (nécessaire à `prisma migrate deploy` au démarrage).
|
||||
|
||||
FROM node:24-bookworm-slim AS build
|
||||
|
||||
# openssl : requis par les moteurs Prisma (cible debian-openssl-3.0.x).
|
||||
# openssl : requis par le moteur de migration de Prisma (cible debian-openssl-3.0.x).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -14,12 +14,12 @@ RUN apt-get update \
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci --no-audit --no-fund
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -46,10 +46,9 @@ RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_mod
|
||||
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Client Prisma généré à l'étape 1 (même image de base, même cible de moteur).
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
# Schéma + migrations : indispensables à `prisma migrate deploy`.
|
||||
# Schéma, migrations et configuration : indispensables à `prisma migrate deploy`. Le client Prisma est compilé dans dist.
|
||||
COPY prisma ./prisma
|
||||
COPY prisma.config.ts ./
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
RUN mkdir -p /app/storage/media && chown -R node:node /app/storage
|
||||
|
||||
@@ -563,7 +563,10 @@ paths:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required: [registration, passwordLogin, oidc]
|
||||
signupPrompts:
|
||||
type: boolean
|
||||
description: true when SIGNUP_PROMPTS=true and accounts can be created. The app then suggests starting one's own tree to relatives and demo visitors
|
||||
required: [registration, passwordLogin, oidc, signupPrompts]
|
||||
|
||||
/auth/oidc/start:
|
||||
get:
|
||||
|
||||
Generated
+1794
-274
File diff suppressed because it is too large.
Load diff
+11
-7
@@ -17,23 +17,27 @@
|
||||
"prisma:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cookie": "^11.1.5",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/jwt": "^10.2.6",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@prisma/client": "^6.15.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^18.0.7",
|
||||
"@node-rs/bcrypt": "^2.0.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"dotenv": "^18.0.9",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.19.2",
|
||||
"prisma": "^6.15.0",
|
||||
"@types/pg": "^8.23.1",
|
||||
"prisma": "^7.10.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.4"
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { config } from 'dotenv'
|
||||
import type { PrismaConfig } from 'prisma/config'
|
||||
|
||||
// Configuration du CLI Prisma (generate, migrate). Depuis Prisma 7, le CLI ne lit plus .env tout seul. Seul un import
|
||||
// de type vient de prisma/config : dans les images, le CLI est installé à part (/opt/prisma) et ce fichier doit se
|
||||
// charger sans lui. Sans DATABASE_URL (generate pendant un build), l'adresse reste vide.
|
||||
config({ quiet: true })
|
||||
|
||||
export default {
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: { path: 'prisma/migrations' },
|
||||
datasource: { url: process.env.DATABASE_URL ?? '' },
|
||||
} satisfies PrismaConfig
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Personnes identifiées sur un souvenir (feat/souvenirs-personnes). Migration additive : nouvelle table, nouvelle valeur d'énumération.
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ContributionEntityType" ADD VALUE 'media_people';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "media_person_tags" (
|
||||
"id" TEXT NOT NULL,
|
||||
"treeId" TEXT NOT NULL,
|
||||
"mediaId" TEXT NOT NULL,
|
||||
"personId" TEXT NOT NULL,
|
||||
"x" DOUBLE PRECISION,
|
||||
"y" DOUBLE PRECISION,
|
||||
"createdBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "media_person_tags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "media_person_tags_treeId_idx" ON "media_person_tags"("treeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "media_person_tags_treeId_personId_idx" ON "media_person_tags"("treeId", "personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "media_person_tags_mediaId_personId_key" ON "media_person_tags"("mediaId", "personId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "media_person_tags" ADD CONSTRAINT "media_person_tags_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- « À vérifier » sur une personne (feat/a-verifier). Migration additive : booléen à faux par défaut.
|
||||
-- AlterTable
|
||||
ALTER TABLE "persons" ADD COLUMN "toVerify" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Co-gestion d'un arbre (issue #145) : invitations du propriétaire. Migration additive, une table neuve.
|
||||
-- CreateTable
|
||||
CREATE TABLE "tree_invitations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"treeId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"invitedByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"acceptedAt" TIMESTAMP(3),
|
||||
"acceptedUserId" TEXT,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "tree_invitations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tree_invitations_tokenHash_key" ON "tree_invitations"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tree_invitations_treeId_idx" ON "tree_invitations"("treeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tree_invitations_email_idx" ON "tree_invitations"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tree_invitations" ADD CONSTRAINT "tree_invitations_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Icône d'un arbre (un emoji), facultative. Migration additive : colonne vide par défaut.
|
||||
ALTER TABLE "trees" ADD COLUMN "icon" TEXT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Photos posées sur l'arbre par la famille, en attente de relecture (1.5.1). Migration additive, une table neuve.
|
||||
-- CreateTable
|
||||
CREATE TABLE "annotation_photo_uploads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"treeId" TEXT NOT NULL,
|
||||
"filePath" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "annotation_photo_uploads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "annotation_photo_uploads_treeId_createdAt_idx" ON "annotation_photo_uploads"("treeId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "annotation_photo_uploads" ADD CONSTRAINT "annotation_photo_uploads_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Sessions fermées (déconnexion, compte supprimé), gardées en base pour survivre au redémarrage de l'API.
|
||||
-- Migration additive, une table neuve.
|
||||
-- CreateTable
|
||||
CREATE TABLE "revoked_sessions" (
|
||||
"jti" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "revoked_sessions_pkey" PRIMARY KEY ("jti")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "revoked_sessions_expiresAt_idx" ON "revoked_sessions"("expiresAt");
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Aperçus flous (BlurHash) des portraits et des photos, montrés pendant le chargement.
|
||||
-- Migration additive : deux colonnes facultatives, remplies ensuite par l'API (lib/blurhash.ts).
|
||||
-- AlterTable
|
||||
ALTER TABLE "persons" ADD COLUMN "avatarBlurhash" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "media_items" ADD COLUMN "blurhash" TEXT;
|
||||
+111
-28
@@ -1,10 +1,14 @@
|
||||
// Client généré dans src/generated (ignoré par git), compilé avec le reste dans dist : rien à copier depuis
|
||||
// node_modules dans les images. L'adresse de la base vient de prisma.config.ts.
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "esm"
|
||||
importFileExtension = "js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
@@ -40,6 +44,8 @@ enum ContributionEntityType {
|
||||
annotation
|
||||
media
|
||||
life_event
|
||||
// Personnes identifiées sur un souvenir (remplace la liste entière)
|
||||
media_people
|
||||
}
|
||||
|
||||
// Souvenir envoyé par la famille : en attente jusqu'à la relecture du propriétaire
|
||||
@@ -96,6 +102,7 @@ model User {
|
||||
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
|
||||
treeVisits TreeVisit[]
|
||||
identities UserIdentity[]
|
||||
sentTreeInvitations TreeInvitation[] @relation("TreeInvitationSentBy")
|
||||
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([lastOpenedAt])
|
||||
@@ -118,32 +125,37 @@ model UserIdentity {
|
||||
}
|
||||
|
||||
model Tree {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
slug String @unique
|
||||
name String
|
||||
description String?
|
||||
rootPersonId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
|
||||
accessPasswords TreeAccessPasswords?
|
||||
sharePassword TreeSharePassword?
|
||||
memberships TreeMembership[]
|
||||
sharedUserAccesses UserTreeAccess[]
|
||||
settings TreeSettings?
|
||||
immichConfig TreeImmichConfig?
|
||||
persons Person[]
|
||||
unions Union[]
|
||||
parentChildLinks ParentChildLink[]
|
||||
mediaItems MediaItem[]
|
||||
contributionSessions ContributionSession[]
|
||||
auditLogs AuditLog[]
|
||||
annotations Annotation[]
|
||||
visits TreeVisit[]
|
||||
lifeEvents LifeEvent[]
|
||||
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
slug String @unique
|
||||
name String
|
||||
description String?
|
||||
// Icône choisie pour l'arbre (un emoji), à la place de la première lettre dans les menus ; null = la lettre
|
||||
icon String?
|
||||
rootPersonId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
|
||||
accessPasswords TreeAccessPasswords?
|
||||
sharePassword TreeSharePassword?
|
||||
memberships TreeMembership[]
|
||||
sharedUserAccesses UserTreeAccess[]
|
||||
settings TreeSettings?
|
||||
immichConfig TreeImmichConfig?
|
||||
persons Person[]
|
||||
unions Union[]
|
||||
parentChildLinks ParentChildLink[]
|
||||
mediaItems MediaItem[]
|
||||
mediaPersonTags MediaPersonTag[]
|
||||
contributionSessions ContributionSession[]
|
||||
auditLogs AuditLog[]
|
||||
annotations Annotation[]
|
||||
visits TreeVisit[]
|
||||
lifeEvents LifeEvent[]
|
||||
invitations TreeInvitation[]
|
||||
annotationPhotoUploads AnnotationPhotoUpload[]
|
||||
lastOpenedByUsers User[] @relation("UserLastOpenedTree")
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@map("trees")
|
||||
@@ -185,6 +197,51 @@ model TreeMembership {
|
||||
@@map("tree_memberships")
|
||||
}
|
||||
|
||||
// Invitation à co-gérer un arbre (issue #145) : le propriétaire donne une adresse, Parenthèse un lien à transmettre.
|
||||
// Seule l'empreinte du jeton est gardée ; « Copier le lien » en tire un nouveau. Acceptée, elle donne le rôle admin
|
||||
// (co-gestionnaire). Le lien sert une fois, jusqu'à expiresAt.
|
||||
model TreeInvitation {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
email String
|
||||
tokenHash String @unique
|
||||
invitedByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
acceptedUserId String?
|
||||
revokedAt DateTime?
|
||||
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
invitedBy User? @relation("TreeInvitationSentBy", fields: [invitedByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([treeId])
|
||||
@@index([email])
|
||||
@@map("tree_invitations")
|
||||
}
|
||||
|
||||
// Sessions fermées (déconnexion, compte supprimé), gardées jusqu'à la fin de leur jeton
|
||||
model RevokedSession {
|
||||
jti String @id
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([expiresAt])
|
||||
@@map("revoked_sessions")
|
||||
}
|
||||
|
||||
// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est
|
||||
// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est
|
||||
// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne.
|
||||
model AnnotationPhotoUpload {
|
||||
id String @id
|
||||
treeId String
|
||||
filePath String
|
||||
createdAt DateTime @default(now())
|
||||
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([treeId, createdAt])
|
||||
@@map("annotation_photo_uploads")
|
||||
}
|
||||
|
||||
model UserTreeAccess {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
@@ -234,6 +291,8 @@ model Person {
|
||||
deathDate DateTime?
|
||||
// Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide
|
||||
isDeceased Boolean @default(false)
|
||||
// « À vérifier » : la famille n'est pas sûre de cette personne et le signale (retour d'Anaïs, 23/10)
|
||||
toVerify Boolean @default(false)
|
||||
birthPlace String?
|
||||
// Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … »
|
||||
birthDatePrecision String?
|
||||
@@ -251,6 +310,8 @@ model Person {
|
||||
// { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière
|
||||
// recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1).
|
||||
avatarCrop Json?
|
||||
// Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
|
||||
avatarBlurhash String?
|
||||
sex String?
|
||||
notes String?
|
||||
createdBy String?
|
||||
@@ -311,6 +372,8 @@ model MediaItem {
|
||||
mimeType String
|
||||
filePath String
|
||||
thumbPath String?
|
||||
// Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
|
||||
blurhash String?
|
||||
caption String?
|
||||
source String?
|
||||
sizeBytes Int
|
||||
@@ -331,6 +394,26 @@ model MediaItem {
|
||||
@@map("media_items")
|
||||
}
|
||||
|
||||
// Personne identifiée sur un souvenir. Le souvenir reste à la personne qui le porte (media_items.personId) ;
|
||||
// il apparaît aussi sur la fiche de chaque personne identifiée. x et y : point sur le visage, en proportion
|
||||
// de l'image (0 à 1), facultatifs.
|
||||
model MediaPersonTag {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
mediaId String
|
||||
personId String
|
||||
x Float?
|
||||
y Float?
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([mediaId, personId])
|
||||
@@index([treeId])
|
||||
@@index([treeId, personId])
|
||||
@@map("media_person_tags")
|
||||
}
|
||||
|
||||
model ContributionSession {
|
||||
id String @id @default(cuid())
|
||||
treeId String
|
||||
|
||||
+20
-3
@@ -4,16 +4,20 @@ import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from './generated/prisma/client.js'
|
||||
|
||||
import { env } from './config/env.js'
|
||||
import { prisma } from './lib/prisma.js'
|
||||
import { isUserTokenRevoked } from './lib/token-revocation.js'
|
||||
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
|
||||
import { registerIdempotency } from './lib/idempotency.js'
|
||||
import { startBlurhashFiller } from './lib/blurhash.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { contributionRoutes } from './routes/contributions.js'
|
||||
import { graphRoutes } from './routes/graph.js'
|
||||
import { immichRoutes } from './routes/immich.js'
|
||||
import { mediaRoutes } from './routes/media.js'
|
||||
import { managerRoutes } from './routes/managers.js'
|
||||
import { searchSourceRoutes } from './routes/search-sources.js'
|
||||
import { getStorageRoot, mediaRoutes } from './routes/media.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { publicRoutes } from './routes/public.js'
|
||||
import { annotationRoutes } from './routes/annotations.js'
|
||||
@@ -56,6 +60,14 @@ export function createApp() {
|
||||
})
|
||||
|
||||
app.decorate('prisma', prisma)
|
||||
// Les déconnexions d'avant le redémarrage reviennent en mémoire avant la première requête (onReady passe avant
|
||||
// listen). Sans elles, l'API ne démarre pas : chaque session fermée redeviendrait valable sans que personne le
|
||||
// voie, et pm2 comme Docker relancent l'API.
|
||||
app.addHook('onReady', async () => {
|
||||
await loadRevokedTokens(app.prisma)
|
||||
})
|
||||
// Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests
|
||||
if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot()))
|
||||
app.decorateRequest('actor', null)
|
||||
|
||||
app.register(cors, {
|
||||
@@ -148,6 +160,9 @@ export function createApp() {
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
// Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée
|
||||
registerIdempotency(app)
|
||||
|
||||
app.register(authRoutes, { prefix: '/auth' })
|
||||
app.register(oidcRoutes, { prefix: '/auth/oidc' })
|
||||
app.register(publicRoutes)
|
||||
@@ -159,6 +174,8 @@ export function createApp() {
|
||||
app.register(gedcomRoutes)
|
||||
app.register(visitRoutes)
|
||||
app.register(immichRoutes)
|
||||
app.register(managerRoutes)
|
||||
app.register(searchSourceRoutes)
|
||||
|
||||
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||
|
||||
@@ -68,6 +68,11 @@ const envSchema = z.object({
|
||||
// Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte
|
||||
// accepte quand même le premier, celui du propriétaire (voir lib/registration.ts).
|
||||
ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')),
|
||||
// Invitations à créer son propre arbre, ajoutées pour l'instance hébergée app.parenthese.io : « Faire le mien »
|
||||
// dans le menu Ajouter de la famille, l'inscription proposée dans les messages de la démo, message d'invitation
|
||||
// de Noël. Éteintes par défaut : une instance auto-hébergée n'a pas à pousser vers la création de compte.
|
||||
// L'instance hébergée les allume dans ecosystem.config.cjs.
|
||||
SIGNUP_PROMPTS: z.string().default('false').transform(parseBooleanFlag('SIGNUP_PROMPTS')),
|
||||
// Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans
|
||||
// OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback.
|
||||
OIDC_ISSUER: z.string().trim().default(''),
|
||||
@@ -86,6 +91,12 @@ const envSchema = z.object({
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')),
|
||||
// À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement
|
||||
PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')),
|
||||
// Sources publiques que le panneau « Chercher quelqu'un » interroge depuis le serveur, séparées par des
|
||||
// virgules : deces (décès en France depuis 1970, INSEE par matchID), wikitree. Vide : aucun nom ne quitte
|
||||
// l'instance, le panneau ne propose que des liens vers les sites, ouverts par le navigateur.
|
||||
SEARCH_SOURCES: z.string().default(''),
|
||||
// Facultatif : jeton matchID (gratuit, après inscription) au-delà d'une centaine de recherches
|
||||
MATCHID_TOKEN: z.string().trim().default(''),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { InvalidAnnotationContentError, sanitizeAnnotationContentForWrite } from './annotation-content.js'
|
||||
|
||||
// Annotations proposées par la famille (dessin, texte, sticker, photo posée sur l'arbre). Avant la 1.5.1, le
|
||||
// serveur les laissait au navigateur du relecteur, qui les posait après coup : une proposition acceptée pouvait
|
||||
// ne jamais apparaître (onglet fermé, erreur réseau), et rien n'apparaissait en mode « direct ». Elles sont
|
||||
// maintenant posées dans la transaction qui accepte la proposition.
|
||||
|
||||
type Tx = Prisma.TransactionClient
|
||||
type Db = PrismaClient | Tx
|
||||
|
||||
export const MAX_ANNOTATIONS_PER_TREE = 5000
|
||||
// Un envoi de photo jamais proposé vit le temps d'un brouillon de la famille
|
||||
export const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// Une famille pose quelques photos à la fois ; au-delà, c'est un envoi en boucle
|
||||
export const MAX_PENDING_PHOTOS_PER_TREE = 50
|
||||
|
||||
const MAX_STYLE_CHARS = 4000
|
||||
const styleSchema = z.record(z.string(), z.unknown()).refine(
|
||||
(value) => JSON.stringify(value).length <= MAX_STYLE_CHARS,
|
||||
{ message: 'style_too_large' },
|
||||
)
|
||||
|
||||
const annotationCreateSchema = z.object({
|
||||
type: z.enum(['drawing', 'sticker', 'text', 'photo']),
|
||||
x: z.number().finite(),
|
||||
y: z.number().finite(),
|
||||
content: z.string().min(1).max(50000),
|
||||
style: styleSchema.optional(),
|
||||
zIndex: z.number().int().optional(),
|
||||
})
|
||||
|
||||
const annotationUpdateSchema = z.object({
|
||||
x: z.number().finite().optional(),
|
||||
y: z.number().finite().optional(),
|
||||
content: z.string().min(1).max(50000).optional(),
|
||||
style: styleSchema.optional(),
|
||||
zIndex: z.number().int().optional(),
|
||||
})
|
||||
|
||||
export class AnnotationChangeError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code)
|
||||
}
|
||||
}
|
||||
|
||||
type AnnotationChange = { action: string; entityId?: string | null; after?: unknown }
|
||||
|
||||
// Contenu vérifié comme à l'écriture directe : une photo doit être une photo de cet arbre
|
||||
function cleanContent(content: string, treeId: string): string {
|
||||
try {
|
||||
return sanitizeAnnotationContentForWrite(content, treeId)
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidAnnotationContentError) throw new AnnotationChangeError('invalid_annotation_photo_path')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie une proposition d'annotation dès l'envoi, pour qu'une relecture n'échoue pas sur un contenu refusé
|
||||
export function validateAnnotationChange(change: AnnotationChange, treeId: string): void {
|
||||
if (change.action === 'create') {
|
||||
const parsed = annotationCreateSchema.safeParse(change.after)
|
||||
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
|
||||
cleanContent(parsed.data.content, treeId)
|
||||
return
|
||||
}
|
||||
if (change.action === 'update') {
|
||||
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
|
||||
const parsed = annotationUpdateSchema.safeParse(change.after)
|
||||
if (!parsed.success) throw new AnnotationChangeError('invalid_annotation_change')
|
||||
if (parsed.data.content !== undefined) cleanContent(parsed.data.content, treeId)
|
||||
return
|
||||
}
|
||||
if (change.action === 'delete') {
|
||||
if (!change.entityId) throw new AnnotationChangeError('invalid_annotation_change')
|
||||
return
|
||||
}
|
||||
throw new AnnotationChangeError('unsupported_change_action_for_annotation')
|
||||
}
|
||||
|
||||
// L'« après » d'une proposition d'annotation tel qu'il est gardé : contenu nettoyé comme à l'écriture directe
|
||||
// (photoPath canonique, sans adresse), pour qu'on le relise toujours de la même façon
|
||||
export function normalizedAnnotationAfter(change: AnnotationChange, treeId: string): unknown {
|
||||
const after = change.after
|
||||
if (!after || typeof after !== 'object' || Array.isArray(after)) return after
|
||||
const content = (after as { content?: unknown }).content
|
||||
if (typeof content !== 'string') return after
|
||||
return { ...(after as Record<string, unknown>), content: cleanContent(content, treeId) }
|
||||
}
|
||||
|
||||
// Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null
|
||||
export function annotationPhotoId(after: unknown, treeId: string): string | null {
|
||||
if (!after || typeof after !== 'object' || Array.isArray(after)) return null
|
||||
const content = (after as { content?: unknown }).content
|
||||
if (typeof content !== 'string' || !content.startsWith('{')) return null
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { photoPath?: unknown }
|
||||
const prefix = `/trees/${treeId}/annotation-photos/`
|
||||
if (typeof parsed.photoPath !== 'string' || !parsed.photoPath.startsWith(prefix)) return null
|
||||
const photoId = parsed.photoPath.slice(prefix.length)
|
||||
return /^[A-Za-z0-9-]+$/.test(photoId) ? photoId : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pose une annotation acceptée. La photo d'une famille devient définitive : sa ligne d'attente disparaît.
|
||||
export async function applyAnnotationChange(tx: Tx, treeId: string, change: AnnotationChange, actorUserId: string | null): Promise<{ entityId: string | null }> {
|
||||
validateAnnotationChange(change, treeId)
|
||||
|
||||
if (change.action === 'create') {
|
||||
const data = annotationCreateSchema.parse(change.after)
|
||||
const count = await tx.annotation.count({ where: { treeId, deletedAt: null } })
|
||||
if (count + 1 > MAX_ANNOTATIONS_PER_TREE) throw new AnnotationChangeError('annotation_limit_reached')
|
||||
const created = await tx.annotation.create({
|
||||
data: {
|
||||
treeId,
|
||||
type: data.type,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
content: cleanContent(data.content, treeId),
|
||||
style: (data.style ?? {}) as Prisma.InputJsonValue,
|
||||
zIndex: data.zIndex ?? 0,
|
||||
createdBy: actorUserId,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
const photoId = annotationPhotoId(change.after, treeId)
|
||||
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
|
||||
return { entityId: created.id }
|
||||
}
|
||||
|
||||
if (change.action === 'update') {
|
||||
const data = annotationUpdateSchema.parse(change.after)
|
||||
const patch: Prisma.AnnotationUpdateManyMutationInput = {}
|
||||
if (data.x !== undefined) patch.x = data.x
|
||||
if (data.y !== undefined) patch.y = data.y
|
||||
if (data.content !== undefined) patch.content = cleanContent(data.content, treeId)
|
||||
if (data.style !== undefined) patch.style = data.style as Prisma.InputJsonValue
|
||||
if (data.zIndex !== undefined) patch.zIndex = data.zIndex
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await tx.annotation.updateMany({ where: { id: change.entityId as string, treeId, deletedAt: null }, data: patch })
|
||||
}
|
||||
const photoId = annotationPhotoId(change.after, treeId)
|
||||
if (photoId) await tx.annotationPhotoUpload.deleteMany({ where: { id: photoId, treeId } })
|
||||
return { entityId: change.entityId ?? null }
|
||||
}
|
||||
|
||||
await tx.annotation.updateMany({
|
||||
where: { id: change.entityId as string, treeId, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
return { entityId: change.entityId ?? null }
|
||||
}
|
||||
|
||||
// Photos citées par les propositions d'annotation encore en attente sur cet arbre (hors une session donnée).
|
||||
// Lu dans le JSON décodé, pas dans son texte : un chemin échappé (\/trees\/…) reste reconnu. Un arbre a au plus
|
||||
// quelques centaines de propositions en attente.
|
||||
async function photosInPendingProposals(db: Db, treeId: string, excludeSessionId?: string): Promise<Set<string>> {
|
||||
const changes = await db.contributionChange.findMany({
|
||||
where: {
|
||||
entityType: 'annotation',
|
||||
session: { treeId, status: 'pending', ...(excludeSessionId ? { id: { not: excludeSessionId } } : {}) },
|
||||
},
|
||||
select: { afterJson: true },
|
||||
})
|
||||
const cited = new Set<string>()
|
||||
for (const change of changes) {
|
||||
const photoId = annotationPhotoId(change.afterJson, treeId)
|
||||
if (photoId) cited.add(photoId)
|
||||
}
|
||||
return cited
|
||||
}
|
||||
|
||||
// Photos d'une famille refusées pendant une relecture : effacées si leur ligne d'attente existe encore (une
|
||||
// acceptation la retire) et si aucune autre proposition en attente ne les cite. Appelé une fois, après toutes les
|
||||
// décisions. Renvoie les chemins à effacer une fois la transaction validée.
|
||||
export async function releaseRejectedPhotos(tx: Tx, treeId: string, rejectedAfters: unknown[], sessionId: string): Promise<string[]> {
|
||||
const candidates = new Set(rejectedAfters.map((after) => annotationPhotoId(after, treeId)).filter((id): id is string => Boolean(id)))
|
||||
if (candidates.size === 0) return []
|
||||
const cited = await photosInPendingProposals(tx, treeId, sessionId)
|
||||
const removed: string[] = []
|
||||
for (const photoId of candidates) {
|
||||
if (cited.has(photoId)) continue
|
||||
const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } })
|
||||
if (!upload) continue
|
||||
await tx.annotationPhotoUpload.delete({ where: { id: photoId } })
|
||||
removed.push(upload.filePath)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur
|
||||
// cet arbre seulement. Renvoie les chemins à effacer.
|
||||
export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new Date()): Promise<string[]> {
|
||||
const stale = await db.annotationPhotoUpload.findMany({
|
||||
where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } },
|
||||
select: { id: true, filePath: true },
|
||||
})
|
||||
if (stale.length === 0) return []
|
||||
const cited = await photosInPendingProposals(db, treeId)
|
||||
const removed: string[] = []
|
||||
for (const upload of stale) {
|
||||
if (cited.has(upload.id)) continue
|
||||
await db.annotationPhotoUpload.delete({ where: { id: upload.id } })
|
||||
removed.push(upload.filePath)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// Réserve une place d'envoi en attente sous un verrou par arbre : deux envois simultanés ne dépassent pas le
|
||||
// plafond. Renvoie false si l'arbre est plein. La ligne est créée avant l'écriture du fichier ; si l'écriture
|
||||
// échoue, l'appelant la retire (forgetPendingPhoto).
|
||||
export async function reservePendingPhoto(prisma: PrismaClient, treeId: string, photoId: string, filePath: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'annotation-photos:' + treeId}))`
|
||||
if (await tx.annotationPhotoUpload.count({ where: { treeId } }) >= MAX_PENDING_PHOTOS_PER_TREE) return false
|
||||
await tx.annotationPhotoUpload.create({ data: { id: photoId, treeId, filePath } })
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export async function forgetPendingPhoto(db: Db, photoId: string): Promise<void> {
|
||||
await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } })
|
||||
}
|
||||
|
||||
// Chemin relatif sûr d'une photo d'annotation (sous <treeId>/annotation-photos/)
|
||||
export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string {
|
||||
return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`)
|
||||
}
|
||||
+17
-5
@@ -1,11 +1,23 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
// bcrypt natif (binaire précompilé) : le calcul passe sur les fils de libuv, la boucle de l'API garde la main.
|
||||
// bcryptjs calculait en JavaScript : 8 vérifications simultanées retardaient chaque requête de 0,7 s en moyenne.
|
||||
// Mêmes empreintes $2a$/$2b$ : les mots de passe enregistrés restent valables.
|
||||
import { hash, verify } from '@node-rs/bcrypt'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
const HASH_ROUNDS = 12
|
||||
|
||||
export async function hashPassword(value: string): Promise<string> {
|
||||
return bcrypt.hash(value, HASH_ROUNDS)
|
||||
return hash(value, { cost: HASH_ROUNDS })
|
||||
}
|
||||
|
||||
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(value, hash)
|
||||
}
|
||||
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
|
||||
return verify(value, storedHash)
|
||||
}
|
||||
|
||||
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
|
||||
// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le
|
||||
// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente.
|
||||
export function confirmsWithPassword(passwordHash: string | null | undefined): boolean {
|
||||
return Boolean(passwordHash) && env.PASSWORD_LOGIN
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { encode } from 'blurhash'
|
||||
import sharp from 'sharp'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
// Aperçus flous (BlurHash) : une courte chaîne par photo, que le navigateur décode en image floue aux bonnes
|
||||
// couleurs pendant que la vraie se charge. Les photos partent en taille réelle, sans miniature : sur un réseau
|
||||
// lent, une vignette restait un carré vide.
|
||||
//
|
||||
// Calculé à l'envoi d'une photo ou d'un portrait (routes/media.ts). Les autres chemins (Immich, import GEDCOM,
|
||||
// propositions acceptées) et les photos d'avant cette version sont rattrapés en tâche de fond. Une image
|
||||
// illisible reçoit une chaîne vide, pour ne pas être relue à chaque passage.
|
||||
|
||||
// Assez petit pour un calcul rapide ; l'aperçu garde les proportions de la photo
|
||||
const SAMPLE_MAX = 32
|
||||
const COMPONENTS_X = 4
|
||||
const COMPONENTS_Y = 3
|
||||
|
||||
export async function computeBlurhash(buf: Buffer): Promise<string | null> {
|
||||
try {
|
||||
const { data, info } = await sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' })
|
||||
.rotate()
|
||||
.resize(SAMPLE_MAX, SAMPLE_MAX, { fit: 'inside' })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
return encode(new Uint8ClampedArray(data), info.width, info.height, COMPONENTS_X, COMPONENTS_Y)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function hashStoredFile(storageRoot: string, relativePath: string): Promise<string> {
|
||||
const absolutePath = path.resolve(storageRoot, relativePath)
|
||||
if (!absolutePath.startsWith(storageRoot + path.sep)) return ''
|
||||
const buf = await readFile(absolutePath).catch(() => null)
|
||||
return (buf && await computeBlurhash(buf)) || ''
|
||||
}
|
||||
|
||||
const BATCH = 25
|
||||
const PAUSE_MS = 60_000
|
||||
|
||||
// Un lot de photos puis de portraits sans aperçu ; renvoie le nombre traité
|
||||
export async function fillMissingBlurhashes(app: FastifyInstance, storageRoot: string): Promise<number> {
|
||||
const medias = await app.prisma.mediaItem.findMany({
|
||||
where: { type: 'photo', blurhash: null, deletedAt: null, NOT: { mimeType: 'video/youtube' } },
|
||||
select: { id: true, filePath: true },
|
||||
take: BATCH,
|
||||
})
|
||||
for (const media of medias) {
|
||||
const blurhash = await hashStoredFile(storageRoot, media.filePath)
|
||||
await app.prisma.mediaItem.updateMany({ where: { id: media.id, blurhash: null }, data: { blurhash } })
|
||||
}
|
||||
|
||||
const persons = await app.prisma.person.findMany({
|
||||
where: { avatarPath: { not: null }, avatarBlurhash: null, deletedAt: null },
|
||||
select: { id: true, avatarPath: true },
|
||||
take: BATCH,
|
||||
})
|
||||
for (const person of persons) {
|
||||
const avatarBlurhash = await hashStoredFile(storageRoot, person.avatarPath as string)
|
||||
// Le portrait a pu changer pendant le calcul : on n'écrit que s'il est toujours le même
|
||||
await app.prisma.person.updateMany({ where: { id: person.id, avatarPath: person.avatarPath, avatarBlurhash: null }, data: { avatarBlurhash } })
|
||||
}
|
||||
|
||||
return medias.length + persons.length
|
||||
}
|
||||
|
||||
// Tâche de fond : enchaîne les lots tant qu'il en reste, puis repasse toutes les minutes
|
||||
export function startBlurhashFiller(app: FastifyInstance, storageRoot: string) {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
let stopped = false
|
||||
const run = async () => {
|
||||
let done = 0
|
||||
try {
|
||||
done = await fillMissingBlurhashes(app, storageRoot)
|
||||
} catch (error) {
|
||||
app.log.warn({ err: error }, 'aperçus flous : lot en échec')
|
||||
}
|
||||
if (!stopped) {
|
||||
timer = setTimeout(run, done >= BATCH ? 0 : PAUSE_MS)
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
timer = setTimeout(run, 5_000)
|
||||
timer.unref()
|
||||
app.addHook('onClose', async () => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
|
||||
|
||||
await db.person.updateMany({
|
||||
where: { id: personId, treeId, deletedAt: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, avatarBlurhash: null, immichPersonId: null },
|
||||
})
|
||||
|
||||
const unions = await db.union.findMany({
|
||||
@@ -47,7 +47,15 @@ export async function softDeletePersonCascade(
|
||||
|
||||
const medias = await db.mediaItem.findMany({
|
||||
where: { treeId, personId, deletedAt: null },
|
||||
select: { filePath: true, thumbPath: true },
|
||||
select: { id: true, filePath: true, thumbPath: true },
|
||||
})
|
||||
|
||||
// Identifications : la personne disparaît des photos des autres, et ses souvenirs n'ont plus personne
|
||||
await db.mediaPersonTag.deleteMany({
|
||||
where: {
|
||||
treeId,
|
||||
OR: [{ personId }, ...(medias.length > 0 ? [{ mediaId: { in: medias.map((media) => media.id) } }] : [])],
|
||||
},
|
||||
})
|
||||
|
||||
const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
// Envois rejoués sans doublon (en-tête Idempotency-Key). Sur un réseau faible, la famille renvoie une proposition
|
||||
// ou une photo dont la réponse s'est perdue : le serveur avait déjà tout créé, et l'arbre recevait deux fois la
|
||||
// même chose. Le navigateur tire une clé au début d'un envoi et la garde pour chaque nouvel essai ; le serveur
|
||||
// répond au second essai avec la réponse du premier, sans rien recréer. Deux essais simultanés : le second attend
|
||||
// le premier.
|
||||
//
|
||||
// En mémoire, sur une seule instance de l'API (le cas de Parenthèse et de l'auto-hébergement) : un redémarrage
|
||||
// oublie les clés, ce qui ne recrée de doublon que si un essai tombe pile pendant le redémarrage.
|
||||
|
||||
// Les créations faites par la famille : propositions, souvenirs, photos posées sur l'arbre, moments
|
||||
const IDEMPOTENT_ROUTES = new Set([
|
||||
'POST /trees/:id/contributions/sessions',
|
||||
'POST /trees/:id/persons/:personId/media',
|
||||
'POST /trees/:id/annotation-photos',
|
||||
'POST /trees/:id/persons/:personId/life/events',
|
||||
])
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000
|
||||
const MAX_ENTRIES = 5000
|
||||
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/
|
||||
|
||||
type Stored = { statusCode: number, contentType: string | undefined, payload: string }
|
||||
type Entry = { expires: number, done: Stored | null, settled: Promise<void>, settle: () => void }
|
||||
|
||||
const entries = new Map<string, Entry>()
|
||||
|
||||
function prune(now: number) {
|
||||
for (const [scope, entry] of entries) {
|
||||
if (entry.expires <= now) entries.delete(scope)
|
||||
}
|
||||
// Au-delà du plafond, les plus anciennes partent d'abord (la Map garde l'ordre d'insertion)
|
||||
while (entries.size > MAX_ENTRIES) {
|
||||
const oldest = entries.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
entries.get(oldest)?.settle()
|
||||
entries.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
// Une clé ne vaut que pour la même route, la même adresse et la même personne : celle d'un autre ne rejoue rien
|
||||
function scopeOf(request: FastifyRequest, key: string) {
|
||||
const who = createHash('sha256').update(request.headers.authorization ?? `ip:${request.ip}`).digest('hex').slice(0, 32)
|
||||
return `${request.method} ${request.url} ${who} ${key}`
|
||||
}
|
||||
|
||||
function replay(reply: FastifyReply, stored: Stored) {
|
||||
if (stored.contentType) reply.header('content-type', stored.contentType)
|
||||
return reply.code(stored.statusCode).header('idempotent-replayed', 'true').send(stored.payload)
|
||||
}
|
||||
|
||||
export function registerIdempotency(app: FastifyInstance) {
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (!IDEMPOTENT_ROUTES.has(`${request.method} ${request.routeOptions.url}`)) return
|
||||
const key = request.headers['idempotency-key']
|
||||
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return
|
||||
|
||||
const now = Date.now()
|
||||
prune(now)
|
||||
const scope = scopeOf(request, key)
|
||||
|
||||
// Un essai précédent est en cours : on attend sa fin, puis on rejoue sa réponse s'il a abouti
|
||||
for (let existing = entries.get(scope); existing; existing = entries.get(scope)) {
|
||||
if (existing.done) return replay(reply, existing.done)
|
||||
await existing.settled
|
||||
}
|
||||
|
||||
let settle = () => {}
|
||||
const settled = new Promise<void>((resolve) => { settle = resolve })
|
||||
entries.set(scope, { expires: now + TTL_MS, done: null, settled, settle })
|
||||
request.idempotencyScope = scope
|
||||
})
|
||||
|
||||
app.addHook('onSend', async (request, reply, payload) => {
|
||||
const scope = request.idempotencyScope
|
||||
if (!scope) return payload
|
||||
const entry = entries.get(scope)
|
||||
if (!entry) return payload
|
||||
// Seule une réussite se rejoue ; un échec laisse le prochain essai refaire la requête
|
||||
if (reply.statusCode < 300 && typeof payload === 'string') {
|
||||
const contentType = reply.getHeader('content-type')
|
||||
entry.done = { statusCode: reply.statusCode, contentType: typeof contentType === 'string' ? contentType : undefined, payload }
|
||||
} else {
|
||||
entries.delete(scope)
|
||||
}
|
||||
entry.settle()
|
||||
return payload
|
||||
})
|
||||
|
||||
// Requête coupée avant toute réponse : l'essai suivant ne doit pas attendre pour rien
|
||||
app.addHook('onResponse', async (request) => {
|
||||
const scope = request.idempotencyScope
|
||||
const entry = scope ? entries.get(scope) : undefined
|
||||
if (entry && !entry.done) {
|
||||
entries.delete(scope as string)
|
||||
entry.settle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
idempotencyScope?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
export function resetIdempotency() {
|
||||
entries.clear()
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { decryptImmichApiKey } from './immich-secret.js'
|
||||
import { sniffMimeType } from './media-files.js'
|
||||
|
||||
// Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes.
|
||||
// Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 :
|
||||
@@ -85,12 +86,14 @@ export class ImmichClient {
|
||||
return `${this.apiUrl}${path}`
|
||||
}
|
||||
|
||||
private async fetch(path: string): Promise<Response> {
|
||||
private async fetch(path: string, body?: unknown): Promise<Response> {
|
||||
const url = this.buildUrl(path)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { 'x-api-key': this.apiKey, Accept: 'application/json' },
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers: { 'x-api-key': this.apiKey, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS),
|
||||
// Aucune redirection suivie : la clé ne doit jamais partir vers une autre adresse.
|
||||
redirect: 'error',
|
||||
@@ -126,6 +129,28 @@ export class ImmichClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listAssets(page: number, type: 'IMAGE' | 'VIDEO', filters: { query?: string; personId?: string } = {}) {
|
||||
const query = filters.query?.trim()
|
||||
const response = await this.fetch(query ? '/api/search/smart' : '/api/search/metadata', {
|
||||
page, size: 30, type, isTrashed: false,
|
||||
...(query ? { query } : {}),
|
||||
...(filters.personId ? { personIds: [filters.personId] } : {}),
|
||||
})
|
||||
const payload = await this.readJson(response) as { assets?: { items?: Array<{ id: string; type: string; originalFileName: string }>; nextPage?: string | null } }
|
||||
if (!Array.isArray(payload.assets?.items)) throw new ImmichUnavailableError('Liste de médias Immich illisible')
|
||||
return {
|
||||
assets: payload.assets.items.filter((asset) => isImmichPersonId(asset.id) && asset.type === type).map((asset) => ({
|
||||
id: asset.id, type: asset.type, name: asset.originalFileName,
|
||||
})),
|
||||
hasNextPage: Boolean(payload.assets.nextPage),
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetBytes(assetId: string, thumbnail: boolean, maxBytes: number, preview = false): Promise<Buffer | null> {
|
||||
const variant = preview ? 'thumbnail?size=preview' : thumbnail ? 'thumbnail?size=thumbnail' : 'original'
|
||||
return this.readBytes(`/api/assets/${encodeURIComponent(assetId)}/${variant}`, maxBytes, 5 * 1024 * 1024)
|
||||
}
|
||||
|
||||
// Toutes les personnes (sans les masquées), en suivant la pagination d'Immich.
|
||||
async listPeople(): Promise<ImmichPersonSummary[]> {
|
||||
const people: ImmichPersonSummary[] = []
|
||||
@@ -178,9 +203,14 @@ export class ImmichClient {
|
||||
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
|
||||
// Lue en flux : au-delà de maxBytes, la lecture s'arrête et renvoie null sans charger le reste.
|
||||
async getPersonThumbnail(personId: string, maxBytes: number): Promise<Buffer | null> {
|
||||
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`)
|
||||
return this.readBytes(`/api/people/${encodeURIComponent(personId)}/thumbnail`, maxBytes)
|
||||
}
|
||||
|
||||
private async readBytes(path: string, maxBytes: number, maxImageBytes = maxBytes): Promise<Buffer | null> {
|
||||
const response = await this.fetch(path)
|
||||
let limit = response.headers.get('content-type')?.startsWith('image/') ? Math.min(maxBytes, maxImageBytes) : maxBytes
|
||||
const declared = Number(response.headers.get('content-length'))
|
||||
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||
if (Number.isFinite(declared) && declared > limit) {
|
||||
await response.body?.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
@@ -191,12 +221,19 @@ export class ImmichClient {
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Buffer[] = []
|
||||
let total = 0
|
||||
let signature = Buffer.alloc(0)
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
total += value.byteLength
|
||||
if (total > maxBytes) {
|
||||
// Ne pas se fier au Content-Type : même avec une signature répartie sur
|
||||
// plusieurs chunks, une image est bornée à 5 Mo dès ses premiers octets.
|
||||
if (signature.length < 12) {
|
||||
signature = Buffer.concat([signature, Buffer.from(value.subarray(0, 12 - signature.length))])
|
||||
if (sniffMimeType(signature, 'video')?.startsWith('image/')) limit = Math.min(limit, maxImageBytes)
|
||||
}
|
||||
if (total > limit || (Number.isFinite(declared) && declared > limit)) {
|
||||
await reader.cancel().catch(() => {})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat, unlink } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { FastifyReply } from 'fastify'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import sharp from 'sharp'
|
||||
|
||||
// Fichiers envoyés : le type vient des premiers octets, jamais de ce que déclare le navigateur.
|
||||
@@ -59,6 +60,9 @@ const EXTENSION_BY_MIME: Record<string, string> = {
|
||||
'application/gpx+xml': '.gpx',
|
||||
}
|
||||
|
||||
// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12)
|
||||
const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])
|
||||
|
||||
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
|
||||
if (buf.length < offset + bytes.length) return false
|
||||
return bytes.every((byte, index) => buf[offset + index] === byte)
|
||||
@@ -89,6 +93,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa
|
||||
if (ascii(buf, 4, 8) === 'ftyp') {
|
||||
const brand = ascii(buf, 8, 12)
|
||||
if (brand === 'qt ') return 'video/quicktime'
|
||||
// HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode
|
||||
// pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo.
|
||||
if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic'
|
||||
if (brand === 'avif' || brand === 'avis') return 'image/avif'
|
||||
if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4'
|
||||
return 'video/mp4'
|
||||
}
|
||||
@@ -228,3 +236,49 @@ export async function removeStoredFile(storageRoot: string, relativePath: string
|
||||
// déjà absent
|
||||
}
|
||||
}
|
||||
|
||||
// Plage demandée par « Range: bytes=début-fin » (une seule plage), bornée à la taille du fichier. null : pas de
|
||||
// plage lisible, on envoie tout ; 'unsatisfiable' : la plage tombe hors du fichier.
|
||||
export function parseByteRange(header: string | undefined, size: number): { start: number; end: number } | 'unsatisfiable' | null {
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim())
|
||||
if (!match || (match[1] === '' && match[2] === '')) return null
|
||||
if (match[1] === '') {
|
||||
// Les n derniers octets
|
||||
const suffix = Number(match[2])
|
||||
if (suffix === 0 || size === 0) return 'unsatisfiable'
|
||||
return { start: Math.max(0, size - suffix), end: size - 1 }
|
||||
}
|
||||
const start = Number(match[1])
|
||||
const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1)
|
||||
if (start >= size || end < start) return 'unsatisfiable'
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// Taille d'un fichier stocké, null s'il manque
|
||||
export async function storedFileSize(absolutePath: string): Promise<number | null> {
|
||||
try {
|
||||
const info = await stat(absolutePath)
|
||||
return info.isFile() ? info.size : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Envoie un fichier en flux, par morceaux si le navigateur le demande. Safari ne lit une vidéo que si le serveur
|
||||
// répond aux plages (206) ; avant, le fichier entier passait en mémoire puis partait d'un bloc. À appeler après
|
||||
// applyMediaResponseHeaders.
|
||||
export function sendFileWithRanges(request: FastifyRequest, reply: FastifyReply, absolutePath: string, size: number): FastifyReply {
|
||||
reply.header('Accept-Ranges', 'bytes')
|
||||
const range = parseByteRange(request.headers.range, size)
|
||||
if (range === 'unsatisfiable') {
|
||||
return reply.code(416).header('Content-Range', `bytes */${size}`).send()
|
||||
}
|
||||
if (range) {
|
||||
reply.code(206)
|
||||
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`)
|
||||
reply.header('Content-Length', String(range.end - range.start + 1))
|
||||
return reply.send(createReadStream(absolutePath, { start: range.start, end: range.end }))
|
||||
}
|
||||
reply.header('Content-Length', String(size))
|
||||
return reply.send(createReadStream(absolutePath))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { Prisma } from '../generated/prisma/client.js'
|
||||
|
||||
// Renumérote les souvenirs acceptés d'une personne et met en avant les trois premiers.
|
||||
// Les souvenirs en attente de relecture ne comptent pas.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
// Personnes identifiées sur un souvenir. La liste envoyée remplace la précédente : retirer quelqu'un, c'est
|
||||
// renvoyer la liste sans lui. Le point (x, y) est facultatif, en proportion de l'image (0 à 1), et va par paire.
|
||||
export const MAX_PEOPLE_PER_MEDIA = 50
|
||||
|
||||
const coordinate = z.number().min(0).max(1).nullable().optional()
|
||||
|
||||
export const mediaPersonSchema = z
|
||||
.object({
|
||||
personId: z.string().min(1).max(64),
|
||||
x: coordinate,
|
||||
y: coordinate,
|
||||
})
|
||||
.refine((value) => (value.x == null) === (value.y == null), { message: 'point_requires_x_and_y' })
|
||||
|
||||
export const mediaPeopleSchema = z
|
||||
.array(mediaPersonSchema)
|
||||
.max(MAX_PEOPLE_PER_MEDIA)
|
||||
.refine((people) => new Set(people.map((person) => person.personId)).size === people.length, { message: 'duplicate_person' })
|
||||
|
||||
export type MediaPerson = { personId: string; x: number | null; y: number | null }
|
||||
|
||||
export function normalizeMediaPeople(people: z.infer<typeof mediaPeopleSchema>): MediaPerson[] {
|
||||
return people.map((person) => ({ personId: person.personId, x: person.x ?? null, y: person.y ?? null }))
|
||||
}
|
||||
|
||||
export async function readMediaPeople(db: Db, treeId: string, mediaId: string): Promise<MediaPerson[]> {
|
||||
const tags = await db.mediaPersonTag.findMany({
|
||||
where: { treeId, mediaId },
|
||||
select: { personId: true, x: true, y: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
return tags
|
||||
}
|
||||
|
||||
// Remplace les personnes d'un souvenir. Une personne supprimée entre l'envoi et la relecture est écartée
|
||||
// sans faire échouer le reste. Renvoie la liste réellement enregistrée.
|
||||
export async function replaceMediaPeople(
|
||||
db: Db,
|
||||
treeId: string,
|
||||
mediaId: string,
|
||||
people: MediaPerson[],
|
||||
actorUserId: string | null,
|
||||
): Promise<MediaPerson[]> {
|
||||
const ids = people.map((person) => person.personId)
|
||||
const existing = ids.length
|
||||
? await db.person.findMany({ where: { treeId, id: { in: ids }, deletedAt: null }, select: { id: true } })
|
||||
: []
|
||||
const alive = new Set(existing.map((person) => person.id))
|
||||
const kept = people.filter((person) => alive.has(person.personId))
|
||||
|
||||
await db.mediaPersonTag.deleteMany({ where: { treeId, mediaId } })
|
||||
if (kept.length) {
|
||||
// Une date par rang : l'ordre d'affichage suit celui de la liste envoyée
|
||||
const base = Date.now()
|
||||
await db.mediaPersonTag.createMany({
|
||||
data: kept.map((person, index) => ({
|
||||
treeId,
|
||||
mediaId,
|
||||
personId: person.personId,
|
||||
x: person.x,
|
||||
y: person.y,
|
||||
createdBy: actorUserId,
|
||||
createdAt: new Date(base + index),
|
||||
})),
|
||||
})
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export type OidcPending = {
|
||||
nonce: string
|
||||
codeVerifier: string
|
||||
returnTo: string
|
||||
// Rattachement demandé depuis un compte connecté (ticket de /auth/oidc/link) : l'identité du fournisseur rejoint
|
||||
// ce compte, au lieu d'en chercher un par email
|
||||
linkUserId?: string
|
||||
}
|
||||
|
||||
export type OidcIdentity = {
|
||||
@@ -50,9 +53,10 @@ export function appBaseUrl(): string {
|
||||
return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Seules la racine et l'adresse d'un arbre partagé sont acceptées comme retour : pas de redirection ouverte
|
||||
// Seules la racine, l'adresse d'un arbre partagé et celle d'une invitation à co-gérer un arbre sont acceptées
|
||||
// comme retour : pas de redirection ouverte
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64})?$/.test(value) ? value : '/'
|
||||
return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64}|invitation\/[A-Za-z0-9_-]{20,100})?$/.test(value) ? value : '/'
|
||||
}
|
||||
|
||||
export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import pkg from '@prisma/client'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
const { PrismaClient } = pkg
|
||||
|
||||
// Prisma 7 passe par le pilote pg. Délai de connexion de 5 s, comme le moteur de Prisma 6 (pg attend sans fin par défaut)
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: {
|
||||
db: {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
},
|
||||
adapter: new PrismaPg({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 5_000 }),
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
@@ -12,11 +12,14 @@ export async function isRegistrationOpen(prisma: PrismaClient): Promise<boolean>
|
||||
// Seul chemin de création d'un compte. Inscription fermée : le compte n'est créé que si la base n'en a aucun,
|
||||
// sous un verrou de transaction pour que deux inscriptions simultanées sur une instance neuve n'en créent pas
|
||||
// deux. Renvoie null quand la création est refusée.
|
||||
// invited : l'appelant a vérifié une invitation à co-gérer un arbre pour cette adresse (issue #145) ; le
|
||||
// propriétaire de l'arbre a été admis par l'admin du serveur, son invitation ouvre un compte.
|
||||
export async function createAccountIfAllowed<T>(
|
||||
prisma: PrismaClient,
|
||||
create: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
{ invited = false }: { invited?: boolean } = {},
|
||||
): Promise<T | null> {
|
||||
if (env.ALLOW_REGISTRATION) return create(prisma)
|
||||
if (env.ALLOW_REGISTRATION || invited) return create(prisma)
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Clé arbitraire, propre à la création de compte. pg_advisory_xact_lock renvoie void : $executeRaw, pas $queryRaw.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Sources publiques interrogées par le panneau « Chercher quelqu'un » (SEARCH_SOURCES). Chaque source
|
||||
// reçoit la même question et rend des résultats au même format, que le front affiche sans savoir d'où
|
||||
// ils viennent. Ajouter une source : un objet de plus dans SOURCES, avec ce qu'elle envoie au service.
|
||||
|
||||
export type SearchQuery = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthYear?: number
|
||||
place?: string
|
||||
}
|
||||
|
||||
export type SearchEvent = { date: string | null; place: string | null }
|
||||
|
||||
export type SearchResult = {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
sex: 'M' | 'F' | null
|
||||
birth: SearchEvent
|
||||
death: SearchEvent
|
||||
url: string
|
||||
// good : prénom, nom, année et lieu de naissance concordent
|
||||
match: 'good' | 'partial'
|
||||
}
|
||||
|
||||
type Source = {
|
||||
id: string
|
||||
search: (query: SearchQuery) => Promise<SearchResult[]>
|
||||
}
|
||||
|
||||
export class SearchSourceUnavailableError extends Error {}
|
||||
|
||||
const TIMEOUT_MS = 10_000
|
||||
const MAX_RESULTS = 10
|
||||
const USER_AGENT = 'Parenthese (+https://parenthese.io)'
|
||||
|
||||
export function normalizeName(value: string): string {
|
||||
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z]+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// « 18980824 » ou « 1898-08-24 » → « 1898-08-24 » ; « 0000-00-00 » ou mois inconnu → année seule
|
||||
function toIsoDate(raw: unknown): string | null {
|
||||
const digits = String(raw ?? '').replace(/-/g, '')
|
||||
if (!/^\d{8}$/.test(digits) || digits.startsWith('0000')) return null
|
||||
const [year, month, day] = [digits.slice(0, 4), digits.slice(4, 6), digits.slice(6, 8)]
|
||||
if (month === '00') return year
|
||||
if (day === '00') return `${year}-${month}`
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function yearOf(date: string | null): number | null {
|
||||
return date ? Number(date.slice(0, 4)) : null
|
||||
}
|
||||
|
||||
function grade(query: SearchQuery, result: Omit<SearchResult, 'match'>): SearchResult['match'] {
|
||||
const sameLast = normalizeName(result.lastName) === normalizeName(query.lastName)
|
||||
const firstNames = normalizeName(result.firstName).split(' ')
|
||||
const sameFirst = firstNames.includes(normalizeName(query.firstName).split(' ')[0] ?? '')
|
||||
const birthYear = yearOf(result.birth.date)
|
||||
const sameYear = !query.birthYear || (birthYear !== null && Math.abs(birthYear - query.birthYear) <= 1)
|
||||
// Sans lieu, un nom et une année courants ramènent des homonymes : seul le lieu de naissance départage
|
||||
const samePlace = Boolean(query.place && result.birth.place
|
||||
&& normalizeName(result.birth.place).startsWith(normalizeName(query.place)))
|
||||
return sameLast && sameFirst && sameYear && samePlace ? 'good' : 'partial'
|
||||
}
|
||||
|
||||
async function fetchJson(url: URL, headers: Record<string, string> = {}): Promise<unknown> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) })
|
||||
} catch {
|
||||
throw new SearchSourceUnavailableError('unreachable')
|
||||
}
|
||||
// matchID répond 401 ou 422 quand la recherche sans jeton est épuisée ou que le jeton a expiré
|
||||
if (response.status === 401 || response.status === 422) throw new SearchSourceUnavailableError('quota')
|
||||
if (!response.ok) throw new SearchSourceUnavailableError(`http_${response.status}`)
|
||||
try {
|
||||
return await response.json()
|
||||
} catch {
|
||||
throw new SearchSourceUnavailableError('invalid_json')
|
||||
}
|
||||
}
|
||||
|
||||
// matchID demande une recherche par seconde au plus : les appels de toute l'instance passent l'un après l'autre
|
||||
let matchIdQueue: Promise<unknown> = Promise.resolve()
|
||||
const MATCHID_INTERVAL_MS = 1100
|
||||
|
||||
function throttled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const next = matchIdQueue.then(run, run)
|
||||
matchIdQueue = next.then(
|
||||
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
|
||||
() => new Promise((resolve) => setTimeout(resolve, MATCHID_INTERVAL_MS)),
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
type MatchIdLocation = { city?: string | string[]; departmentCode?: string; country?: string }
|
||||
type MatchIdPerson = {
|
||||
id: string
|
||||
name?: { first?: string[] | string; last?: string }
|
||||
sex?: string
|
||||
birth?: { date?: string; location?: MatchIdLocation }
|
||||
death?: { date?: string; location?: MatchIdLocation }
|
||||
}
|
||||
|
||||
function matchIdPlace(location: MatchIdLocation | undefined): string | null {
|
||||
if (!location) return null
|
||||
const city = Array.isArray(location.city) ? location.city[0] : location.city
|
||||
if (!city) return location.country || null
|
||||
if (location.country && location.country !== 'France') return `${city}, ${location.country}`
|
||||
return location.departmentCode ? `${city} (${location.departmentCode})` : city
|
||||
}
|
||||
|
||||
// Décès en France depuis 1970 : fichier de l'INSEE, cherché par matchID (deces.matchid.io)
|
||||
const deces: Source = {
|
||||
id: 'deces',
|
||||
search: (query) => throttled(async () => {
|
||||
const url = new URL('https://deces.matchid.io/deces/api/v1/search')
|
||||
url.searchParams.set('firstName', query.firstName)
|
||||
url.searchParams.set('lastName', query.lastName)
|
||||
if (query.birthYear) url.searchParams.set('birthDate', String(query.birthYear))
|
||||
url.searchParams.set('size', String(MAX_RESULTS))
|
||||
const headers: Record<string, string> = env.MATCHID_TOKEN ? { Authorization: `Bearer ${env.MATCHID_TOKEN}` } : {}
|
||||
const payload = await fetchJson(url, headers) as { response?: { persons?: MatchIdPerson[] } }
|
||||
const persons = payload?.response?.persons ?? []
|
||||
return persons.map((person) => {
|
||||
const first = person.name?.first
|
||||
const base: Omit<SearchResult, 'match'> = {
|
||||
id: person.id,
|
||||
firstName: Array.isArray(first) ? first.join(' ') : first ?? '',
|
||||
lastName: person.name?.last ?? '',
|
||||
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
|
||||
birth: { date: toIsoDate(person.birth?.date), place: matchIdPlace(person.birth?.location) },
|
||||
death: { date: toIsoDate(person.death?.date), place: matchIdPlace(person.death?.location) },
|
||||
url: `https://deces.matchid.io/id/${encodeURIComponent(person.id)}`,
|
||||
}
|
||||
return { ...base, match: grade(query, base) }
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
type WikiTreeMatch = {
|
||||
Id: number
|
||||
Name: string
|
||||
FirstName?: string
|
||||
MiddleName?: string
|
||||
LastNameAtBirth?: string
|
||||
Gender?: string
|
||||
BirthDate?: string
|
||||
DeathDate?: string
|
||||
BirthLocation?: string
|
||||
DeathLocation?: string
|
||||
}
|
||||
|
||||
// WikiTree : arbre mondial tenu par ses membres, profils publics seulement (api.wikitree.com)
|
||||
const wikitree: Source = {
|
||||
id: 'wikitree',
|
||||
search: async (query) => {
|
||||
const url = new URL('https://api.wikitree.com/api.php')
|
||||
url.searchParams.set('action', 'searchPerson')
|
||||
url.searchParams.set('appId', 'parenthese')
|
||||
url.searchParams.set('FirstName', query.firstName)
|
||||
url.searchParams.set('LastName', query.lastName)
|
||||
if (query.birthYear) {
|
||||
url.searchParams.set('BirthDate', `${query.birthYear}-01-01`)
|
||||
url.searchParams.set('dateSpread', '2')
|
||||
}
|
||||
url.searchParams.set('limit', String(MAX_RESULTS))
|
||||
const payload = await fetchJson(url) as [{ matches?: WikiTreeMatch[] }] | undefined
|
||||
const matches = Array.isArray(payload) ? payload[0]?.matches ?? [] : []
|
||||
// La recherche de WikiTree est floue (« du Pont » pour Dupont, autres prénoms) : même nom de famille seulement
|
||||
const wantedLast = normalizeName(query.lastName).replace(/ /g, '')
|
||||
return matches.filter((match) => normalizeName(match.LastNameAtBirth ?? '').replace(/ /g, '') === wantedLast).map((match) => {
|
||||
const base: Omit<SearchResult, 'match'> = {
|
||||
id: String(match.Id),
|
||||
firstName: [match.FirstName, match.MiddleName].filter(Boolean).join(' '),
|
||||
lastName: match.LastNameAtBirth ?? '',
|
||||
sex: match.Gender === 'Male' ? 'M' : match.Gender === 'Female' ? 'F' : null,
|
||||
birth: { date: toIsoDate(match.BirthDate), place: match.BirthLocation || null },
|
||||
death: { date: toIsoDate(match.DeathDate), place: match.DeathLocation || null },
|
||||
url: `https://www.wikitree.com/wiki/${encodeURIComponent(match.Name)}`,
|
||||
}
|
||||
return { ...base, match: grade(query, base) }
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const SOURCES: Source[] = [deces, wikitree]
|
||||
|
||||
// Sources allumées par l'opérateur, dans l'ordre de SOURCES ; un nom inconnu est signalé puis ignoré
|
||||
export function enabledSearchSources(): string[] {
|
||||
const wanted = env.SEARCH_SOURCES.split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean)
|
||||
for (const name of wanted) {
|
||||
if (!SOURCES.some((source) => source.id === name)) console.warn(`SEARCH_SOURCES : source inconnue ignorée : ${name}`)
|
||||
}
|
||||
return SOURCES.filter((source) => wanted.includes(source.id)).map((source) => source.id)
|
||||
}
|
||||
|
||||
export async function searchSource(id: string, query: SearchQuery): Promise<SearchResult[]> {
|
||||
const source = SOURCES.find((candidate) => candidate.id === id)
|
||||
if (!source) throw new SearchSourceUnavailableError('unknown_source')
|
||||
const results = await source.search(query)
|
||||
// Les meilleures concordances d'abord, l'ordre du service ensuite
|
||||
return results.slice(0, MAX_RESULTS).sort((a, b) => Number(b.match === 'good') - Number(a.match === 'good'))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
type LinkReader = Pick<PrismaClient, 'parentChildLink'> | Pick<Prisma.TransactionClient, 'parentChildLink'>
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
// Sessions fermées par « Se déconnecter » ou par la suppression du compte, jusqu'à la fin de leur jeton.
|
||||
// Gardées en mémoire pour ne coûter aucune requête par appel, et en base pour survivre au redémarrage de l'API :
|
||||
// avant, chaque déploiement les oubliait et un jeton déconnecté redevenait valable jusqu'à 7 jours.
|
||||
// L'API tourne en un seul processus (pm2 fork, un conteneur) : la mémoire suffit entre deux redémarrages.
|
||||
const revokedUserTokens = new Map<string, number>()
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
export function revokeUserToken(jti: string, exp: number): void {
|
||||
type RevocationStore = Pick<PrismaClient, 'revokedSession'>
|
||||
|
||||
export async function revokeUserToken(store: RevocationStore, jti: string, exp: number): Promise<void> {
|
||||
revokedUserTokens.set(jti, exp)
|
||||
const expiresAt = new Date(exp * 1000)
|
||||
await store.revokedSession.upsert({ where: { jti }, create: { jti, expiresAt }, update: {} })
|
||||
}
|
||||
|
||||
export function isUserTokenRevoked(jti: string): boolean {
|
||||
@@ -22,3 +32,21 @@ export function isUserTokenRevoked(jti: string): boolean {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Au démarrage : les déconnexions encore valables reviennent en mémoire, les expirées sont effacées. L'effacement
|
||||
// n'a lieu qu'ici : la table grossit entre deux redémarrages, d'une ligne par déconnexion, ce qui reste minuscule
|
||||
// avec un déploiement par jour ou presque.
|
||||
export async function loadRevokedTokens(store: RevocationStore): Promise<number> {
|
||||
const now = new Date()
|
||||
await store.revokedSession.deleteMany({ where: { expiresAt: { lte: now } } })
|
||||
const rows = await store.revokedSession.findMany({ where: { expiresAt: { gt: now } }, select: { jti: true, expiresAt: true } })
|
||||
for (const row of rows) {
|
||||
revokedUserTokens.set(row.jti, Math.floor(row.expiresAt.getTime() / 1000))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// Tests : repartir d'un processus neuf, comme après un redémarrage
|
||||
export function forgetRevokedTokensInMemory(): void {
|
||||
revokedUserTokens.clear()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
// Co-gestion d'un arbre (issue #145). Le propriétaire invite une adresse ; Parenthèse n'envoie pas de mail, il
|
||||
// donne un lien à transmettre. Seule l'empreinte du jeton est gardée en base.
|
||||
//
|
||||
// Qui est rattaché, et comment :
|
||||
// - par le lien, avec un compte à la même adresse (mot de passe ou OIDC). L'inscription par mot de passe ne
|
||||
// vérifie pas l'adresse : le lien est la preuve, l'adresse seule ne suffit jamais ;
|
||||
// - sans le lien, seulement par OIDC, quand le fournisseur dit l'email vérifié (claimInvitationsForVerifiedEmail).
|
||||
|
||||
export const INVITATION_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
||||
export const MAX_PENDING_INVITATIONS = 20
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
export function newInvitationToken(): { token: string; tokenHash: string } {
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
return { token, tokenHash: hashInvitationToken(token) }
|
||||
}
|
||||
|
||||
export function hashInvitationToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
|
||||
// Invitation encore valable : ni acceptée, ni annulée, ni expirée
|
||||
export function pendingInvitationWhere(now = new Date()): Prisma.TreeInvitationWhereInput {
|
||||
return { acceptedAt: null, revokedAt: null, expiresAt: { gt: now }, tree: { deletedAt: null } }
|
||||
}
|
||||
|
||||
export async function findPendingInvitationByToken(db: Db, token: string) {
|
||||
if (!token || token.length > 200) return null
|
||||
return db.treeInvitation.findFirst({
|
||||
where: { tokenHash: hashInvitationToken(token), ...pendingInvitationWhere() },
|
||||
include: { tree: { select: { id: true, name: true, icon: true, ownerUserId: true, owner: { select: { firstName: true } } } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function hasPendingInvitationForEmail(db: Db, email: string): Promise<boolean> {
|
||||
const found = await db.treeInvitation.findFirst({ where: { email: normalizeEmail(email), ...pendingInvitationWhere() }, select: { id: true } })
|
||||
return Boolean(found)
|
||||
}
|
||||
|
||||
export type AcceptResult = { ok: true; treeId: string } | { ok: false; error: 'invitation_invalid' | 'invitation_email_mismatch' | 'already_owner' }
|
||||
|
||||
// Accepte une invitation pour ce compte : co-gestionnaire (admin) de l'arbre. L'invitation ne sert qu'une fois,
|
||||
// même pour deux acceptations simultanées (updateMany conditionnel dans la transaction).
|
||||
export async function acceptInvitation(prisma: PrismaClient, invitationId: string, userId: string, userEmail: string): Promise<AcceptResult> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const invitation = await tx.treeInvitation.findFirst({
|
||||
where: { id: invitationId, ...pendingInvitationWhere() },
|
||||
select: { id: true, treeId: true, email: true, tree: { select: { ownerUserId: true } } },
|
||||
})
|
||||
if (!invitation) return { ok: false, error: 'invitation_invalid' } as const
|
||||
if (normalizeEmail(userEmail) !== invitation.email) return { ok: false, error: 'invitation_email_mismatch' } as const
|
||||
if (invitation.tree.ownerUserId === userId) return { ok: false, error: 'already_owner' } as const
|
||||
|
||||
const claimed = await tx.treeInvitation.updateMany({
|
||||
where: { id: invitation.id, ...pendingInvitationWhere() },
|
||||
data: { acceptedAt: new Date(), acceptedUserId: userId },
|
||||
})
|
||||
if (claimed.count !== 1) return { ok: false, error: 'invitation_invalid' } as const
|
||||
|
||||
const existing = await tx.treeMembership.findUnique({
|
||||
where: { treeId_userId: { treeId: invitation.treeId, userId } },
|
||||
select: { role: true },
|
||||
})
|
||||
if (!existing) {
|
||||
await tx.treeMembership.create({ data: { treeId: invitation.treeId, userId, role: 'admin' } })
|
||||
} else if (existing.role === 'member') {
|
||||
await tx.treeMembership.update({ where: { treeId_userId: { treeId: invitation.treeId, userId } }, data: { role: 'admin' } })
|
||||
}
|
||||
// L'arbre s'ouvre à la prochaine visite
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastOpenedTreeId: invitation.treeId, lastOpenedAccessMode: 'member', lastOpenedRole: 'admin', lastOpenedAt: new Date() },
|
||||
})
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId: invitation.treeId,
|
||||
actorType: 'user',
|
||||
actorId: userId,
|
||||
action: 'manager_joined',
|
||||
entityType: 'tree',
|
||||
entityId: invitation.treeId,
|
||||
payloadJson: { invitationId: invitation.id },
|
||||
},
|
||||
})
|
||||
return { ok: true, treeId: invitation.treeId } as const
|
||||
})
|
||||
}
|
||||
|
||||
// Connexion OIDC avec un email vérifié par le fournisseur : les invitations en attente à cette adresse sont
|
||||
// acceptées sans le lien. Jamais pour un email non vérifié, jamais pour une connexion par mot de passe, et
|
||||
// seulement si c'est l'adresse du compte : une identité déjà reliée dont l'email a changé chez le fournisseur
|
||||
// ne prend pas les invitations faites à sa nouvelle adresse (elle passe par le lien).
|
||||
export async function claimInvitationsForVerifiedEmail(prisma: PrismaClient, userId: string, verifiedEmail: string): Promise<string[]> {
|
||||
const email = normalizeEmail(verifiedEmail)
|
||||
const invitations = await prisma.treeInvitation.findMany({ where: { email, ...pendingInvitationWhere() }, select: { id: true } })
|
||||
if (!invitations.length) return []
|
||||
const account = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } })
|
||||
if (!account || normalizeEmail(account.email) !== email) return []
|
||||
const joined: string[] = []
|
||||
for (const invitation of invitations) {
|
||||
const result = await acceptInvitation(prisma, invitation.id, userId, email)
|
||||
if (result.ok) joined.push(result.treeId)
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
// « jacques@exemple.fr » devient « ja•••@exemple.fr » sur la page publique du lien
|
||||
export function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!domain) return '•••'
|
||||
return `${local.slice(0, Math.min(2, local.length))}•••@${domain}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
|
||||
@@ -50,9 +50,9 @@ export function resetTreeUnlockGuard(): void {
|
||||
attemptsByTree.clear()
|
||||
}
|
||||
|
||||
// bcryptjs calcule en JavaScript sur le processus de l'API (≈ 250 ms par comparaison au coût 12) : des essais
|
||||
// lancés en parallèle depuis quelques adresses suffisaient à le saturer pour toutes les familles. Les
|
||||
// comparaisons de mot de passe de partage passent une par une, le reste de l'API garde la main entre deux.
|
||||
// Une comparaison bcrypt coûte ≈ 250 ms au coût 12. Même calculée hors de la boucle (bcrypt natif), des essais
|
||||
// lancés en parallèle depuis quelques adresses occuperaient tous les fils de calcul, connexions comprises. Les
|
||||
// comparaisons de mot de passe de partage passent une par une.
|
||||
// Au-delà de MAX_WAITING comparaisons en attente, on refuse tout de suite (null) plutôt que d'allonger la file.
|
||||
const MAX_WAITING = 20
|
||||
let queue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
|
||||
+52
-20
@@ -3,9 +3,10 @@ import { randomUUID } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
|
||||
import { acceptInvitation, findPendingInvitationByToken } from '../lib/tree-invitations.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
import { revokeUserToken } from '../lib/token-revocation.js'
|
||||
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
|
||||
@@ -27,6 +28,8 @@ const credentialsSchema = z.object({
|
||||
// Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas)
|
||||
const registerSchema = credentialsSchema.extend({
|
||||
firstName: z.string().max(60).optional().nullable(),
|
||||
// Lien d'invitation à co-gérer un arbre : ouvre un compte même inscription fermée, à l'adresse invitée seulement
|
||||
invitationToken: z.string().min(1).max(200).optional(),
|
||||
})
|
||||
|
||||
function trimmedFirstName(value: string | null | undefined): string | null {
|
||||
@@ -106,11 +109,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get(
|
||||
'/options',
|
||||
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
|
||||
async (_request, reply) => reply.send({
|
||||
registration: await isRegistrationOpen(app.prisma),
|
||||
passwordLogin: env.PASSWORD_LOGIN,
|
||||
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
|
||||
}),
|
||||
async (_request, reply) => {
|
||||
const registration = await isRegistrationOpen(app.prisma)
|
||||
return reply.send({
|
||||
registration,
|
||||
passwordLogin: env.PASSWORD_LOGIN,
|
||||
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
|
||||
// Rien à proposer quand l'instance ne crée plus de compte
|
||||
signupPrompts: env.SIGNUP_PROMPTS && registration && env.PASSWORD_LOGIN,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
@@ -134,12 +142,20 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(403).send({ error: 'password_login_disabled' })
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase()
|
||||
const invitation = parsed.data.invitationToken ? await findPendingInvitationByToken(app.prisma, parsed.data.invitationToken) : null
|
||||
if (parsed.data.invitationToken && !invitation) {
|
||||
return reply.code(410).send({ error: 'invitation_invalid' })
|
||||
}
|
||||
if (invitation && invitation.email !== email) {
|
||||
return reply.code(403).send({ error: 'invitation_email_mismatch' })
|
||||
}
|
||||
|
||||
// Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte
|
||||
if (!(await isRegistrationOpen(app.prisma))) {
|
||||
if (!invitation && !(await isRegistrationOpen(app.prisma))) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase()
|
||||
const existing = await app.prisma.user.findUnique({ where: { email } })
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: 'email_already_exists' })
|
||||
@@ -157,13 +173,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
email: true,
|
||||
firstName: true,
|
||||
},
|
||||
}))
|
||||
}), { invited: Boolean(invitation) })
|
||||
// Un autre compte est né pendant le calcul du hash, sur une instance fermée
|
||||
if (!user) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } })
|
||||
// Compte ouvert par une invitation : co-gestionnaire de l'arbre tout de suite
|
||||
let joinedTreeId: string | null = null
|
||||
if (invitation) {
|
||||
const accepted = await acceptInvitation(app.prisma, invitation.id, user.id, user.email)
|
||||
if (accepted.ok) {
|
||||
joinedTreeId = accepted.treeId
|
||||
} else if (!(await isRegistrationOpen(app.prisma))) {
|
||||
// Invitation annulée ou servie entre-temps, sur une instance fermée : pas de compte sans arbre
|
||||
await app.prisma.user.delete({ where: { id: user.id } })
|
||||
return reply.code(410).send({ error: 'invitation_invalid' })
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true }, joinedTreeId })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -230,14 +259,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: request.actor.userId },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) } })
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe.
|
||||
// oidcLinked : le compte se connecte déjà par le fournisseur ; sinon le compte propose « Relier à … »
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash), oidcLinked: (user.identities?.length ?? 0) > 0 } })
|
||||
})
|
||||
|
||||
app.get('/me/last-tree', async (request, reply) => {
|
||||
@@ -379,17 +409,19 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number; iat?: number }>()
|
||||
|
||||
if (!user.passwordHash) {
|
||||
// Compte né d'une connexion OIDC : pas de mot de passe à redonner. Il retape son email, et la session doit
|
||||
// venir d'une connexion récente chez le fournisseur (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const withPassword = confirmsWithPassword(user.passwordHash)
|
||||
if (!withPassword) {
|
||||
// Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à
|
||||
// redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur
|
||||
// (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity
|
||||
if (sessionAgeMs > RECENT_SIGN_IN_MS) {
|
||||
return reply.code(403).send({ error: 'recent_sign_in_required' })
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = user.passwordHash
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash)
|
||||
const isValid = withPassword
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string)
|
||||
: (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email
|
||||
if (!isValid) {
|
||||
return reply.code(403).send({ error: 'invalid_password' })
|
||||
@@ -424,7 +456,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
|
||||
if (payload.kind === 'user' && typeof payload.exp === 'number') {
|
||||
revokeUserToken(payload.jti, payload.exp)
|
||||
await revokeUserToken(app.prisma, payload.jti, payload.exp)
|
||||
}
|
||||
|
||||
await removeTreeMediaDirectories(ownedTreeIds, request.log)
|
||||
@@ -444,7 +476,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
if (typeof payload.exp === 'number') {
|
||||
revokeUserToken(payload.jti, payload.exp)
|
||||
await revokeUserToken(app.prisma, payload.jti, payload.exp)
|
||||
}
|
||||
|
||||
return reply.send({ ok: true })
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
|
||||
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
|
||||
import { AnnotationChangeError, applyAnnotationChange, normalizedAnnotationAfter, releaseRejectedPhotos, validateAnnotationChange } from '../lib/annotation-changes.js'
|
||||
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
|
||||
import { removeStoredFile } from '../lib/media-files.js'
|
||||
import { canReadTree } from '../lib/tree-access.js'
|
||||
import { syncPersonMediaOrder } from '../lib/media-order.js'
|
||||
import { mediaPeopleSchema, normalizeMediaPeople, replaceMediaPeople } from '../lib/media-people.js'
|
||||
|
||||
import type { Actor, MembershipRole } from '../types/auth.js'
|
||||
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
|
||||
@@ -31,6 +34,8 @@ const sessionParamsSchema = z.object({
|
||||
// remplissait la base à 12 Mo par envoi
|
||||
const MAX_CHANGE_JSON_CHARS = 64_000
|
||||
const MAX_PENDING_SESSIONS_PER_TREE = 200
|
||||
// Annotations proposées avant la 1.5.1 : écartées à la relecture si elles ne se posent pas
|
||||
const SKIPPABLE_ANNOTATION_ERRORS = new Set(['invalid_annotation_change', 'invalid_annotation_photo_path', 'unsupported_change_action_for_annotation'])
|
||||
const boundedJson = z.unknown().optional().refine(
|
||||
(value) => value === undefined || JSON.stringify(value).length <= MAX_CHANGE_JSON_CHARS,
|
||||
{ message: 'change_too_large' },
|
||||
@@ -77,6 +82,7 @@ const personCreateSchema = z.object({
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
toVerify: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
@@ -95,6 +101,7 @@ const personUpdateSchema = z
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
toVerify: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
@@ -155,9 +162,10 @@ const reorderSchema = z.object({
|
||||
|
||||
type ContributionStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
// Les souvenirs et les moments arrivent par leurs propres routes, jamais par une session envoyée en bloc
|
||||
// Les souvenirs, les moments et les personnes d'un souvenir arrivent par leurs propres routes, jamais par une
|
||||
// session envoyée en bloc
|
||||
type ParsedChange = Omit<z.infer<typeof contributionChangeSchema>, 'entityType'> & {
|
||||
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event'
|
||||
entityType: z.infer<typeof contributionChangeSchema>['entityType'] | 'life_event' | 'media_people'
|
||||
}
|
||||
|
||||
type TxClient = Prisma.TransactionClient
|
||||
@@ -190,7 +198,7 @@ function recordTempRef(after: unknown, createdId: string, refs: RefMap) {
|
||||
}
|
||||
|
||||
// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle
|
||||
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
|
||||
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4, media_people: 5 }
|
||||
function byApplicationOrder<T extends { entityType: string; action: string }>(changes: T[]): T[] {
|
||||
const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1)
|
||||
return changes.map((change, index) => ({ change, index }))
|
||||
@@ -242,7 +250,7 @@ function stripAnnotationUrl<T>(value: T): T {
|
||||
|
||||
function toPrismaJson(
|
||||
value: unknown,
|
||||
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull | undefined {
|
||||
): Prisma.InputJsonValue | typeof Prisma.DbNull | typeof Prisma.JsonNull | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
@@ -321,10 +329,15 @@ async function applyContributionChange(
|
||||
// Fichiers à effacer une fois la transaction validée (suppression d'une personne)
|
||||
filesToRemove: string[] = [],
|
||||
): Promise<{ entityId: string | null }> {
|
||||
// Les annotations sont appliquées par le client après la relecture (batchAnnotations) :
|
||||
// rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture.
|
||||
// Annotation (dessin, texte, sticker, photo posée sur l'arbre) : posée ici, dans la transaction qui accepte
|
||||
// la proposition. Avant la 1.5.1, le navigateur du relecteur la posait après coup, et pouvait la perdre.
|
||||
if (change.entityType === 'annotation') {
|
||||
return { entityId: change.entityId ?? null }
|
||||
try {
|
||||
return await applyAnnotationChange(tx, treeId, { action: change.action, entityId: change.entityId, after: change.after }, actorUserId)
|
||||
} catch (error) {
|
||||
if (error instanceof AnnotationChangeError) throw new ContributionRouteError(400, error.code)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Moment proposé par la famille : il existe déjà, on le rend visible
|
||||
@@ -342,6 +355,26 @@ async function applyContributionChange(
|
||||
return { entityId: change.entityId }
|
||||
}
|
||||
|
||||
// Personnes identifiées sur un souvenir : la liste proposée remplace l'actuelle. Souvenir supprimé entre
|
||||
// l'envoi et la relecture : rien à faire, la relecture continue.
|
||||
if (change.entityType === 'media_people') {
|
||||
if (change.action !== 'update' || !change.entityId) {
|
||||
throw new ContributionRouteError(400, 'unsupported_change_action_for_media_people')
|
||||
}
|
||||
const people = mediaPeopleSchema.safeParse(isRecord(change.after) ? change.after.people : undefined)
|
||||
if (!people.success) {
|
||||
throw new ContributionRouteError(400, 'invalid_media_people')
|
||||
}
|
||||
const media = await tx.mediaItem.findFirst({
|
||||
where: { id: change.entityId, treeId, status: 'approved', deletedAt: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (media) {
|
||||
await replaceMediaPeople(tx, treeId, change.entityId, normalizeMediaPeople(people.data), actorUserId)
|
||||
}
|
||||
return { entityId: change.entityId }
|
||||
}
|
||||
|
||||
// Souvenir envoyé par la famille : le fichier existe déjà, on le rend visible
|
||||
if (change.entityType === 'media') {
|
||||
if (change.action !== 'create' || !change.entityId) {
|
||||
@@ -388,6 +421,7 @@ async function applyContributionChange(
|
||||
birthDate: toDateOrNull(parsed.data.birthDate),
|
||||
deathDate: toDateOrNull(parsed.data.deathDate),
|
||||
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
|
||||
toVerify: Boolean(parsed.data.toVerify),
|
||||
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(parsed.data.profession),
|
||||
@@ -457,6 +491,7 @@ async function applyContributionChange(
|
||||
birthDate: nextDates.birthDate,
|
||||
deathDate: nextDates.deathDate,
|
||||
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
|
||||
toVerify: parsed.data.toVerify,
|
||||
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
|
||||
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
|
||||
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
|
||||
@@ -856,6 +891,7 @@ async function snapshotEntity(
|
||||
birthDate: true,
|
||||
deathDate: true,
|
||||
isDeceased: true,
|
||||
toVerify: true,
|
||||
birthPlace: true,
|
||||
deathPlace: true,
|
||||
profession: true,
|
||||
@@ -1063,6 +1099,115 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
|
||||
}
|
||||
|
||||
export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Personnes identifiées sur un souvenir. Administrateur : enregistré tout de suite. Famille (membre, lien
|
||||
// de partage) : une proposition, appliquée à la relecture, ou tout de suite si l'arbre l'autorise.
|
||||
app.put('/trees/:id/media/:mediaId/people', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = z.object({ id: z.string().min(1), mediaId: z.string().min(1) }).safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
const payload = z.object({
|
||||
people: mediaPeopleSchema,
|
||||
submittedByLabel: z.string().trim().min(1).max(120).optional(),
|
||||
}).safeParse(request.body)
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
const treeId = params.data.id
|
||||
const mediaId = params.data.mediaId
|
||||
|
||||
try {
|
||||
const context = await resolveSubmissionContext(app, request.actor, treeId)
|
||||
const actor = request.actor
|
||||
const membership = actor?.kind === 'user'
|
||||
? await app.prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: actor.userId } }, select: { role: true } })
|
||||
: null
|
||||
const isAdmin = Boolean(membership && hasAdminRight(membership.role))
|
||||
|
||||
const media = await app.prisma.mediaItem.findFirst({
|
||||
where: { id: mediaId, treeId, status: 'approved', deletedAt: null },
|
||||
select: { id: true, personId: true },
|
||||
})
|
||||
if (!media) {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
const people = normalizeMediaPeople(payload.data.people)
|
||||
const known = people.length
|
||||
? await app.prisma.person.count({ where: { treeId, id: { in: people.map((person) => person.personId) }, deletedAt: null } })
|
||||
: 0
|
||||
if (known !== people.length) {
|
||||
return reply.code(400).send({ error: 'invalid_person_reference' })
|
||||
}
|
||||
|
||||
if (context.autoStatus === 'pending') {
|
||||
const pendingCount = await app.prisma.contributionSession.count({ where: { treeId, status: 'pending' } })
|
||||
if (pendingCount >= MAX_PENDING_SESSIONS_PER_TREE) {
|
||||
return reply.code(429).send({ error: 'too_many_pending_contributions' })
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const saved = await app.prisma.$transaction(async (tx) => {
|
||||
const before = await tx.mediaPersonTag.findMany({
|
||||
where: { treeId, mediaId },
|
||||
select: { personId: true, x: true, y: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
const applied = context.autoStatus === 'approved'
|
||||
? await replaceMediaPeople(tx, treeId, mediaId, people, context.submittedByUserId)
|
||||
: null
|
||||
|
||||
// L'administrateur n'a rien à relire : pas de session, comme pour ses propres souvenirs
|
||||
if (!isAdmin) {
|
||||
const session = await tx.contributionSession.create({
|
||||
data: {
|
||||
treeId,
|
||||
submittedByUserId: context.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
|
||||
mode: context.mode,
|
||||
status: context.autoStatus,
|
||||
title: 'Personnes sur un souvenir',
|
||||
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
|
||||
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
|
||||
},
|
||||
})
|
||||
await tx.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'media_people',
|
||||
entityId: mediaId,
|
||||
action: 'update',
|
||||
beforeJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people: before }),
|
||||
afterJson: toPrismaJson({ mediaId, ownerPersonId: media.personId, people }),
|
||||
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
actorType: context.actorType,
|
||||
actorId: context.actorId,
|
||||
action: 'media_people_updated',
|
||||
entityType: 'media_item',
|
||||
entityId: mediaId,
|
||||
payloadJson: { status: context.autoStatus, count: people.length },
|
||||
},
|
||||
})
|
||||
return applied
|
||||
})
|
||||
|
||||
return reply.send({ status: context.autoStatus, people: saved ?? people })
|
||||
} catch (error) {
|
||||
if (error instanceof ContributionRouteError) {
|
||||
return reply.code(error.statusCode).send({ error: error.code })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/trees/:id/contributions/sessions', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = treeIdParamSchema.safeParse(request.params)
|
||||
if (!params.success) {
|
||||
@@ -1078,6 +1223,17 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'media_changes_not_allowed' })
|
||||
}
|
||||
|
||||
// Une annotation refusée à l'écriture le serait aussi à la relecture : on le dit tout de suite
|
||||
for (const change of payload.data.changes) {
|
||||
if (change.entityType !== 'annotation') continue
|
||||
try {
|
||||
validateAnnotationChange(change, params.data.id)
|
||||
} catch (error) {
|
||||
if (error instanceof AnnotationChangeError) return reply.code(400).send({ error: error.code })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const context = await resolveSubmissionContext(app, request.actor, params.data.id)
|
||||
const now = new Date()
|
||||
@@ -1139,7 +1295,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
entityId: effectiveEntityId,
|
||||
action: change.action,
|
||||
beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before),
|
||||
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after),
|
||||
// Annotation : gardée nettoyée (photoPath canonique), pour être relue sans ambiguïté à la relecture
|
||||
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(normalizedAnnotationAfter(change, params.data.id)) : change.after),
|
||||
conflictState: change.conflictState ?? 'none',
|
||||
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
|
||||
},
|
||||
@@ -1238,6 +1395,21 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
// État d'une proposition, pour la personne qui l'a envoyée : ses fantômes « en attente » s'effacent quand elle
|
||||
// est relue. Seulement le statut, à qui lit l'arbre et connaît l'identifiant (rendu à l'envoi).
|
||||
app.get('/trees/:id/contributions/sessions/:sessionId/status', { config: { rateLimit: { max: 120, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = sessionParamsSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
|
||||
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
|
||||
const session = await app.prisma.contributionSession.findFirst({
|
||||
where: { id: params.data.sessionId, treeId: params.data.id },
|
||||
select: { status: true },
|
||||
})
|
||||
if (!session) return reply.code(404).send({ error: 'contribution_session_not_found' })
|
||||
return reply.send({ status: session.status })
|
||||
})
|
||||
|
||||
app.post('/trees/:id/contributions/sessions/:sessionId/review', async (request, reply) => {
|
||||
const params = sessionParamsSchema.safeParse(request.params)
|
||||
if (!params.success) {
|
||||
@@ -1293,6 +1465,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
let rejectedCount = 0
|
||||
let conflictCount = 0
|
||||
const refs: RefMap = new Map()
|
||||
// Photos posées sur l'arbre par la famille : celles des annotations refusées sont libérées après toutes les
|
||||
// décisions. Une annotation acceptée dans cette même relecture a déjà retiré la ligne d'attente de sa photo,
|
||||
// qui n'est donc plus effacée (avant, la libération dans la boucle effaçait une photo acceptée plus loin).
|
||||
const rejectedAnnotationAfters: unknown[] = []
|
||||
|
||||
for (const change of byApplicationOrder(session.changes)) {
|
||||
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
|
||||
@@ -1331,10 +1507,12 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
)
|
||||
approvedCount += 1
|
||||
} catch (error) {
|
||||
// Lien vers une personne ajoutée mais refusée : le lien est écarté, pas la relecture
|
||||
if (error instanceof ContributionRouteError && error.code === 'unresolved_reference') {
|
||||
// Lien vers une personne ajoutée mais refusée, ou annotation d'avant la 1.5.1 que le serveur ne
|
||||
// peut pas poser (contenu incomplet) : la proposition est écartée, pas la relecture
|
||||
if (error instanceof ContributionRouteError && (error.code === 'unresolved_reference' || SKIPPABLE_ANNOTATION_ERRORS.has(error.code))) {
|
||||
decision = 'rejected'
|
||||
rejectedCount += 1
|
||||
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
@@ -1346,6 +1524,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: { status: 'rejected', deletedAt: new Date() },
|
||||
})
|
||||
}
|
||||
if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson)
|
||||
if (change.entityType === 'media' && change.entityId) {
|
||||
await tx.mediaItem.updateMany({
|
||||
where: { id: change.entityId, treeId: params.data.id },
|
||||
@@ -1366,6 +1545,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
}
|
||||
|
||||
filesToRemove.push(...await releaseRejectedPhotos(tx, params.data.id, rejectedAnnotationAfters, session.id))
|
||||
|
||||
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
|
||||
|
||||
const updatedSession = await tx.contributionSession.update({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson }
|
||||
import type { GedcomRecords } from '../lib/gedcom-photos.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
import { sexOrNull } from '../lib/person-sex.js'
|
||||
import { sexOrNull, type PersonSex } from '../lib/person-sex.js'
|
||||
|
||||
const treeIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -285,6 +285,15 @@ function pairKey(a: string, b: string | null): string {
|
||||
return b ? [a, b].sort().join('|') : `${a}|`
|
||||
}
|
||||
|
||||
// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U,
|
||||
// et l'import relit X comme avant.
|
||||
export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null {
|
||||
if (sex === 'male') return 'M'
|
||||
if (sex === 'female') return 'F'
|
||||
if (sex === 'other') return 'U'
|
||||
return null
|
||||
}
|
||||
|
||||
function sanitizeXref(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
}
|
||||
@@ -456,10 +465,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
|
||||
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
|
||||
|
||||
const sex = sexOrNull(person.sex)
|
||||
if (sex === 'male') lines.push('1 SEX M')
|
||||
else if (sex === 'female') lines.push('1 SEX F')
|
||||
else if (sex === 'other') lines.push('1 SEX X')
|
||||
const sexValue = gedcomSexValue(sexOrNull(person.sex))
|
||||
if (sexValue) lines.push(`1 SEX ${sexValue}`)
|
||||
|
||||
const personEvents = eventsByPerson.get(person.id) ?? []
|
||||
const birthDetails = personEvents.find((event) => event.type === 'birth')
|
||||
|
||||
@@ -119,7 +119,7 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Journal des visites (onglet Visites des administrateurs), sans attendre
|
||||
void recordTreeVisit(app, request, treeId, visitKind, actor.kind === 'user' ? actor.userId : null)
|
||||
|
||||
const [persons, unions, links, medias, annotations] = await Promise.all([
|
||||
const [persons, unions, links, medias, annotations, mediaPeople] = await Promise.all([
|
||||
app.prisma.person.findMany({
|
||||
where: {
|
||||
treeId,
|
||||
@@ -156,9 +156,14 @@ export const graphRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
app.prisma.mediaPersonTag.findMany({
|
||||
where: { treeId },
|
||||
select: { mediaId: true, personId: true, x: true, y: true },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations)
|
||||
const graph = buildGraphPayload(treeId, persons, unions, links, medias, annotations, mediaPeople)
|
||||
|
||||
return reply.send({
|
||||
treeId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
ImmichUnavailableError,
|
||||
type ImmichPersonSummary,
|
||||
} from '../lib/immich.js'
|
||||
import { computeBlurhash } from '../lib/blurhash.js'
|
||||
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
|
||||
import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
|
||||
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js'
|
||||
|
||||
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
|
||||
// vignette devient son portrait. Chaque arbre porte sa propre connexion Immich : une adresse
|
||||
@@ -97,7 +98,65 @@ function buildAvatarRelativePath(treeId: string, personId: string, extension: st
|
||||
return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`)
|
||||
}
|
||||
|
||||
|
||||
async function membershipRole(app: FastifyInstance, userId: string, treeId: string) {
|
||||
const membership = await app.prisma.treeMembership.findUnique({
|
||||
where: { treeId_userId: { treeId, userId } },
|
||||
select: { role: true, tree: { select: { deletedAt: true } } },
|
||||
})
|
||||
return membership && !membership.tree.deletedAt ? membership.role : null
|
||||
}
|
||||
|
||||
// Immich passe par la clé du propriétaire, qui ouvre toute sa photothèque, pas seulement les photos de l'arbre :
|
||||
// réservé au propriétaire, jamais aux co-gestionnaires (issue #145)
|
||||
async function requireImmichOwner(app: FastifyInstance, userId: string, treeId: string): Promise<boolean> {
|
||||
return (await membershipRole(app, userId, treeId)) === 'owner'
|
||||
}
|
||||
|
||||
export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre.
|
||||
// Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota,
|
||||
// validation et rattachement au moment), sans exposer d'URL ni de clé Immich au navigateur.
|
||||
app.get('/trees/:id/immich/assets', async (request, reply) => {
|
||||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||||
const params = treeParamSchema.safeParse(request.params)
|
||||
const query = z.object({
|
||||
page: z.coerce.number().int().min(1).max(100000).default(1),
|
||||
type: z.enum(['IMAGE', 'VIDEO']).default('IMAGE'),
|
||||
q: z.string().trim().max(500).optional(),
|
||||
personId: z.string().refine(isImmichPersonId).optional(),
|
||||
}).safeParse(request.query)
|
||||
if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||||
try {
|
||||
reply.header('Cache-Control', 'no-store')
|
||||
return reply.send(await client.listAssets(query.data.page, query.data.type, { query: query.data.q, personId: query.data.personId }))
|
||||
} catch (error) { return replyImmichError(request, reply, error) }
|
||||
})
|
||||
|
||||
app.get('/trees/:id/immich/assets/:assetId/:variant', async (request, reply) => {
|
||||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||||
const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||||
try {
|
||||
const thumbnail = params.data.variant !== 'original'
|
||||
const bytes = await client.getAssetBytes(params.data.assetId, thumbnail, (thumbnail ? 5 : 20) * 1024 * 1024, params.data.variant === 'preview')
|
||||
if (!bytes) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||||
const mime = sniffMimeType(bytes, 'video')
|
||||
if (!mime || !(mime.startsWith('image/') || (!thumbnail && mime.startsWith('video/')))) return reply.code(400).send({ error: 'invalid_immich_media' })
|
||||
if (mime.startsWith('image/') && bytes.length > MAX_THUMBNAIL_BYTES) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||||
return reply.header('Content-Type', mime).header('X-Content-Type-Options', 'nosniff').header('Cache-Control', 'no-store').send(bytes)
|
||||
} catch (error) {
|
||||
if (error instanceof ImmichNotFoundError) return reply.code(404).send({ error: 'immich_asset_not_found' })
|
||||
return replyImmichError(request, reply, error)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Connexion Immich de l'arbre : lecture, écriture, suppression. Réservées aux
|
||||
// administrateurs de l'arbre ; la clé d'API ne quitte jamais le serveur.
|
||||
|
||||
@@ -111,9 +170,15 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
const role = await membershipRole(app, request.actor.userId, params.data.id)
|
||||
if (role !== 'owner' && role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
// Co-gestionnaire : la clé Immich est celle du propriétaire et ouvre toute sa photothèque. Il ne voit ni ne
|
||||
// règle rien ; pour l'interface, Immich n'est pas configuré
|
||||
if (role !== 'owner') {
|
||||
return reply.send({ config: null, instances: [], ownerOnly: true })
|
||||
}
|
||||
|
||||
const config = await app.prisma.treeImmichConfig.findUnique({
|
||||
where: { treeId: params.data.id },
|
||||
@@ -141,7 +206,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -210,7 +275,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -244,7 +309,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -267,7 +332,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -296,7 +361,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_immich_person_id' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -315,7 +380,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une
|
||||
// balise <img>. Vide, trop grande ou pas une image : rien à montrer.
|
||||
const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null
|
||||
if (!thumbnail || !mimeType?.startsWith('image/')) {
|
||||
if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) {
|
||||
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
|
||||
return reply.code(404).send({ error: 'thumbnail_not_found' })
|
||||
}
|
||||
@@ -338,7 +403,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -364,7 +429,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -475,6 +540,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait, nouveau cadrage à faire
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
|
||||
immichPersonId,
|
||||
updatedBy: request.actor.userId,
|
||||
},
|
||||
@@ -522,7 +588,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) {
|
||||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
@@ -555,11 +621,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarPath?: null
|
||||
avatarMimeType?: null
|
||||
avatarCrop?: typeof Prisma.DbNull
|
||||
avatarBlurhash?: null
|
||||
} = { immichPersonId: null, updatedBy: request.actor.userId }
|
||||
if (avatarFromImmich) {
|
||||
updateData.avatarPath = null
|
||||
updateData.avatarMimeType = null
|
||||
updateData.avatarCrop = Prisma.DbNull
|
||||
updateData.avatarBlurhash = null
|
||||
}
|
||||
|
||||
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import {
|
||||
INVITATION_TTL_MS,
|
||||
MAX_PENDING_INVITATIONS,
|
||||
acceptInvitation,
|
||||
findPendingInvitationByToken,
|
||||
hashInvitationToken,
|
||||
maskEmail,
|
||||
newInvitationToken,
|
||||
normalizeEmail,
|
||||
pendingInvitationWhere,
|
||||
} from '../lib/tree-invitations.js'
|
||||
|
||||
// Co-gestion d'un arbre (issue #145). Un co-gestionnaire est un membre « admin » : il fait tout ce que fait le
|
||||
// propriétaire, sauf inviter ou retirer un co-gestionnaire et supprimer l'arbre (DELETE /trees/:id, réservé au
|
||||
// propriétaire), ni Immich, qui passe par la clé du propriétaire (routes/immich.ts). Les droits sont relus en base à
|
||||
// chaque requête : un co-gestionnaire retiré perd la main aussitôt. Il a pu voir le mot de passe de partage :
|
||||
// l'écran de retrait propose de le changer.
|
||||
|
||||
const treeIdParamSchema = z.object({ id: z.string().min(1) })
|
||||
const invitationParamsSchema = z.object({ id: z.string().min(1), invitationId: z.string().min(1) })
|
||||
const managerParamsSchema = z.object({ id: z.string().min(1), userId: z.string().min(1) })
|
||||
const tokenParamSchema = z.object({ token: z.string().min(1).max(200) })
|
||||
const inviteSchema = z.object({ email: z.string().trim().email().max(254) })
|
||||
|
||||
function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string } | null {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
reply.code(401).send({ error: 'authentication_required' })
|
||||
return null
|
||||
}
|
||||
return { userId: request.actor.userId }
|
||||
}
|
||||
|
||||
async function membershipOf(app: FastifyInstance, treeId: string, userId: string) {
|
||||
const membership = await app.prisma.treeMembership.findUnique({
|
||||
where: { treeId_userId: { treeId, userId } },
|
||||
select: { role: true, tree: { select: { deletedAt: true, ownerUserId: true } } },
|
||||
})
|
||||
return membership && !membership.tree.deletedAt ? membership : null
|
||||
}
|
||||
|
||||
function audit(app: FastifyInstance, treeId: string, actorId: string, action: string, payloadJson: Record<string, string>) {
|
||||
return app.prisma.auditLog.create({
|
||||
data: { treeId, actorType: 'user', actorId, action, entityType: 'tree', entityId: treeId, payloadJson },
|
||||
})
|
||||
}
|
||||
|
||||
export const managerRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Gestionnaires et invitations en attente, pour tout gestionnaire de l'arbre. canManage : le propriétaire.
|
||||
app.get('/trees/:id/managers', async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = treeIdParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
|
||||
|
||||
const membership = await membershipOf(app, params.data.id, user.userId)
|
||||
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
const [managers, invitations] = await Promise.all([
|
||||
app.prisma.treeMembership.findMany({
|
||||
where: { treeId: params.data.id, role: { in: ['owner', 'admin'] } },
|
||||
select: { role: true, createdAt: true, user: { select: { id: true, email: true, firstName: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
app.prisma.treeInvitation.findMany({
|
||||
where: { treeId: params.data.id, ...pendingInvitationWhere() },
|
||||
select: { id: true, email: true, createdAt: true, expiresAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
canManage: membership.role === 'owner',
|
||||
managers: managers.map((m) => ({ userId: m.user.id, email: m.user.email, firstName: m.user.firstName, role: m.role, since: m.createdAt })),
|
||||
invitations,
|
||||
})
|
||||
})
|
||||
|
||||
// Inviter une adresse. Une invitation déjà en attente pour cette adresse reçoit un nouveau lien (l'ancien ne
|
||||
// sert plus). Le jeton n'est rendu qu'ici et par /link : la base n'en garde que l'empreinte.
|
||||
app.post('/trees/:id/invitations', { config: { rateLimit: { max: 20, timeWindow: '10 minutes' } } }, async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = treeIdParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' })
|
||||
const payload = inviteSchema.safeParse(request.body)
|
||||
if (!payload.success) return reply.code(400).send({ error: 'invalid_email' })
|
||||
|
||||
const membership = await membershipOf(app, params.data.id, user.userId)
|
||||
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
const email = normalizeEmail(payload.data.email)
|
||||
const alreadyManager = await app.prisma.treeMembership.findFirst({
|
||||
where: { treeId: params.data.id, role: { in: ['owner', 'admin'] }, user: { email } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (alreadyManager) return reply.code(409).send({ error: 'already_manager' })
|
||||
|
||||
const { token, tokenHash } = newInvitationToken()
|
||||
const expiresAt = new Date(Date.now() + INVITATION_TTL_MS)
|
||||
const existing = await app.prisma.treeInvitation.findFirst({
|
||||
where: { treeId: params.data.id, email, ...pendingInvitationWhere() },
|
||||
select: { id: true },
|
||||
})
|
||||
let invitation
|
||||
if (existing) {
|
||||
invitation = await app.prisma.treeInvitation.update({
|
||||
where: { id: existing.id },
|
||||
data: { tokenHash, expiresAt },
|
||||
select: { id: true, email: true, createdAt: true, expiresAt: true },
|
||||
})
|
||||
} else {
|
||||
const pending = await app.prisma.treeInvitation.count({ where: { treeId: params.data.id, ...pendingInvitationWhere() } })
|
||||
if (pending >= MAX_PENDING_INVITATIONS) return reply.code(429).send({ error: 'too_many_invitations' })
|
||||
invitation = await app.prisma.treeInvitation.create({
|
||||
data: { treeId: params.data.id, email, tokenHash, expiresAt, invitedByUserId: user.userId },
|
||||
select: { id: true, email: true, createdAt: true, expiresAt: true },
|
||||
})
|
||||
}
|
||||
await audit(app, params.data.id, user.userId, 'manager_invited', { invitationId: invitation.id })
|
||||
return reply.code(201).send({ invitation, token })
|
||||
})
|
||||
|
||||
// « Copier le lien » : un nouveau jeton (l'ancien lien ne sert plus), la durée repart de zéro
|
||||
app.post('/trees/:id/invitations/:invitationId/link', { config: { rateLimit: { max: 30, timeWindow: '10 minutes' } } }, async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = invitationParamsSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
|
||||
const membership = await membershipOf(app, params.data.id, user.userId)
|
||||
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
const { token, tokenHash } = newInvitationToken()
|
||||
const updated = await app.prisma.treeInvitation.updateMany({
|
||||
where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() },
|
||||
data: { tokenHash, expiresAt: new Date(Date.now() + INVITATION_TTL_MS) },
|
||||
})
|
||||
if (updated.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' })
|
||||
return reply.send({ token })
|
||||
})
|
||||
|
||||
app.delete('/trees/:id/invitations/:invitationId', async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = invitationParamsSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
|
||||
const membership = await membershipOf(app, params.data.id, user.userId)
|
||||
if (!membership || membership.role !== 'owner') return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
const revoked = await app.prisma.treeInvitation.updateMany({
|
||||
where: { id: params.data.invitationId, treeId: params.data.id, ...pendingInvitationWhere() },
|
||||
data: { revokedAt: new Date() },
|
||||
})
|
||||
if (revoked.count !== 1) return reply.code(404).send({ error: 'invitation_not_found' })
|
||||
await audit(app, params.data.id, user.userId, 'manager_invitation_revoked', { invitationId: params.data.invitationId })
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (le co-gestionnaire lui-même).
|
||||
// Le propriétaire ne se retire jamais : son arbre part avec lui (suppression de l'arbre ou du compte).
|
||||
app.delete('/trees/:id/managers/:userId', async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = managerParamsSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
|
||||
const membership = await membershipOf(app, params.data.id, user.userId)
|
||||
const leaving = params.data.userId === user.userId
|
||||
if (!membership || (leaving ? membership.role !== 'admin' : membership.role !== 'owner')) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
// Un accès retenu par le mot de passe de partage (user_tree_accesses) part aussi : sinon le compte retiré
|
||||
// lirait encore l'arbre
|
||||
const removed = await app.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.treeMembership.deleteMany({
|
||||
where: { treeId: params.data.id, userId: params.data.userId, role: 'admin' },
|
||||
})
|
||||
if (deleted.count === 1) {
|
||||
await tx.userTreeAccess.deleteMany({ where: { treeId: params.data.id, userId: params.data.userId } })
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
if (removed.count !== 1) return reply.code(404).send({ error: 'manager_not_found' })
|
||||
await audit(app, params.data.id, user.userId, leaving ? 'manager_left' : 'manager_removed', { userId: params.data.userId })
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Page du lien, sans compte : l'arbre, qui invite, l'adresse masquée, et les façons d'entrer sur ce serveur
|
||||
app.get('/invitations/:token', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const params = tokenParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
|
||||
if (!invitation) {
|
||||
// Lien déjà servi ou expiré : dit tel quel, pour que la page explique quoi faire. Rien d'autre ne sort.
|
||||
const known = await app.prisma.treeInvitation.findUnique({
|
||||
where: { tokenHash: hashInvitationToken(params.data.token) },
|
||||
select: { acceptedAt: true, revokedAt: true, expiresAt: true },
|
||||
})
|
||||
const error = known?.acceptedAt ? 'invitation_used' : known && !known.revokedAt && known.expiresAt <= new Date() ? 'invitation_expired' : 'invitation_invalid'
|
||||
return reply.code(404).send({ error })
|
||||
}
|
||||
return reply.send({
|
||||
treeName: invitation.tree.name,
|
||||
treeIcon: invitation.tree.icon,
|
||||
invitedBy: invitation.tree.owner.firstName || null,
|
||||
emailHint: maskEmail(invitation.email),
|
||||
expiresAt: invitation.expiresAt,
|
||||
passwordLogin: env.PASSWORD_LOGIN,
|
||||
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
|
||||
})
|
||||
})
|
||||
|
||||
// Accepter avec le compte connecté : il doit avoir l'adresse invitée
|
||||
app.post('/invitations/:token/accept', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
|
||||
const user = requireUser(request, reply)
|
||||
if (!user) return
|
||||
const params = tokenParamSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
|
||||
const invitation = await findPendingInvitationByToken(app.prisma, params.data.token)
|
||||
if (!invitation) return reply.code(404).send({ error: 'invitation_invalid' })
|
||||
const account = await app.prisma.user.findUnique({ where: { id: user.userId }, select: { email: true } })
|
||||
if (!account) return reply.code(401).send({ error: 'authentication_required' })
|
||||
|
||||
const result = await acceptInvitation(app.prisma, invitation.id, user.userId, account.email)
|
||||
if (!result.ok) {
|
||||
const code = result.error === 'invitation_invalid' ? 404 : result.error === 'already_owner' ? 409 : 403
|
||||
return reply.code(code).send({ error: result.error })
|
||||
}
|
||||
return reply.send({ treeId: result.treeId, role: 'admin' })
|
||||
})
|
||||
}
|
||||
+62
-14
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
removeStoredFile,
|
||||
sanitizeImage,
|
||||
scrubMp4Location,
|
||||
sendFileWithRanges,
|
||||
sniffMimeType,
|
||||
storedFileSize,
|
||||
type BinaryMediaType,
|
||||
} from '../lib/media-files.js'
|
||||
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
|
||||
import { computeBlurhash } from '../lib/blurhash.js'
|
||||
import { syncPersonMediaOrder } from '../lib/media-order.js'
|
||||
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
|
||||
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
|
||||
import { forgetPendingPhoto, reservePendingPhoto, sweepStalePendingPhotos } from '../lib/annotation-changes.js'
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024
|
||||
@@ -240,6 +244,18 @@ async function canReadMedia(app: FastifyRequest['server'], treeId: string, reque
|
||||
return token ? canReadTreeWithMediaToken(app, treeId, token) : false
|
||||
}
|
||||
|
||||
// Compte qui demande un média : celui de la session, sinon celui du jeton médias (lien de partage : aucun)
|
||||
async function mediaRequesterUserId(app: FastifyRequest['server'], request: FastifyRequest, token: string | undefined): Promise<string | null> {
|
||||
if (request.actor?.kind === 'user') return request.actor.userId
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = await app.jwt.verify<{ kind?: string; userId?: string }>(token)
|
||||
return payload.kind === 'media' && payload.userId ? payload.userId : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/trees/:id/persons/:personId/media', UPLOAD_RATE_LIMIT, async (request, reply) => {
|
||||
const actor = request.actor
|
||||
@@ -486,6 +502,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: contributionSession?.id ?? null,
|
||||
lifeEventId,
|
||||
blurhash: payload.data.type === 'photo' ? await computeBlurhash(prepared.buffer) ?? '' : null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -605,6 +622,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
|
||||
updatedBy: request.actor.userId,
|
||||
// Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association
|
||||
// survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne
|
||||
@@ -661,6 +679,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
avatarPath: null,
|
||||
avatarMimeType: null,
|
||||
avatarCrop: Prisma.DbNull,
|
||||
avatarBlurhash: null,
|
||||
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
|
||||
immichPersonId: null,
|
||||
updatedBy: request.actor.userId,
|
||||
@@ -982,8 +1001,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
// Supprimé = effacé du disque (la ligne reste pour le journal)
|
||||
// Supprimé = effacé du disque (la ligne reste pour le journal), et plus personne n'y est identifié
|
||||
await removeStoredFile(getStorageRoot(), existingMedia?.filePath)
|
||||
await app.prisma.mediaPersonTag.deleteMany({ where: { treeId: params.data.id, mediaId: params.data.mediaId } })
|
||||
|
||||
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
|
||||
|
||||
@@ -1077,6 +1097,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
select: {
|
||||
filePath: true,
|
||||
mimeType: true,
|
||||
status: true,
|
||||
uploadedBy: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1084,6 +1106,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
// Un souvenir qui attend la relecture ne se lit que par qui gère l'arbre ou par qui l'a envoyé, pas par toute la
|
||||
// famille qui connaîtrait son identifiant. Même réponse qu'un média absent : rien n'indique qu'il existe.
|
||||
if (media.status === 'pending') {
|
||||
const userId = await mediaRequesterUserId(app, request, query.data.token)
|
||||
const allowed = Boolean(userId) && (media.uploadedBy === userId || Boolean(await requireAdminMembership(app, userId as string, params.data.id)))
|
||||
if (!allowed) {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube videos: filePath is the YouTube URL, redirect the browser
|
||||
if (media.mimeType === 'video/youtube') {
|
||||
return reply.code(302).redirect(media.filePath)
|
||||
@@ -1095,16 +1127,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_media_path' })
|
||||
}
|
||||
|
||||
let fileBuffer: Buffer
|
||||
try {
|
||||
fileBuffer = await readFile(absolutePath)
|
||||
} catch {
|
||||
const size = await storedFileSize(absolutePath)
|
||||
if (size === null) {
|
||||
return reply.code(404).send({ error: 'media_file_missing' })
|
||||
}
|
||||
|
||||
applyMediaResponseHeaders(reply, media.mimeType, path.basename(media.filePath))
|
||||
|
||||
return reply.send(fileBuffer)
|
||||
return sendFileWithRanges(request, reply, absolutePath, size)
|
||||
})
|
||||
|
||||
// ---- Annotation Photos ----
|
||||
@@ -1122,8 +1151,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
photoId: z.string().min(1),
|
||||
})
|
||||
|
||||
// Photo posée sur l'arbre. Un gestionnaire la pose tout de suite ; la famille (lien de partage ou membre) la
|
||||
// propose : le fichier attend la relecture, noté dans annotation_photo_uploads (lib/annotation-changes.ts).
|
||||
app.post('/trees/:id/annotation-photos', UPLOAD_RATE_LIMIT, async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
if (!request.actor) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
@@ -1137,9 +1168,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
|
||||
}
|
||||
|
||||
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
|
||||
if (!membership) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
const isManager = request.actor.kind === 'user' && Boolean(await requireAdminMembership(app, request.actor.userId, params.data.id))
|
||||
if (!isManager) {
|
||||
try {
|
||||
await resolveSubmissionContext(app, request.actor, params.data.id)
|
||||
} catch (error) {
|
||||
if (error instanceof ContributionRouteError) return reply.code(error.statusCode).send({ error: error.code })
|
||||
throw error
|
||||
}
|
||||
const stale = await sweepStalePendingPhotos(app.prisma, params.data.id)
|
||||
await Promise.all(stale.map((filePath) => removeStoredFile(getStorageRoot(), filePath)))
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64')
|
||||
@@ -1179,8 +1217,18 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_photo_path' })
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true })
|
||||
await writeFile(absolutePath, prepared.buffer)
|
||||
// Famille : la place est réservée sous un verrou par arbre avant d'écrire, pour tenir le plafond même quand
|
||||
// plusieurs envois arrivent ensemble
|
||||
if (!isManager && !(await reservePendingPhoto(app.prisma, params.data.id, photoId, relativePath))) {
|
||||
return reply.code(429).send({ error: 'too_many_pending_photos' })
|
||||
}
|
||||
try {
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true })
|
||||
await writeFile(absolutePath, prepared.buffer)
|
||||
} catch (error) {
|
||||
if (!isManager) await forgetPendingPhoto(app.prisma, photoId)
|
||||
throw error
|
||||
}
|
||||
|
||||
return reply.code(201).send({
|
||||
photoId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
type OidcIdentity,
|
||||
type OidcPending,
|
||||
} from '../lib/oidc.js'
|
||||
import { confirmsWithPassword } from '../lib/auth.js'
|
||||
import { createAccountIfAllowed } from '../lib/registration.js'
|
||||
import { claimInvitationsForVerifiedEmail, hasPendingInvitationForEmail } from '../lib/tree-invitations.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
|
||||
// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ;
|
||||
@@ -44,8 +46,42 @@ const pendingSchema = z.object({
|
||||
nonce: z.string().min(1),
|
||||
codeVerifier: z.string().min(43),
|
||||
returnTo: z.string(),
|
||||
linkUserId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
// Rattacher une identité du fournisseur à un compte à mot de passe. Le compte connecté demande un ticket (court, à
|
||||
// usage unique), le départ OIDC le porte, et le retour relie l'identité à ce compte. La personne prouve les deux :
|
||||
// son mot de passe (session) et son compte chez le fournisseur. C'est le chemin pour passer une instance en
|
||||
// PASSWORD_LOGIN=false sans enfermer dehors les comptes à mot de passe : le rattachement automatique par email reste
|
||||
// refusé pour eux (resolveAccount), parce que l'inscription par mot de passe ne vérifie pas l'adresse.
|
||||
const LINK_TICKET_TTL_S = 120
|
||||
type LinkTicketPayload = { kind: 'oidc_link'; sub: string; jti: string; exp?: number }
|
||||
const usedLinkTickets = new Map<string, number>()
|
||||
function consumeLinkTicket(jti: string, expSeconds: number): boolean {
|
||||
const now = Date.now()
|
||||
for (const [key, expiresAt] of usedLinkTickets) {
|
||||
if (expiresAt < now) usedLinkTickets.delete(key)
|
||||
}
|
||||
if (usedLinkTickets.has(jti)) return false
|
||||
usedLinkTickets.set(jti, expSeconds * 1000)
|
||||
return true
|
||||
}
|
||||
|
||||
type LinkResult = { userId: string } | { error: 'oidc_identity_taken' | 'oidc_failed' }
|
||||
|
||||
async function linkIdentity(app: FastifyInstance, userId: string, identity: OidcIdentity): Promise<LinkResult> {
|
||||
const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
|
||||
if (!user) return { error: 'oidc_failed' }
|
||||
const linked = await app.prisma.userIdentity.findUnique({
|
||||
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
|
||||
select: { userId: true },
|
||||
})
|
||||
// Déjà reliée à ce compte : rien à faire. Reliée à un autre : jamais déplacée d'un compte à l'autre
|
||||
if (linked) return linked.userId === userId ? { userId } : { error: 'oidc_identity_taken' }
|
||||
await app.prisma.userIdentity.create({ data: { userId, issuer: identity.issuer, subject: identity.subject } })
|
||||
return { userId }
|
||||
}
|
||||
|
||||
const exchangeSchema = z.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
})
|
||||
@@ -64,7 +100,10 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
if (!identity.email) return { error: 'oidc_email_missing' }
|
||||
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, passwordHash: true } })
|
||||
const existing = await app.prisma.user.findUnique({
|
||||
where: { email: identity.email },
|
||||
select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
|
||||
})
|
||||
if (existing) {
|
||||
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
|
||||
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
|
||||
@@ -72,6 +111,9 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
// Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce
|
||||
// compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers
|
||||
if (existing.passwordHash) return { error: 'oidc_email_taken' }
|
||||
// Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui
|
||||
// présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié.
|
||||
if (existing.identities?.length) return { error: 'oidc_email_taken' }
|
||||
|
||||
await app.prisma.userIdentity.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
@@ -79,6 +121,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
return { userId: existing.id }
|
||||
}
|
||||
|
||||
// Une invitation à co-gérer un arbre ouvre un compte même inscription fermée, si le fournisseur garantit l'adresse
|
||||
const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
|
||||
data: {
|
||||
email: identity.email as string,
|
||||
@@ -87,7 +130,7 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro
|
||||
identities: { create: { issuer: identity.issuer, subject: identity.subject } },
|
||||
},
|
||||
select: { id: true },
|
||||
}))
|
||||
}), { invited: identity.emailVerified && await hasPendingInvitationForEmail(app.prisma, identity.email) })
|
||||
return created ? { userId: created.id } : { error: 'registration_closed' }
|
||||
}
|
||||
|
||||
@@ -105,7 +148,18 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
'/start',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
|
||||
const query = (request.query as Record<string, unknown> | undefined) ?? {}
|
||||
// Ticket de rattachement : vérifié et consommé ici, le compte voyage dans le cookie signé
|
||||
let linkUserId: string | undefined
|
||||
if (typeof query.link === 'string') {
|
||||
let ticket: LinkTicketPayload | null = null
|
||||
try { ticket = app.jwt.verify<LinkTicketPayload>(query.link) } catch { ticket = null }
|
||||
if (!ticket || ticket.kind !== 'oidc_link' || typeof ticket.exp !== 'number' || !consumeLinkTicket(ticket.jti, ticket.exp)) {
|
||||
return redirectToApp(reply, '/', 'oidc_error=oidc_link_expired')
|
||||
}
|
||||
linkUserId = ticket.sub
|
||||
}
|
||||
const returnTo = linkUserId ? '/' : safeReturnTo(query.returnTo)
|
||||
let start: Awaited<ReturnType<typeof startAuthorization>>
|
||||
try {
|
||||
start = await startAuthorization(returnTo)
|
||||
@@ -114,7 +168,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
|
||||
}
|
||||
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(linkUserId ? { ...start.pending, linkUserId } : start.pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
|
||||
@@ -158,13 +212,13 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
let result: AccountResult
|
||||
let result: AccountResult | LinkResult
|
||||
try {
|
||||
result = await resolveAccount(app, identity)
|
||||
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
|
||||
} catch (error) {
|
||||
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
result = await resolveAccount(app, identity)
|
||||
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
@@ -172,6 +226,11 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
if ('error' in result) {
|
||||
return redirectToApp(reply, returnTo, `oidc_error=${result.error}`)
|
||||
}
|
||||
// Invitations à co-gérer un arbre : acceptées sans le lien, l'adresse étant garantie par le fournisseur (et
|
||||
// devant être celle du compte, voir claimInvitationsForVerifiedEmail)
|
||||
if (identity.email && identity.emailVerified) {
|
||||
await claimInvitationsForVerifiedEmail(app.prisma, result.userId, identity.email)
|
||||
}
|
||||
|
||||
const handoff = app.jwt.sign(
|
||||
{ kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() },
|
||||
@@ -181,6 +240,23 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
)
|
||||
|
||||
// Ticket de rattachement pour le compte connecté (session par en-tête) : la navigation vers /start ne peut pas
|
||||
// porter l'en-tête, le ticket le remplace, deux minutes et une seule fois
|
||||
app.post(
|
||||
'/link',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
const ticket = app.jwt.sign(
|
||||
{ kind: 'oidc_link', sub: request.actor.userId, jti: randomUUID() },
|
||||
{ expiresIn: `${LINK_TICKET_TTL_S}s` },
|
||||
)
|
||||
return reply.send({ ticket })
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/exchange',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
@@ -210,7 +286,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
return reply.send({
|
||||
token: signUserSession(app, user),
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) },
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) },
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { canReadTree } from '../lib/tree-access.js'
|
||||
import { enabledSearchSources, searchSource, SearchSourceUnavailableError } from '../lib/search-sources.js'
|
||||
|
||||
// Panneau « Chercher quelqu'un » : le serveur interroge les sources allumées par l'opérateur
|
||||
// (SEARCH_SOURCES) au nom d'une personne qui lit l'arbre. Rattaché à un arbre pour ne pas faire de
|
||||
// l'instance un relais ouvert vers ces services ; rien n'est enregistré, seule la question part.
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().min(1), source: z.string().min(1).max(40) })
|
||||
|
||||
const querySchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(80),
|
||||
lastName: z.string().trim().min(1).max(80),
|
||||
birthYear: z.coerce.number().int().min(1500).max(2100).optional(),
|
||||
place: z.string().trim().max(120).optional(),
|
||||
})
|
||||
|
||||
export const searchSourceRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Sources que le front peut proposer : rien d'autre que leurs identifiants. Sous /trees comme le reste :
|
||||
// nginx ne relaie vers l'API que /api, /auth, /trees et /health
|
||||
app.get('/trees/:id/search-sources', async (request, reply) => {
|
||||
const params = z.object({ id: z.string().min(1) }).safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
|
||||
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
|
||||
return reply.send({ sources: enabledSearchSources() })
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/trees/:id/search-sources/:source',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const params = paramsSchema.safeParse(request.params)
|
||||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||||
|
||||
if (!request.actor) return reply.code(401).send({ error: 'authentication_required' })
|
||||
if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
if (!enabledSearchSources().includes(params.data.source)) return reply.code(404).send({ error: 'source_not_enabled' })
|
||||
|
||||
const body = querySchema.safeParse(request.body)
|
||||
if (!body.success) return reply.code(400).send({ error: 'invalid_query' })
|
||||
|
||||
try {
|
||||
const results = await searchSource(params.data.source, body.data)
|
||||
return reply.send({ results })
|
||||
} catch (error) {
|
||||
if (error instanceof SearchSourceUnavailableError) {
|
||||
request.log.warn({ source: params.data.source, reason: error.message }, 'search source unavailable')
|
||||
// Quota : l'opérateur peut y remédier (MATCHID_TOKEN), le front le dit autrement qu'une panne
|
||||
return reply.code(502).send({ error: error.message === 'quota' ? 'source_quota' : 'source_unavailable' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../generated/prisma/client.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
@@ -40,6 +40,8 @@ const patchTreeSchema = z
|
||||
slug: z.string().min(1).max(128).optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
rootPersonId: z.string().min(1).optional().nullable(),
|
||||
// Un emoji (graphème composé compris), ou null pour revenir à la première lettre
|
||||
icon: z.string().trim().max(16).regex(/^\p{Extended_Pictographic}/u).optional().nullable(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
message: 'at_least_one_field_required',
|
||||
@@ -93,6 +95,7 @@ const createPersonSchema = z.object({
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
toVerify: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
@@ -111,6 +114,8 @@ const patchPersonSchema = z
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
// « À vérifier » : la famille n'est pas sûre de cette personne (retour d'Anaïs, 23/10)
|
||||
toVerify: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
@@ -138,6 +143,9 @@ const patchUnionSchema = z
|
||||
partner2PersonId: z.string().min(1).optional().nullable(),
|
||||
unionType: z.string().max(50).optional().nullable(),
|
||||
startDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
// Lieu et précision de la date du couple, saisis dans l'onglet Vie (« vers 1980 »)
|
||||
startPlace: z.string().trim().max(300).optional().nullable(),
|
||||
startDatePrecision: z.enum(['about', 'estimated', 'calculated', 'before', 'after']).optional().nullable(),
|
||||
endDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
displayOrder: z.number().int().positive().optional(),
|
||||
})
|
||||
@@ -389,6 +397,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
rootPersonId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -410,6 +419,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
rootPersonId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -439,20 +449,36 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
slug: string
|
||||
name: string
|
||||
description: string | null
|
||||
icon: string | null
|
||||
rootPersonId: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
role: string
|
||||
accessMode: 'member' | 'share'
|
||||
lastAccessedAt: Date | null
|
||||
coManagers?: string[]
|
||||
}> = []
|
||||
|
||||
// Co-gestionnaires des arbres possédés : l'écran de suppression du compte les nomme (l'arbre part avec le compte)
|
||||
const ownedIds = memberships.filter((m: { role: string }) => m.role === 'owner').map((m: { tree: { id: string } }) => m.tree.id)
|
||||
const coManagerRows = ownedIds.length
|
||||
? await app.prisma.treeMembership.findMany({
|
||||
where: { treeId: { in: ownedIds }, role: 'admin' },
|
||||
select: { treeId: true, user: { select: { firstName: true, email: true } } },
|
||||
})
|
||||
: []
|
||||
|
||||
for (const membership of memberships) {
|
||||
mergedTrees.push({
|
||||
...membership.tree,
|
||||
role: membership.role,
|
||||
accessMode: 'member',
|
||||
lastAccessedAt: null,
|
||||
...(membership.role === 'owner' ? {
|
||||
coManagers: coManagerRows
|
||||
.filter((row: { treeId: string }) => row.treeId === membership.tree.id)
|
||||
.map((row: { user: { firstName: string | null; email: string } }) => row.user.firstName || row.user.email),
|
||||
} : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -647,6 +673,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
name?: string
|
||||
slug?: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
rootPersonId?: string | null
|
||||
} = {}
|
||||
|
||||
@@ -675,6 +702,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
updateData.description = payload.data.description
|
||||
}
|
||||
|
||||
if (payload.data.icon !== undefined) {
|
||||
updateData.icon = payload.data.icon
|
||||
}
|
||||
|
||||
if (payload.data.rootPersonId !== undefined) {
|
||||
if (payload.data.rootPersonId === null) {
|
||||
updateData.rootPersonId = null
|
||||
@@ -1321,6 +1352,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
deathDate: toDateOrNull(payload.data.deathDate),
|
||||
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
|
||||
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
|
||||
toVerify: Boolean(payload.data.toVerify),
|
||||
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(payload.data.profession),
|
||||
@@ -1391,6 +1423,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
birthDate?: Date | null
|
||||
deathDate?: Date | null
|
||||
isDeceased?: boolean
|
||||
toVerify?: boolean
|
||||
birthPlace?: string | null
|
||||
deathPlace?: string | null
|
||||
profession?: string | null
|
||||
@@ -1433,6 +1466,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
data.isDeceased = false
|
||||
}
|
||||
|
||||
if (payload.data.toVerify !== undefined) {
|
||||
data.toVerify = payload.data.toVerify
|
||||
}
|
||||
|
||||
if (payload.data.birthPlace !== undefined) {
|
||||
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
|
||||
}
|
||||
@@ -1746,6 +1783,8 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
data: {
|
||||
...unionPrecisionResets(existingUnion, { startDate }),
|
||||
...(payload.data.startDatePrecision !== undefined ? { startDatePrecision: payload.data.startDatePrecision } : {}),
|
||||
startPlace: payload.data.startPlace !== undefined ? payload.data.startPlace || null : undefined,
|
||||
partner1PersonId: payload.data.partner1PersonId,
|
||||
partner2PersonId: payload.data.partner2PersonId,
|
||||
unionType: payload.data.unionType,
|
||||
|
||||
@@ -35,7 +35,15 @@ export type OidcHandoffJwtPayload = {
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
|
||||
// Ticket de rattachement d'une identité OIDC au compte connecté (120 s, à usage unique, porté par /auth/oidc/start) :
|
||||
// jamais une session, le décodeur de l'en-tête ne retient que les sortes user et tree_access
|
||||
export type OidcLinkJwtPayload = {
|
||||
kind: 'oidc_link'
|
||||
sub: string
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload | OidcLinkJwtPayload
|
||||
|
||||
export type UserActor = {
|
||||
kind: 'user'
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { PrismaClient } from '../generated/prisma/client.js'
|
||||
import type { Actor, AnyJwtPayload } from './auth.js'
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
|
||||
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '../generated/prisma/client.js'
|
||||
|
||||
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
|
||||
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
|
||||
@@ -15,6 +15,7 @@ export type GraphPayload = {
|
||||
birthDate: string | null
|
||||
deathDate: string | null
|
||||
isDeceased: boolean
|
||||
toVerify: boolean
|
||||
birthPlace: string | null
|
||||
deathPlace: string | null
|
||||
profession: string | null
|
||||
@@ -25,6 +26,7 @@ export type GraphPayload = {
|
||||
notes: string | null
|
||||
avatarFromImmich: boolean
|
||||
avatarCrop: AvatarCrop | null
|
||||
avatarBlurhash: string | null
|
||||
}>
|
||||
unions: Array<{
|
||||
id: string
|
||||
@@ -53,6 +55,9 @@ export type GraphPayload = {
|
||||
source: string | null
|
||||
displayOrder: number
|
||||
isFeatured: boolean
|
||||
blurhash: string | null
|
||||
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
|
||||
people: Array<{ personId: string; x: number | null; y: number | null }>
|
||||
}>
|
||||
annotations: Array<{
|
||||
id: string
|
||||
@@ -81,7 +86,17 @@ export function buildGraphPayload(
|
||||
links: ParentChildLink[],
|
||||
medias: MediaItem[] = [],
|
||||
annotations: Annotation[] = [],
|
||||
mediaPeople: Pick<MediaPersonTag, 'mediaId' | 'personId' | 'x' | 'y'>[] = [],
|
||||
): GraphPayload {
|
||||
// Une personne supprimée n'est plus montrée sur les photos où elle était identifiée
|
||||
const livingIds = new Set(persons.map((person) => person.id))
|
||||
const peopleByMedia = new Map<string, Array<{ personId: string; x: number | null; y: number | null }>>()
|
||||
for (const tag of mediaPeople) {
|
||||
if (!livingIds.has(tag.personId)) continue
|
||||
const list = peopleByMedia.get(tag.mediaId) ?? []
|
||||
list.push({ personId: tag.personId, x: tag.x, y: tag.y })
|
||||
peopleByMedia.set(tag.mediaId, list)
|
||||
}
|
||||
// Keep DB order from route queries to preserve sibling and union ordering semantics.
|
||||
return {
|
||||
persons: persons.map((person) => ({
|
||||
@@ -93,6 +108,7 @@ export function buildGraphPayload(
|
||||
birthDate: formatDate(person.birthDate),
|
||||
deathDate: formatDate(person.deathDate),
|
||||
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
|
||||
toVerify: Boolean(person.toVerify),
|
||||
birthPlace: person.birthPlace,
|
||||
deathPlace: person.deathPlace,
|
||||
profession: person.profession,
|
||||
@@ -103,6 +119,7 @@ export function buildGraphPayload(
|
||||
notes: person.notes,
|
||||
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
|
||||
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
|
||||
avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null,
|
||||
})),
|
||||
unions: unions.map((union) => ({
|
||||
id: union.id,
|
||||
@@ -131,6 +148,8 @@ export function buildGraphPayload(
|
||||
source: media.source,
|
||||
displayOrder: media.displayOrder,
|
||||
isFeatured: media.isFeatured,
|
||||
blurhash: media.blurhash || null,
|
||||
people: peopleByMedia.get(media.id) ?? [],
|
||||
})),
|
||||
annotations: annotations.map((ann) => ({
|
||||
id: ann.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
|
||||
|
||||
type RelationReader = Pick<PrismaClient, 'parentChildLink' | 'union'>
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Aperçus flous (lib/blurhash.ts), sur une vraie base : calculés à l'envoi d'une photo et d'un portrait,
|
||||
// publiés dans le graphe, et rattrapés en tâche de fond pour ce qui n'en a pas (photos d'avant la migration,
|
||||
// imports). Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/apercus-flous.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
|
||||
let ip = 0
|
||||
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
const storage = mkdtempSync(path.join(tmpdir(), 'pth-apercus-'))
|
||||
const PHOTO_BYTES = readFileSync(fileURLToPath(new URL('../../frontend/public/demo/souvenir-mariage-1918.webp', import.meta.url)))
|
||||
const PHOTO = PHOTO_BYTES.toString('base64')
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
let body = null
|
||||
try { body = res.body ? res.json() : null } catch { body = null }
|
||||
return { status: res.statusCode, body }
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('aperçus flous, vraie base', () => {
|
||||
let token: string
|
||||
let treeId: string
|
||||
let personId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.MEDIA_STORAGE_PATH = storage
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.MEDIA_STORAGE_PATH = storage
|
||||
const owner = await call('POST', '/auth/register', undefined, { email: `apercus-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Patch' })
|
||||
expect(owner.status).toBe(201)
|
||||
token = owner.body.token
|
||||
const tree = await call('POST', '/trees', token, { name: 'Aperçus', slug: `apercus-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
treeId = tree.body.tree?.id ?? tree.body.id
|
||||
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
|
||||
personId = person.body.person?.id ?? person.body.id
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
const graph = async () => (await call('GET', `/trees/${treeId}/graph`, token)).body
|
||||
|
||||
it('une photo et un portrait envoyés ont leur aperçu tout de suite, publié dans le graphe', async () => {
|
||||
const media = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { type: 'photo', fileName: 'mariage.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
|
||||
expect(media.status).toBe(201)
|
||||
const avatar = await call('POST', `/trees/${treeId}/persons/${personId}/avatar`, token, { fileName: 'portrait.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
|
||||
expect(avatar.status).toBeLessThan(300)
|
||||
const g = await graph()
|
||||
expect(g.graph?.medias?.[0]?.blurhash ?? g.medias?.[0]?.blurhash).toMatch(/^[0-9A-Za-z#$%*+,-.:;=?@[\]^_{|}~]{28}$/)
|
||||
const person = (g.graph?.persons ?? g.persons).find((p: { id: string }) => p.id === personId)
|
||||
expect(person.avatarBlurhash).toHaveLength(28)
|
||||
})
|
||||
|
||||
it('la tâche de fond rattrape une photo et un portrait sans aperçu', async () => {
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId }, data: { blurhash: null } })
|
||||
await app.prisma.person.updateMany({ where: { id: personId }, data: { avatarBlurhash: null } })
|
||||
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
|
||||
const done = await fillMissingBlurhashes(app, path.resolve(storage))
|
||||
expect(done).toBeGreaterThanOrEqual(2)
|
||||
expect((await app.prisma.mediaItem.findFirst({ where: { treeId } }))?.blurhash).toHaveLength(28)
|
||||
expect((await app.prisma.person.findUnique({ where: { id: personId } }))?.avatarBlurhash).toHaveLength(28)
|
||||
})
|
||||
|
||||
it('un fichier illisible reçoit un aperçu vide, pour ne pas être relu à chaque passage', async () => {
|
||||
const media = await app.prisma.mediaItem.findFirst({ where: { treeId } })
|
||||
await app.prisma.mediaItem.update({ where: { id: media!.id }, data: { blurhash: null, filePath: `${treeId}/absent.webp` } })
|
||||
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
|
||||
await fillMissingBlurhashes(app, path.resolve(storage))
|
||||
expect((await app.prisma.mediaItem.findUnique({ where: { id: media!.id } }))?.blurhash).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,12 @@ import { parseGedcom } from '../src/routes/gedcom'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
@@ -95,6 +101,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -921,7 +932,7 @@ describe('Sexe dans le GEDCOM (issue #114)', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => {
|
||||
it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() })
|
||||
@@ -940,10 +951,12 @@ describe('Sexe dans le GEDCOM (issue #114)', () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
const text = response.body.replace(/\r\n/g, '\n')
|
||||
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n')
|
||||
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n')
|
||||
expect(text).not.toContain('SEX X')
|
||||
expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/)
|
||||
|
||||
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null })
|
||||
// SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915).
|
||||
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -1204,6 +1217,28 @@ describe('Surnom (nickname) en écriture directe', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('« À vérifier » en écriture directe (retour d’Anaïs, 23/10)', () => {
|
||||
it('POST et PATCH posent et retirent le drapeau, absent = inchangé', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const headers = { authorization: `Bearer ${token}` }
|
||||
|
||||
const created = await app.inject({ method: 'POST', url: '/trees/tree-1/persons', headers, payload: { firstName: 'Jeanne', lastName: 'Martin', toVerify: true } })
|
||||
expect(created.statusCode).toBe(201)
|
||||
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
|
||||
|
||||
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers, payload })
|
||||
expect((await patch({ toVerify: true })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
|
||||
expect((await patch({ toVerify: false })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: false })
|
||||
expect((await patch({ nickname: 'Loulou' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).not.toHaveProperty('toVerify')
|
||||
expect((await patch({ toVerify: 'oui' })).statusCode).toBe(400)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sexe d’une personne en écriture directe (issue #114)', () => {
|
||||
it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => {
|
||||
const app = createApp()
|
||||
|
||||
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
@@ -91,6 +97,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -1146,6 +1157,33 @@ describe('API security and authz', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('« à vérifier » proposé par la famille : appliqué à l’approbation, en création comme en modification', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||||
id: 'session-1',
|
||||
treeId: 'tree-1',
|
||||
status: 'pending',
|
||||
changes: [
|
||||
{ id: 'change-1', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { firstName: 'Alice', lastName: 'Martin', toVerify: true }, conflictState: 'none' },
|
||||
{ id: 'change-2', entityType: 'person', action: 'update', entityId: 'person-1', beforeJson: { toVerify: false }, afterJson: { toVerify: true }, conflictState: 'none' },
|
||||
],
|
||||
})
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions/session-1/review',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { decision: 'approved' },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.person.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ firstName: 'Alice', toVerify: true }) }))
|
||||
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'person-1' }), data: expect.objectContaining({ toVerify: true }) }))
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows owner to upload media below image limit', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hashPassword, verifyPassword } from '../src/lib/auth.js'
|
||||
|
||||
// bcrypt natif à la place de bcryptjs : les empreintes déjà en base restent valables
|
||||
describe('mots de passe, bcrypt natif', () => {
|
||||
it('lit une empreinte faite par bcryptjs', async () => {
|
||||
const fromBcryptjs = '$2b$10$4uBVFoNEA8gO3uIGq/RqMueaq4w2Vl9VzPFiSj9v9dc0oI1DcR.YS'
|
||||
expect(await verifyPassword('123soleil 🌞', fromBcryptjs)).toBe(true)
|
||||
expect(await verifyPassword('123soleil', fromBcryptjs)).toBe(false)
|
||||
})
|
||||
|
||||
it('fait une empreinte au coût 12 qui se relit', async () => {
|
||||
const stored = await hashPassword('mot-de-passe-solide')
|
||||
expect(stored).toMatch(/^\$2[aby]\$12\$/)
|
||||
expect(await verifyPassword('mot-de-passe-solide', stored)).toBe(true)
|
||||
expect(await verifyPassword('mot-de-passe-faux', stored)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
@@ -111,7 +117,7 @@ describe('prénom du compte', () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
|
||||
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true, oidcLinked: false })
|
||||
|
||||
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
|
||||
@@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { Prisma } from '../src/generated/prisma/client.js'
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
@@ -19,6 +25,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
treeMembership: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { decode, isBlurhashValid } from 'blurhash'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeBlurhash } from '../src/lib/blurhash.js'
|
||||
|
||||
// Aperçu flou calculé à l'envoi d'une photo : une chaîne valide, aux couleurs de la photo
|
||||
const demo = (name: string) => fileURLToPath(new URL(`../../frontend/public/demo/${name}`, import.meta.url))
|
||||
|
||||
describe('computeBlurhash', () => {
|
||||
it('donne un aperçu valide pour une vraie photo', async () => {
|
||||
const hash = await computeBlurhash(await readFile(demo('souvenir-mariage-1918.webp')))
|
||||
expect(hash).toBeTruthy()
|
||||
expect(isBlurhashValid(hash as string).result).toBe(true)
|
||||
// 4 × 3 composantes : 4 + 2 × 4 × 3 - 2 = 28 caractères
|
||||
expect(hash).toHaveLength(28)
|
||||
// Photo en noir et blanc : l'aperçu reste gris (rouge, vert et bleu proches)
|
||||
const px = decode(hash as string, 8, 8)
|
||||
const [r, g, b] = [px[0], px[1], px[2]]
|
||||
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(24)
|
||||
})
|
||||
|
||||
it('renvoie null pour un fichier qui n’est pas une image', async () => {
|
||||
expect(await computeBlurhash(Buffer.from('pas une image'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Co-gestion d'un arbre (#145), sur une vraie base : une fonction de droits se vérifie avec de vraies requêtes,
|
||||
// pas avec des mocks. Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8 pour les emoji) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/co-gestion.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
const { oidcMock } = vi.hoisted(() => ({ oidcMock: { startAuthorization: vi.fn(), finishAuthorization: vi.fn() } }))
|
||||
vi.mock('../src/lib/oidc.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/lib/oidc.js')>()),
|
||||
...oidcMock,
|
||||
}))
|
||||
|
||||
const ISSUER = 'https://id.example.org'
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean }
|
||||
let prisma: typeof import('../src/lib/prisma.js')['prisma']
|
||||
let ip = 0
|
||||
const nextIp = () => `10.1.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
const mail = (name: string) => `${name}-${run}@exemple.fr`
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
return { status: res.statusCode, body: res.body ? res.json() : null }
|
||||
}
|
||||
|
||||
async function register(name: string, extra: Record<string, unknown> = {}) {
|
||||
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name, ...extra })
|
||||
return res
|
||||
}
|
||||
|
||||
async function account(name: string) {
|
||||
const res = await register(name)
|
||||
expect(res.status).toBe(201)
|
||||
return { token: res.body.token as string, id: res.body.user.id as string, email: mail(name) }
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('co-gestion, vraie base', () => {
|
||||
let owner: { token: string; id: string; email: string }
|
||||
let treeId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.OIDC_ISSUER = ISSUER
|
||||
process.env.OIDC_CLIENT_ID = 'parenthese'
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
||||
process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback'
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
;({ prisma } = await import('../src/lib/prisma.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
owner = await account('proprio')
|
||||
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `demo-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
expect(tree.status).toBe(201)
|
||||
treeId = tree.body.tree?.id ?? tree.body.id
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.PASSWORD_LOGIN = true
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
async function invite(email: string) {
|
||||
const res = await call('POST', `/trees/${treeId}/invitations`, owner.token, { email })
|
||||
expect(res.status).toBe(201)
|
||||
return res.body as { token: string; invitation: { id: string } }
|
||||
}
|
||||
|
||||
it("un compte sans lien avec l'arbre ne voit ni ne change les gestionnaires", async () => {
|
||||
const stranger = await account('inconnu')
|
||||
expect((await call('GET', `/trees/${treeId}/managers`, stranger.token)).status).toBe(403)
|
||||
expect((await call('POST', `/trees/${treeId}/invitations`, stranger.token, { email: stranger.email })).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, stranger.token)).status).toBe(403)
|
||||
expect((await call('GET', `/trees/${treeId}/managers`)).status).toBe(401)
|
||||
})
|
||||
|
||||
it("le lien n'ouvre rien au mauvais compte, ne sert qu'une fois, et donne les droits d'administration", async () => {
|
||||
const coEmail = mail('cogest')
|
||||
const { token, invitation } = await invite(coEmail)
|
||||
|
||||
const preview = await call('GET', `/invitations/${token}`)
|
||||
expect(preview.status).toBe(200)
|
||||
expect(preview.body.treeName).toBe('Arbre de démo')
|
||||
expect(preview.body.emailHint).not.toContain(coEmail.split('@')[0])
|
||||
|
||||
// Le lien entre de mauvaises mains : un compte à une autre adresse ne l'accepte pas
|
||||
const thief = await account('voleur')
|
||||
expect((await call('POST', `/invitations/${token}/accept`, thief.token)).body.error).toBe('invitation_email_mismatch')
|
||||
expect((await call('PATCH', `/trees/${treeId}`, thief.token, { name: 'Volé' })).status).toBe(403)
|
||||
|
||||
const co = await account('cogest')
|
||||
const accepted = await call('POST', `/invitations/${token}/accept`, co.token)
|
||||
expect(accepted.status).toBe(200)
|
||||
expect(accepted.body).toEqual({ treeId, role: 'admin' })
|
||||
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(404)
|
||||
expect((await call('GET', `/invitations/${token}`)).body).toEqual({ error: 'invitation_used' })
|
||||
expect((await prisma.treeInvitation.findUnique({ where: { id: invitation.id } }))?.acceptedUserId).toBe(co.id)
|
||||
|
||||
// Co-gestionnaire : modifie l'arbre, voit la liste, mais n'invite pas, ne retire personne, ne supprime pas l'arbre
|
||||
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Par la co-gestionnaire' })).status).toBe(200)
|
||||
const list = await call('GET', `/trees/${treeId}/managers`, co.token)
|
||||
expect(list.body.canManage).toBe(false)
|
||||
expect(list.body.managers.map((m: { role: string }) => m.role).sort()).toEqual(['admin', 'owner'])
|
||||
expect((await call('POST', `/trees/${treeId}/invitations`, co.token, { email: mail('autre') })).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, co.token)).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}`, co.token, { confirmName: 'Arbre de démo' })).status).toBe(403)
|
||||
|
||||
// Le propriétaire le retire : son jeton de session ne sert plus à rien sur l'arbre, tout de suite
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, co.token, { description: 'Encore moi' })).status).toBe(403)
|
||||
expect((await call('GET', `/trees/${treeId}/managers`, co.token)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('un co-gestionnaire quitte seul, et ne retire jamais un autre co-gestionnaire', async () => {
|
||||
const a = await account('gestion-a')
|
||||
const b = await account('gestion-b')
|
||||
for (const who of [a, b]) {
|
||||
const { token } = await invite(who.email)
|
||||
expect((await call('POST', `/invitations/${token}/accept`, who.token)).status).toBe(200)
|
||||
}
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${b.id}`, a.token)).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${a.id}`, a.token)).status).toBe(200)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, a.token, { description: 'x' })).status).toBe(403)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, b.token, { description: 'y' })).status).toBe(200)
|
||||
// Le propriétaire ne se retire pas lui-même
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${owner.id}`, owner.token)).status).toBe(403)
|
||||
await call('DELETE', `/trees/${treeId}/managers/${b.id}`, owner.token)
|
||||
})
|
||||
|
||||
it('« Copier le lien » remplace le lien ; annulé ou expiré, il ne sert plus', async () => {
|
||||
const email = mail('rotation')
|
||||
const first = await invite(email)
|
||||
const rotated = await call('POST', `/trees/${treeId}/invitations/${first.invitation.id}/link`, owner.token)
|
||||
expect(rotated.status).toBe(200)
|
||||
expect((await call('GET', `/invitations/${first.token}`)).status).toBe(404)
|
||||
expect((await call('GET', `/invitations/${rotated.body.token}`)).status).toBe(200)
|
||||
|
||||
expect((await call('DELETE', `/trees/${treeId}/invitations/${first.invitation.id}`, owner.token)).status).toBe(200)
|
||||
const user = await account('rotation')
|
||||
expect((await call('POST', `/invitations/${rotated.body.token}/accept`, user.token)).status).toBe(404)
|
||||
|
||||
const late = await invite(email)
|
||||
await prisma.treeInvitation.update({ where: { id: late.invitation.id }, data: { expiresAt: new Date(Date.now() - 1000) } })
|
||||
expect((await call('POST', `/invitations/${late.token}/accept`, user.token)).status).toBe(404)
|
||||
expect((await call('GET', `/invitations/${late.token}`)).body).toEqual({ error: 'invitation_expired' })
|
||||
expect((await call('GET', `/invitations/${rotated.body.token}`)).body).toEqual({ error: 'invitation_invalid' })
|
||||
})
|
||||
|
||||
it("inscription fermée : l'invitation ouvre un compte, à l'adresse invitée seulement", async () => {
|
||||
const email = mail('ferme')
|
||||
const { token } = await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
|
||||
expect((await register('sans-invitation')).body.error).toBe('registration_closed')
|
||||
expect((await register('autre-adresse', { invitationToken: token })).body.error).toBe('invitation_email_mismatch')
|
||||
expect((await register('x', { invitationToken: 'faux-jeton' })).status).toBe(410)
|
||||
|
||||
const created = await register('ferme', { invitationToken: token })
|
||||
expect(created.status).toBe(201)
|
||||
expect(created.body.joinedTreeId).toBe(treeId)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, created.body.token, { description: 'z' })).status).toBe(200)
|
||||
// Le lien a servi : il n'ouvre pas un second compte
|
||||
expect((await call('POST', '/auth/register', undefined, { email: mail('ferme2'), password: 'mot-de-passe-solide', invitationToken: token })).status).toBe(410)
|
||||
await call('DELETE', `/trees/${treeId}/managers/${created.body.user.id}`, owner.token)
|
||||
})
|
||||
|
||||
it("un co-gestionnaire n'utilise jamais Immich, qui passe par la clé du propriétaire", async () => {
|
||||
const co = await account('immich')
|
||||
const { token } = await invite(co.email)
|
||||
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200)
|
||||
const config = await call('GET', `/trees/${treeId}/immich/config`, co.token)
|
||||
expect(config.status).toBe(200)
|
||||
expect(config.body).toEqual({ config: null, instances: [], ownerOnly: true })
|
||||
for (const url of ['/immich/assets?page=1&q=plage', '/immich/people', '/immich/status', '/immich/assets/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b/thumbnail', '/immich/thumbnail/3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b']) {
|
||||
expect((await call('GET', `/trees/${treeId}${url}`, co.token)).status).toBe(403)
|
||||
}
|
||||
expect((await call('PUT', `/trees/${treeId}/immich/config`, co.token, { instanceId: '0123456789abcdef', apiKey: 'y' })).status).toBe(403)
|
||||
expect((await call('DELETE', `/trees/${treeId}/immich/config`, co.token)).status).toBe(403)
|
||||
await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)
|
||||
})
|
||||
|
||||
it("retiré, un co-gestionnaire qui avait ouvert l'arbre par le mot de passe de partage ne le lit plus", async () => {
|
||||
const co = await account('partage')
|
||||
const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, co.token, { password: 'partage-demo-1' })
|
||||
expect(unlocked.status).toBe(200)
|
||||
expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(200)
|
||||
const { token } = await invite(co.email)
|
||||
expect((await call('POST', `/invitations/${token}/accept`, co.token)).status).toBe(200)
|
||||
expect((await call('DELETE', `/trees/${treeId}/managers/${co.id}`, owner.token)).status).toBe(200)
|
||||
expect((await call('GET', `/trees/${treeId}/graph`, co.token)).status).toBe(403)
|
||||
})
|
||||
|
||||
it("l'icône de l'arbre : un emoji ou rien, posée par un gestionnaire, rendue dans la liste des arbres", async () => {
|
||||
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: 'abc' })).status).toBe(400)
|
||||
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: '🌳' })).status).toBe(200)
|
||||
const list = await call('GET', '/trees', owner.token)
|
||||
expect(list.body.trees.find((t: { id: string }) => t.id === treeId).icon).toBe('🌳')
|
||||
expect((await call('PATCH', `/trees/${treeId}`, owner.token, { icon: null })).status).toBe(200)
|
||||
})
|
||||
|
||||
describe('OIDC', () => {
|
||||
let address = 0
|
||||
async function oidcLogin(identity: { subject: string; email: string; emailVerified: boolean }) {
|
||||
const remoteAddress = `10.9.0.${++address}`
|
||||
oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({
|
||||
url: new URL(`${ISSUER}/authorize?state=s`),
|
||||
pending: { state: 's', nonce: 'n', codeVerifier: 'v'.repeat(43), returnTo },
|
||||
}))
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ issuer: ISSUER, firstName: null, ...identity })
|
||||
const start = await app.inject({ method: 'GET', url: '/auth/oidc/start?returnTo=/', remoteAddress })
|
||||
const cookie = String(start.headers['set-cookie']).split(';')[0]
|
||||
const back = await app.inject({ method: 'GET', url: '/auth/oidc/callback?code=c&state=s', headers: { cookie }, remoteAddress })
|
||||
return new URLSearchParams(String(back.headers.location).split('#')[1] ?? '')
|
||||
}
|
||||
|
||||
it("email vérifié par le fournisseur : rattaché sans le lien, même inscription fermée", async () => {
|
||||
const email = mail('oidc-ok')
|
||||
await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const fragment = await oidcLogin({ subject: `sub-ok-${run}`, email, emailVerified: true })
|
||||
expect(fragment.get('oidc')).toBeTruthy()
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
|
||||
const membership = await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })
|
||||
expect(membership?.role).toBe('admin')
|
||||
})
|
||||
|
||||
it("email non vérifié : jamais rattaché, et pas de compte sur une instance fermée", async () => {
|
||||
const email = mail('oidc-non-verifie')
|
||||
await invite(email)
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const fragment = await oidcLogin({ subject: `sub-nv-${run}`, email, emailVerified: false })
|
||||
expect(fragment.get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(await prisma.user.findUnique({ where: { email } })).toBeNull()
|
||||
})
|
||||
|
||||
it("identité déjà reliée dont l'email a changé chez le fournisseur : pas d'invitation prise sans le lien", async () => {
|
||||
const subject = `sub-change-${run}`
|
||||
expect((await oidcLogin({ subject, email: mail('avant'), emailVerified: true })).get('oidc')).toBeTruthy()
|
||||
const target = mail('cible')
|
||||
await invite(target)
|
||||
expect((await oidcLogin({ subject, email: target, emailVerified: true })).get('oidc')).toBeTruthy()
|
||||
const user = await prisma.user.findUnique({ where: { email: mail('avant') }, select: { id: true } })
|
||||
expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull()
|
||||
})
|
||||
|
||||
it("email non vérifié sur un compte déjà relié : l'invitation reste en attente", async () => {
|
||||
const email = mail('oidc-relie')
|
||||
const subject = `sub-relie-${run}`
|
||||
expect((await oidcLogin({ subject, email, emailVerified: true })).get('oidc')).toBeTruthy()
|
||||
await invite(email)
|
||||
expect((await oidcLogin({ subject, email, emailVerified: false })).get('oidc')).toBeTruthy()
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } })
|
||||
expect(await prisma.treeMembership.findUnique({ where: { treeId_userId: { treeId, userId: user!.id } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,16 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
@@ -53,6 +59,7 @@ let storageRoot = ''
|
||||
let passwordHash = ''
|
||||
let createApp: () => FastifyInstance
|
||||
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
|
||||
let env: { PASSWORD_LOGIN: boolean }
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
@@ -97,6 +104,7 @@ beforeAll(async () => {
|
||||
|
||||
createApp = (await import('../src/app.js')).createApp
|
||||
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -220,6 +228,73 @@ describe('DELETE /auth/me', () => {
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => {
|
||||
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign({
|
||||
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
|
||||
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
|
||||
}, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
env.PASSWORD_LOGIN = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
env.PASSWORD_LOGIN = true
|
||||
})
|
||||
|
||||
it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un autre email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'autre@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'GET', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().user.hasPassword).toBe(false)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a wrong password without deleting anything', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const app = createApp()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Une session fermée reste fermée après un redémarrage de l'API (un déploiement), sur une vraie base. Avant, la
|
||||
// déconnexion ne vivait qu'en mémoire et le jeton redevenait valable jusqu'à 7 jours. Lancé seulement avec
|
||||
// TEST_DATABASE_URL (base jetable, migrée) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/deconnexion.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let ip = 0
|
||||
const nextIp = () => `10.5.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
return { status: res.statusCode, body: res.body ? res.json() : null }
|
||||
}
|
||||
|
||||
// Nouveau processus : la mémoire des déconnexions repart de zéro, puis l'API la recharge depuis la base
|
||||
async function restart() {
|
||||
await app.close()
|
||||
const { forgetRevokedTokensInMemory } = await import('../src/lib/token-revocation.js')
|
||||
forgetRevokedTokensInMemory()
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
await app.ready()
|
||||
}
|
||||
|
||||
async function login(name: string) {
|
||||
const email = `${name}-${run}@exemple.fr`
|
||||
const reg = await call('POST', '/auth/register', undefined, { email, password: 'mot-de-passe-solide', firstName: name })
|
||||
expect(reg.status).toBe(201)
|
||||
return reg.body.token as string
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('déconnexion durable, vraie base', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
const { env } = await import('../src/config/env.js')
|
||||
env.ALLOW_REGISTRATION = true
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('un jeton déconnecté reste refusé après un redémarrage', async () => {
|
||||
const token = await login('deconnecte')
|
||||
const other = await login('reste')
|
||||
expect((await call('GET', '/auth/me', token)).status).toBe(200)
|
||||
expect((await call('POST', '/auth/logout', token)).status).toBe(200)
|
||||
expect((await call('GET', '/auth/me', token)).status).toBe(401)
|
||||
|
||||
await restart()
|
||||
expect((await call('GET', '/auth/me', token)).status).toBe(401)
|
||||
// Une autre session, jamais fermée, passe toujours
|
||||
expect((await call('GET', '/auth/me', other)).status).toBe(200)
|
||||
})
|
||||
|
||||
it('une déconnexion expirée est effacée au démarrage', async () => {
|
||||
const { prisma } = await import('../src/lib/prisma.js')
|
||||
const jti = `expire-${run}`
|
||||
await prisma.revokedSession.create({ data: { jti, expiresAt: new Date(Date.now() - 1000) } })
|
||||
await restart()
|
||||
expect(await prisma.revokedSession.findUnique({ where: { jti } })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
@@ -96,6 +102,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseSex } from '../src/lib/person-sex'
|
||||
import { gedcomSexValue, parseGedcom } from '../src/routes/gedcom'
|
||||
|
||||
// Export en GEDCOM 5.5.1 : SEX ne vaut que M, F ou U (#118). « X » n'existe qu'en GEDCOM 7.
|
||||
describe('export GEDCOM 5.5.1 : valeur SEX', () => {
|
||||
it('écrit M, F, et U pour un sexe « autre »', () => {
|
||||
expect(gedcomSexValue('male')).toBe('M')
|
||||
expect(gedcomSexValue('female')).toBe('F')
|
||||
expect(gedcomSexValue('other')).toBe('U')
|
||||
})
|
||||
|
||||
it("n'écrit jamais X, et ne déclare rien pour un sexe non précisé", () => {
|
||||
for (const sex of ['male', 'female', 'other', null] as const) {
|
||||
expect(gedcomSexValue(sex)).not.toBe('X')
|
||||
}
|
||||
expect(gedcomSexValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('l’import relit toujours SEX X comme « autre », et U comme non précisé', () => {
|
||||
const sexes = parseGedcom(['0 @I1@ INDI', '1 SEX X', '0 @I2@ INDI', '1 SEX U'].join('\n'))
|
||||
.filter((node) => node.tag === 'INDI')
|
||||
.map((indi) => parseSex(indi.children.find((child) => child.tag === 'SEX')?.value ?? null))
|
||||
expect(sexes).toEqual(['other', null])
|
||||
})
|
||||
})
|
||||
@@ -169,3 +169,33 @@ describe('buildGraphPayload', () => {
|
||||
expect(graph.filiations[1].id).toBe('l-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildGraphPayload, personnes identifiées sur un souvenir', () => {
|
||||
const person = (id: string) => ({ id, treeId: 't-1', firstName: id, lastName: 'X', birthDate: null, deathDate: null, avatarPath: null, deletedAt: null })
|
||||
const media = { id: 'm-1', treeId: 't-1', personId: 'p-1', type: 'photo', mimeType: 'image/jpeg', filePath: 'f', caption: null, source: null, displayOrder: 1, isFeatured: false }
|
||||
|
||||
it('attaches people in order with their point, and drops a person missing from the tree', () => {
|
||||
const graph = buildGraphPayload(
|
||||
't-1',
|
||||
[person('p-1'), person('p-2')] as any,
|
||||
[],
|
||||
[],
|
||||
[media] as any,
|
||||
[],
|
||||
[
|
||||
{ mediaId: 'm-1', personId: 'p-2', x: 0.3, y: 0.6 },
|
||||
{ mediaId: 'm-1', personId: 'p-1', x: null, y: null },
|
||||
{ mediaId: 'm-1', personId: 'p-gone', x: 0.5, y: 0.5 },
|
||||
],
|
||||
)
|
||||
expect(graph.medias[0].people).toEqual([
|
||||
{ personId: 'p-2', x: 0.3, y: 0.6 },
|
||||
{ personId: 'p-1', x: null, y: null },
|
||||
])
|
||||
})
|
||||
|
||||
it('gives an empty list when nobody is identified', () => {
|
||||
const graph = buildGraphPayload('t-1', [person('p-1')] as any, [], [], [media] as any)
|
||||
expect(graph.medias[0].people).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { registerIdempotency, resetIdempotency } from '../src/lib/idempotency.js'
|
||||
|
||||
// Un envoi rejoué avec la même clé (réponse perdue sur un réseau faible) ne crée rien une seconde fois
|
||||
let app: FastifyInstance
|
||||
let created = 0
|
||||
let failNext = false
|
||||
|
||||
const KEY = 'a1b2c3d4-e5f6-4789-9abc-def012345678'
|
||||
const send = (headers: Record<string, string> = {}, url = '/trees/t1/contributions/sessions') =>
|
||||
app.inject({ method: 'POST', url, headers: { authorization: 'Bearer famille', ...headers }, payload: { title: 'x' } })
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify()
|
||||
registerIdempotency(app)
|
||||
app.post('/trees/:id/contributions/sessions', async (_request, reply) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
return reply.code(503).send({ error: 'busy' })
|
||||
}
|
||||
created += 1
|
||||
return reply.code(201).send({ session: { id: `s${created}` } })
|
||||
})
|
||||
app.post('/trees/:id/autre', async () => {
|
||||
created += 1
|
||||
return { ok: created }
|
||||
})
|
||||
await app.ready()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
created = 0
|
||||
failNext = false
|
||||
resetIdempotency()
|
||||
})
|
||||
|
||||
describe('Idempotency-Key', () => {
|
||||
it('rejoue la première réponse sans recréer', async () => {
|
||||
const first = await send({ 'idempotency-key': KEY })
|
||||
const second = await send({ 'idempotency-key': KEY })
|
||||
expect(created).toBe(1)
|
||||
expect(second.statusCode).toBe(201)
|
||||
expect(second.json()).toEqual(first.json())
|
||||
expect(second.headers['idempotent-replayed']).toBe('true')
|
||||
})
|
||||
|
||||
it('deux essais simultanés : un seul est traité', async () => {
|
||||
const [a, b] = await Promise.all([send({ 'idempotency-key': KEY }), send({ 'idempotency-key': KEY })])
|
||||
expect(created).toBe(1)
|
||||
expect(a.json()).toEqual(b.json())
|
||||
})
|
||||
|
||||
it('un échec n’est pas gardé : l’essai suivant refait la requête', async () => {
|
||||
failNext = true
|
||||
const first = await send({ 'idempotency-key': KEY })
|
||||
const second = await send({ 'idempotency-key': KEY })
|
||||
expect(first.statusCode).toBe(503)
|
||||
expect(second.statusCode).toBe(201)
|
||||
expect(created).toBe(1)
|
||||
})
|
||||
|
||||
it('la clé d’une autre personne ne rejoue rien', async () => {
|
||||
await send({ 'idempotency-key': KEY })
|
||||
await send({ 'idempotency-key': KEY, authorization: 'Bearer quelquun-dautre' })
|
||||
expect(created).toBe(2)
|
||||
})
|
||||
|
||||
it('sans clé, ou avec une clé mal formée, chaque envoi compte', async () => {
|
||||
await send()
|
||||
await send()
|
||||
await send({ 'idempotency-key': 'court' })
|
||||
await send({ 'idempotency-key': 'court' })
|
||||
expect(created).toBe(4)
|
||||
})
|
||||
|
||||
it('les autres routes ne sont pas concernées', async () => {
|
||||
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
|
||||
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
|
||||
expect(created).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
@@ -25,6 +31,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
mediaItem: {
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,12 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
@@ -23,6 +29,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
treeImmichConfig: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -9,6 +9,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
@@ -26,6 +32,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
mediaItem: {
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
@@ -65,6 +76,161 @@ function mockImmichPerson(options: { thumbnailPath?: string; thumbnailStatus?: n
|
||||
})
|
||||
}
|
||||
|
||||
describe('Souvenirs Immich', () => {
|
||||
it('combine le contexte et un visage sur la recherche intelligente Immich', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?q=repas%20au%20jardin&personId=${immichPersonId}&page=2`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/smart')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ page: 2, size: 30, type: 'IMAGE', isTrashed: false, query: 'repas au jardin', personIds: [immichPersonId] })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('filtre par visage sans contexte via les métadonnées', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [], nextPage: null } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?personId=${immichPersonId}&q=%20%20`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ personIds: [immichPersonId] })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un visage invalide et un contexte excessif avant tout appel distant', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
for (const query of ['personId=invalid', `q=${'x'.repeat(501)}`]) {
|
||||
expect((await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets?${query}`, headers: { authorization: `Bearer ${token}` } })).statusCode).toBe(400)
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('charge un aperçu agrandi sans demander le fichier original', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes)))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/preview`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe(`http://immich.test/api/assets/${immichPersonId}/thumbnail?size=preview`)
|
||||
expect(response.headers['content-type']).toBe('image/jpeg')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('réserve la photothèque aux administrateurs et ne contacte pas Immich en cas de refus', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('pagine les vidéos sans exposer les métadonnées privées', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(await jsonResponse({ assets: { items: [{ id: immichPersonId, type: 'VIDEO', originalFileName: 'famille.mp4', originalPath: '/private/video.mp4' }], nextPage: '3' } }))
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/assets?page=2&type=VIDEO', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ assets: [{ id: immichPersonId, type: 'VIDEO', name: 'famille.mp4' }], hasNextPage: true })
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://immich.test/api/search/metadata')
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({ page: 2, type: 'VIDEO', size: 30 })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse les originaux trop gros avant de les lire', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
fetchMock.mockResolvedValue(new Response('too large', { headers: { 'content-length': String(21 * 1024 * 1024) } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('sert une photo avec son type réel et refuse le HTML', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes)))
|
||||
const response = await app.inject(options)
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.headers['content-type']).toBe('image/jpeg')
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>unsafe</html>'))
|
||||
expect((await app.inject(options)).statusCode).toBe(400)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it.each(['preview', 'original'])('coupe une image à 5 Mo en flux sur %s, même déclarée vidéo', async (variant) => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const cancel = vi.fn()
|
||||
let sent = 0
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
// La signature JPEG arrive en trois chunks, sans Content-Length.
|
||||
if (sent < 3) controller.enqueue(new Uint8Array(jpegBytes.subarray(sent, ++sent)))
|
||||
else { sent++; controller.enqueue(new Uint8Array(1024 * 1024)) }
|
||||
}, cancel,
|
||||
}, { highWaterMark: 0 })
|
||||
fetchMock.mockResolvedValueOnce(new Response(stream, { headers: { 'content-type': 'video/mp4' } }))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/${variant}`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(sent).toBe(8) // 3 octets + 5 Mo : aucun chunk supplémentaire lu.
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une image annoncée au-dessus de 5 Mo avant toute lecture', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const pull = vi.fn()
|
||||
const cancel = vi.fn()
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ pull, cancel }, { highWaterMark: 0 }), {
|
||||
headers: { 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) },
|
||||
}))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(413)
|
||||
expect(pull).not.toHaveBeenCalled()
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('conserve la limite vidéo de 20 Mo au-delà de 5 Mo', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const video = Buffer.alloc(6 * 1024 * 1024)
|
||||
video.set(Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom')]))
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
|
||||
const response = await app.inject({ method: 'GET', url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.rawPayload.equals(video)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('importe les octets d’une vidéo et coupe un flux qui dépasse la limite', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const options = { method: 'GET' as const, url: `/trees/tree-1/immich/assets/${immichPersonId}/original`, headers: { authorization: `Bearer ${token}` } }
|
||||
const video = Buffer.from([0, 0, 0, 24, ...Buffer.from('ftypisom'), 0, 0, 0, 0])
|
||||
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(video)))
|
||||
const response = await app.inject(options)
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.headers['content-type']).toBe('video/mp4')
|
||||
expect(response.rawPayload).toEqual(video)
|
||||
const cancel = vi.fn()
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) { controller.enqueue(new Uint8Array(21 * 1024 * 1024)) }, cancel,
|
||||
})))
|
||||
expect((await app.inject(options)).statusCode).toBe(413)
|
||||
expect(cancel).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
@@ -1046,6 +1212,7 @@ describe('Immich integration: suppression logique d’une personne', () => {
|
||||
calls.push({ model: name, method: 'updateMany', args })
|
||||
return { count: 0 }
|
||||
},
|
||||
deleteMany: async () => ({ count: 0 }),
|
||||
})
|
||||
const db = new Proxy({} as Record<string, unknown>, {
|
||||
get: (_target, name: string) => model(name),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Souvenir en attente de relecture, sur une vraie base : seuls qui gère l'arbre et qui l'a envoyé le lisent,
|
||||
// la famille qui connaîtrait son identifiant reçoit la même réponse qu'un média absent. Lancé seulement avec
|
||||
// TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-attente.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
|
||||
let ip = 0
|
||||
const nextIp = () => `10.3.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
const mail = (name: string) => `${name}-${run}@exemple.fr`
|
||||
// PNG d'un pixel
|
||||
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
||||
const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-attente-'))
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
let body = null
|
||||
try { body = res.body ? res.json() : null } catch { body = null }
|
||||
return { status: res.statusCode, body }
|
||||
}
|
||||
|
||||
async function account(name: string) {
|
||||
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name })
|
||||
expect(res.status).toBe(201)
|
||||
return { token: res.body.token as string, id: res.body.user.id as string }
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('souvenir en attente, vraie base', () => {
|
||||
let owner: { token: string; id: string }
|
||||
let member: { token: string; id: string }
|
||||
let reader: { token: string; id: string }
|
||||
let shareToken: string
|
||||
let treeId: string
|
||||
let personId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.MEDIA_STORAGE_PATH = storage
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.MEDIA_STORAGE_PATH = storage
|
||||
owner = await account('proprio')
|
||||
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `attente-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
expect(tree.status).toBe(201)
|
||||
treeId = tree.body.tree?.id ?? tree.body.id
|
||||
const person = await call('POST', `/trees/${treeId}/persons`, owner.token, { firstName: 'Odette', lastName: 'Duval' })
|
||||
expect(person.status).toBe(201)
|
||||
personId = person.body.person?.id ?? person.body.id
|
||||
// Deux comptes de la famille, rattachés par le mot de passe de partage, et le lien de partage seul
|
||||
member = await account('membre')
|
||||
reader = await account('lecteur')
|
||||
for (const who of [member, reader]) {
|
||||
expect((await call('POST', `/trees/${treeId}/access/unlock`, who.token, { password: 'partage-demo-1' })).status).toBe(200)
|
||||
}
|
||||
const shared = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' })
|
||||
expect(shared.status).toBe(200)
|
||||
shareToken = shared.body.token
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
const upload = (token: string) => call('POST', `/trees/${treeId}/persons/${personId}/media`, token, {
|
||||
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
|
||||
})
|
||||
const read = (mediaId: string, token?: string) => call('GET', `/trees/${treeId}/media/${mediaId}`, token)
|
||||
// Jeton médias, celui que porte l'adresse d'une image (<img src>) : émis avec le graphe de l'arbre
|
||||
const mediaToken = async (token: string) => (await call('GET', `/trees/${treeId}/graph`, token)).body?.mediaToken as string
|
||||
|
||||
it('un souvenir envoyé par un membre attend la relecture, et seuls le gestionnaire et son auteur le lisent', async () => {
|
||||
const sent = await upload(member.token)
|
||||
expect(sent.status).toBe(201)
|
||||
const media = sent.body.media ?? sent.body
|
||||
expect(media.status).toBe('pending')
|
||||
|
||||
expect((await read(media.id, owner.token)).status).toBe(200)
|
||||
expect((await read(media.id, member.token)).status).toBe(200)
|
||||
expect((await read(media.id, reader.token)).status).toBe(404)
|
||||
expect((await read(media.id, shareToken)).status).toBe(404)
|
||||
expect((await read(media.id)).status).toBe(401)
|
||||
|
||||
// Par l'adresse d'une image : le jeton médias du gestionnaire ouvre, celui du lecteur non
|
||||
expect((await read(`${media.id}?token=${await mediaToken(owner.token)}`)).status).toBe(200)
|
||||
expect((await read(`${media.id}?token=${await mediaToken(member.token)}`)).status).toBe(200)
|
||||
expect((await read(`${media.id}?token=${await mediaToken(reader.token)}`)).status).toBe(404)
|
||||
expect((await read(`${media.id}?token=${await mediaToken(shareToken)}`)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('un souvenir accepté se lit par toute la famille', async () => {
|
||||
const sent = await upload(member.token)
|
||||
const media = sent.body.media ?? sent.body
|
||||
expect(media.status).toBe('pending')
|
||||
const session = sent.body.contributionSessionId ?? media.contributionSessionId
|
||||
expect(session).toBeTruthy()
|
||||
const reviewed = await call('POST', `/trees/${treeId}/contributions/sessions/${session}/review`, owner.token, { decision: 'approved' })
|
||||
expect(reviewed.status).toBe(200)
|
||||
expect((await read(media.id, reader.token)).status).toBe(200)
|
||||
expect((await read(media.id, shareToken)).status).toBe(200)
|
||||
})
|
||||
|
||||
it('un souvenir posé par le gestionnaire est visible tout de suite', async () => {
|
||||
const sent = await upload(owner.token)
|
||||
expect(sent.status).toBe(201)
|
||||
const media = sent.body.media ?? sent.body
|
||||
expect(media.status).toBe('approved')
|
||||
expect((await read(media.id, reader.token)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { replaceMediaPeople } from '../src/lib/media-people.js'
|
||||
|
||||
describe('replaceMediaPeople', () => {
|
||||
it('keeps only people still in the tree, in the order sent', async () => {
|
||||
const db = {
|
||||
person: { findMany: vi.fn(async () => [{ id: 'p-3' }, { id: 'p-1' }]) },
|
||||
mediaPersonTag: { deleteMany: vi.fn(async () => ({ count: 0 })), createMany: vi.fn(async () => ({ count: 2 })) },
|
||||
}
|
||||
const kept = await replaceMediaPeople(db as any, 't-1', 'm-1', [
|
||||
{ personId: 'p-1', x: 0.1, y: 0.2 },
|
||||
{ personId: 'p-deleted', x: null, y: null },
|
||||
{ personId: 'p-3', x: null, y: null },
|
||||
], 'u-1')
|
||||
expect(kept.map((p) => p.personId)).toEqual(['p-1', 'p-3'])
|
||||
expect(db.mediaPersonTag.deleteMany).toHaveBeenCalledWith({ where: { treeId: 't-1', mediaId: 'm-1' } })
|
||||
const rows = (db.mediaPersonTag.createMany.mock.calls[0] as any)[0].data
|
||||
expect(rows.map((row: any) => row.personId)).toEqual(['p-1', 'p-3'])
|
||||
expect(rows[0].createdAt.getTime()).toBeLessThan(rows[1].createdAt.getTime())
|
||||
})
|
||||
|
||||
it('only clears when the list is empty', async () => {
|
||||
const db = {
|
||||
person: { findMany: vi.fn() },
|
||||
mediaPersonTag: { deleteMany: vi.fn(async () => ({ count: 3 })), createMany: vi.fn() },
|
||||
}
|
||||
expect(await replaceMediaPeople(db as any, 't-1', 'm-1', [], null)).toEqual([])
|
||||
expect(db.person.findMany).not.toHaveBeenCalled()
|
||||
expect(db.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
tree: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
count: vi.fn(async () => 0),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
treeSettings: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
treeAccessPasswords: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
treeSharePassword: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
userTreeAccess: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
person: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
union: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
parentChildLink: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
mediaItem: {
|
||||
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
lifeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contributionChange: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) }
|
||||
})
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
const URL_PEOPLE = '/trees/tree-1/media/media-1/people'
|
||||
|
||||
beforeAll(async () => {
|
||||
applyTestEnv()
|
||||
const module = await import('../src/app.js')
|
||||
createApp = module.createApp
|
||||
})
|
||||
|
||||
function setRole(role: string) {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||||
treeId: 'tree-1',
|
||||
userId: 'user-1',
|
||||
role,
|
||||
contributionModeOverride: null,
|
||||
tree: { deletedAt: null, rootPersonId: null, settings: { memberContributionPolicy: 'pending' } },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
||||
setRole('owner')
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', personId: 'person-1' })
|
||||
prismaMock.person.count.mockImplementation(async ({ where }: any) => where.id.in.length)
|
||||
prismaMock.person.findMany.mockImplementation(async ({ where }: any) => where.id.in.map((id: string) => ({ id })))
|
||||
prismaMock.mediaPersonTag.findMany.mockResolvedValue([{ personId: 'person-2', x: null, y: null }])
|
||||
prismaMock.mediaPersonTag.deleteMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.mediaPersonTag.createMany.mockResolvedValue({ count: 2 })
|
||||
prismaMock.contributionSession.count.mockResolvedValue(0)
|
||||
prismaMock.contributionSession.create.mockResolvedValue({ id: 'session-1' })
|
||||
prismaMock.contributionChange.create.mockResolvedValue({ id: 'change-1' })
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
||||
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock))
|
||||
})
|
||||
|
||||
const PEOPLE = [
|
||||
{ personId: 'person-2', x: 0.25, y: 0.4 },
|
||||
{ personId: 'person-3' },
|
||||
]
|
||||
|
||||
async function put(app: FastifyInstance, payload: unknown) {
|
||||
const token = await createUserToken(app)
|
||||
return app.inject({ method: 'PUT', url: URL_PEOPLE, headers: { authorization: `Bearer ${token}` }, payload })
|
||||
}
|
||||
|
||||
describe('PUT media people (identifier des personnes sur un souvenir)', () => {
|
||||
it('saves directly for an admin, without a contribution session', async () => {
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().status).toBe('approved')
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', mediaId: 'media-1' } })
|
||||
const created = prismaMock.mediaPersonTag.createMany.mock.calls[0][0].data
|
||||
expect(created.map((tag: any) => [tag.personId, tag.x, tag.y])).toEqual([['person-2', 0.25, 0.4], ['person-3', null, null]])
|
||||
expect(prismaMock.contributionSession.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('turns a contributor request into a pending proposal, nothing applied', async () => {
|
||||
setRole('contributor')
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE, submittedByLabel: 'Lucie' })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().status).toBe('pending')
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.contributionSession.create.mock.calls[0][0].data).toMatchObject({ status: 'pending', submittedByLabel: 'Lucie' })
|
||||
const change = prismaMock.contributionChange.create.mock.calls[0][0].data
|
||||
expect(change).toMatchObject({ entityType: 'media_people', entityId: 'media-1', action: 'update' })
|
||||
expect(change.beforeJson.people).toEqual([{ personId: 'person-2', x: null, y: null }])
|
||||
expect(change.afterJson.people).toHaveLength(2)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a person from another tree', async () => {
|
||||
prismaMock.person.count.mockResolvedValueOnce(1)
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(400)
|
||||
expect(response.json().error).toBe('invalid_person_reference')
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('answers 404 for a missing or pending souvenir', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: PEOPLE })
|
||||
expect(response.statusCode).toBe(404)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects a duplicate person, a lone coordinate and a point outside the photo', async () => {
|
||||
const app = createApp()
|
||||
const duplicate = await put(app, { people: [{ personId: 'person-2' }, { personId: 'person-2' }] })
|
||||
expect(duplicate.statusCode).toBe(400)
|
||||
const lone = await put(app, { people: [{ personId: 'person-2', x: 0.5 }] })
|
||||
expect(lone.statusCode).toBe(400)
|
||||
const outside = await put(app, { people: [{ personId: 'person-2', x: 1.2, y: 0.5 }] })
|
||||
expect(outside.statusCode).toBe(400)
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('clears every identification with an empty list', async () => {
|
||||
const app = createApp()
|
||||
const response = await put(app, { people: [] })
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.mediaPersonTag.deleteMany).toHaveBeenCalled()
|
||||
expect(prismaMock.mediaPersonTag.createMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires a session', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const response = await app.inject({ method: 'PUT', url: URL_PEOPLE, payload: { people: [] } })
|
||||
expect(response.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Un souvenir se lit par morceaux (Range), sur une vraie base. Lancé seulement avec TEST_DATABASE_URL :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-range.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
|
||||
let ip = 0
|
||||
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
// PNG d'un pixel
|
||||
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
||||
const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-range-'))
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown, headers: Record<string, string> = {}) {
|
||||
return app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers },
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('souvenir lu par morceaux, vraie base', () => {
|
||||
let token: string
|
||||
let url: string
|
||||
let treeId: string
|
||||
let personId: string
|
||||
let whole: Buffer
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.MEDIA_STORAGE_PATH = storage
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.MEDIA_STORAGE_PATH = storage
|
||||
const owner = await call('POST', '/auth/register', undefined, { email: `range-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Hélène' })
|
||||
token = owner.json().token
|
||||
const tree = await call('POST', '/trees', token, { name: 'Arbre de démo', slug: `range-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
treeId = tree.json().tree?.id ?? tree.json().id
|
||||
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
|
||||
personId = person.json().person?.id ?? person.json().id
|
||||
const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, {
|
||||
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
|
||||
})
|
||||
expect(sent.statusCode).toBe(201)
|
||||
const media = sent.json().media ?? sent.json()
|
||||
url = `/trees/${treeId}/media/${media.id}`
|
||||
const full = await call('GET', url, token)
|
||||
expect(full.statusCode).toBe(200)
|
||||
whole = full.rawPayload
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('sans plage : tout le fichier, et l annonce des plages', async () => {
|
||||
const res = await call('GET', url, token)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['accept-ranges']).toBe('bytes')
|
||||
expect(Number(res.headers['content-length'])).toBe(whole.length)
|
||||
expect(res.headers['content-type']).toBe('image/png')
|
||||
})
|
||||
|
||||
it('les deux premiers octets, comme Safari avant une vidéo', async () => {
|
||||
const res = await call('GET', url, token, undefined, { range: 'bytes=0-1' })
|
||||
expect(res.statusCode).toBe(206)
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-1/${whole.length}`)
|
||||
expect(res.rawPayload.equals(whole.subarray(0, 2))).toBe(true)
|
||||
})
|
||||
|
||||
it('la fin du fichier, et une plage hors du fichier', async () => {
|
||||
const tail = await call('GET', url, token, undefined, { range: 'bytes=-10' })
|
||||
expect(tail.statusCode).toBe(206)
|
||||
expect(tail.rawPayload.equals(whole.subarray(whole.length - 10))).toBe(true)
|
||||
const out = await call('GET', url, token, undefined, { range: `bytes=${whole.length}-` })
|
||||
expect(out.statusCode).toBe(416)
|
||||
expect(out.headers['content-range']).toBe(`bytes */${whole.length}`)
|
||||
})
|
||||
|
||||
it('les droits passent avant la plage, souvenir en attente compris', async () => {
|
||||
expect((await call('GET', url, undefined, undefined, { range: 'bytes=0-1' })).statusCode).toBe(401)
|
||||
// Un membre envoie un souvenir, qui attend la relecture ; un autre membre le demande par morceaux
|
||||
const register = async (name: string) => (await call('POST', '/auth/register', undefined, { email: `${name}-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: name })).json().token as string
|
||||
const author = await register('auteur')
|
||||
const reader = await register('lecteur')
|
||||
for (const t of [author, reader]) expect((await call('POST', `/trees/${treeId}/access/unlock`, t, { password: 'partage-demo-1' })).statusCode).toBe(200)
|
||||
const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, author, {
|
||||
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
|
||||
})
|
||||
const pending = sent.json().media ?? sent.json()
|
||||
expect(pending.status).toBe('pending')
|
||||
const pendingUrl = `/trees/${treeId}/media/${pending.id}`
|
||||
expect((await call('GET', pendingUrl, reader, undefined, { range: 'bytes=0-1' })).statusCode).toBe(404)
|
||||
expect((await call('GET', pendingUrl, author, undefined, { range: 'bytes=0-1' })).statusCode).toBe(206)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseByteRange } from '../src/lib/media-files.js'
|
||||
|
||||
// Plages demandées par les navigateurs pour lire une vidéo (Safari ne lit rien sans elles)
|
||||
describe('parseByteRange', () => {
|
||||
it('lit les plages que demandent Safari et Chrome', () => {
|
||||
expect(parseByteRange('bytes=0-1', 1000)).toEqual({ start: 0, end: 1 })
|
||||
expect(parseByteRange('bytes=0-', 1000)).toEqual({ start: 0, end: 999 })
|
||||
expect(parseByteRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 })
|
||||
expect(parseByteRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 })
|
||||
})
|
||||
|
||||
it('borne la fin à la taille du fichier', () => {
|
||||
expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ start: 900, end: 999 })
|
||||
expect(parseByteRange('bytes=-5000', 1000)).toEqual({ start: 0, end: 999 })
|
||||
})
|
||||
|
||||
it('refuse une plage hors du fichier', () => {
|
||||
expect(parseByteRange('bytes=1000-', 1000)).toBe('unsatisfiable')
|
||||
expect(parseByteRange('bytes=5-2', 1000)).toBe('unsatisfiable')
|
||||
expect(parseByteRange('bytes=-0', 1000)).toBe('unsatisfiable')
|
||||
expect(parseByteRange('bytes=-5', 0)).toBe('unsatisfiable')
|
||||
})
|
||||
|
||||
it('envoie tout sans plage lisible', () => {
|
||||
expect(parseByteRange(undefined, 1000)).toBeNull()
|
||||
expect(parseByteRange('bytes=-', 1000)).toBeNull()
|
||||
expect(parseByteRange('bytes=0-1,5-9', 1000)).toBeNull()
|
||||
expect(parseByteRange('items=0-1', 1000)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
@@ -92,6 +98,11 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
mediaPersonTag: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
|
||||
+119
-1
@@ -6,6 +6,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie.
|
||||
const { prismaMock, oidcMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
@@ -15,6 +21,10 @@ const { prismaMock, oidcMock } = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
treeInvitation: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
@@ -71,6 +81,8 @@ beforeEach(() => {
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue(null)
|
||||
prismaMock.treeInvitation.findFirst.mockResolvedValue(null)
|
||||
prismaMock.treeInvitation.findMany.mockResolvedValue([])
|
||||
prismaMock.userIdentity.create.mockResolvedValue({})
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-new' })
|
||||
prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock))
|
||||
@@ -102,7 +114,7 @@ function fragmentOf(location: string | undefined) {
|
||||
describe('départ et retour', () => {
|
||||
it("annonce le fournisseur à l'écran de connexion", async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } })
|
||||
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' }, signupPrompts: false })
|
||||
})
|
||||
|
||||
it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => {
|
||||
@@ -168,6 +180,29 @@ describe('compte relié, créé ou refusé', () => {
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({
|
||||
where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } },
|
||||
select: { userId: true },
|
||||
})
|
||||
expect(prismaMock.user.findUnique).not.toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' })
|
||||
const { cookie } = await start()
|
||||
@@ -308,6 +343,89 @@ describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () =>
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('rattacher le fournisseur à un compte à mot de passe (avant PASSWORD_LOGIN=false)', () => {
|
||||
const sessionOf = (userId: string) => app.jwt.sign({ kind: 'user', sub: userId, jti: `s-${userId}`, userId, email: 'anais@example.com' })
|
||||
|
||||
async function ticketFor(userId: string) {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', headers: { authorization: `Bearer ${sessionOf(userId)}` }, remoteAddress: nextAddress() })
|
||||
expect(res.statusCode).toBe(200)
|
||||
return res.json().ticket as string
|
||||
}
|
||||
|
||||
async function startLink(ticket: string) {
|
||||
currentAddress = nextAddress()
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?link=${encodeURIComponent(ticket)}&returnTo=${encodeURIComponent('/arbre/abc')}`, remoteAddress: currentAddress })
|
||||
return { res, cookie: String(res.headers['set-cookie']).split(';')[0] }
|
||||
}
|
||||
|
||||
it('sans session, pas de ticket', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', remoteAddress: nextAddress() })
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("relie l'identité au compte qui a demandé le ticket, même avec un mot de passe et sans regarder l'email", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { res: started, cookie } = await startLink(ticket)
|
||||
expect(started.statusCode).toBe(302)
|
||||
// Le retour de l'app ne vient pas de la requête : un rattachement revient toujours à la racine
|
||||
expect(oidcMock.startAuthorization).toHaveBeenCalledWith('/')
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'autre@example.com', emailVerified: false })
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-pw', issuer: ISSUER, subject: 'sub-1' } })
|
||||
const code = fragmentOf(res.headers.location).get('oidc') as string
|
||||
expect(app.jwt.decode<{ sub: string }>(code)?.sub).toBe('user-pw')
|
||||
// Le rattachement ne passe jamais par la recherche d'un compte par email ni par la création d'un compte
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("une identité déjà reliée à un autre compte n'est jamais déplacée", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-other' })
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_identity_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('déjà reliée à ce compte : connexion, rien de recréé', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-pw' })
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('le ticket sert une fois, et un faux ticket ou une session ne lancent rien', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
expect((await startLink(ticket)).res.statusCode).toBe(302)
|
||||
const again = await startLink(ticket)
|
||||
expect(fragmentOf(again.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
const forged = await startLink('nimportequoi')
|
||||
expect(fragmentOf(forged.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
const session = await startLink(sessionOf('user-pw'))
|
||||
expect(fragmentOf(session.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
|
||||
})
|
||||
|
||||
it("le ticket n'ouvre aucune session", async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${ticket}` } })
|
||||
expect(asBearer.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('compte supprimé entre le ticket et le retour : refusé', async () => {
|
||||
const ticket = await ticketFor('user-pw')
|
||||
const { cookie } = await startLink(ticket)
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('journaux', () => {
|
||||
it("le code d'autorisation du retour n'est pas journalisé", async () => {
|
||||
const { redactUrl } = await import('../src/app.js')
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { existsSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
// Propositions de la famille sur l'arbre (1.5.1) : photos posées sur l'arbre, annotations posées par le serveur
|
||||
// à l'acceptation, état d'une proposition. Sur une vraie base, comme toute règle de droits :
|
||||
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/propositions-fantomes.db.test.ts
|
||||
const TEST_DB = process.env.TEST_DATABASE_URL
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
|
||||
let prisma: typeof import('../src/lib/prisma.js')['prisma']
|
||||
let ip = 0
|
||||
const nextIp = () => `10.2.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
|
||||
const run = Date.now().toString(36)
|
||||
const mail = (name: string) => `${name}-${run}@exemple.fr`
|
||||
// PNG d'un pixel
|
||||
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
|
||||
const storage = mkdtempSync(path.join(tmpdir(), 'pth-151-'))
|
||||
|
||||
async function call(method: string, url: string, token?: string, payload?: unknown) {
|
||||
const res = await app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as Record<string, unknown>,
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
remoteAddress: nextIp(),
|
||||
})
|
||||
return { status: res.statusCode, body: res.body ? res.json() : null }
|
||||
}
|
||||
|
||||
async function account(name: string) {
|
||||
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name })
|
||||
expect(res.status).toBe(201)
|
||||
return { token: res.body.token as string, id: res.body.user.id as string }
|
||||
}
|
||||
|
||||
describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base', () => {
|
||||
let owner: { token: string; id: string }
|
||||
let treeId: string
|
||||
let family: string
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = TEST_DB
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.MEDIA_STORAGE_PATH = storage
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
;({ prisma } = await import('../src/lib/prisma.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.MEDIA_STORAGE_PATH = storage
|
||||
owner = await account('proprio')
|
||||
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `fantomes-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
|
||||
expect(tree.status).toBe(201)
|
||||
treeId = tree.body.tree?.id ?? tree.body.id
|
||||
const unlocked = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' })
|
||||
expect(unlocked.status).toBe(200)
|
||||
family = unlocked.body.token
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
const upload = (token?: string) => call('POST', `/trees/${treeId}/annotation-photos`, token, {
|
||||
fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
|
||||
})
|
||||
const photoAnnotation = (photoPath: string) => ({ type: 'photo', x: 10, y: 20, content: JSON.stringify({ photoPath, width: 1, height: 1 }), style: {}, zIndex: 1 })
|
||||
const propose = (token: string, changes: unknown[]) => call('POST', `/trees/${treeId}/contributions/sessions`, token, { title: 'Proposition de la famille', submittedByLabel: 'Cousine', changes })
|
||||
const review = (sessionId: string, decision: 'approved' | 'rejected') => call('POST', `/trees/${treeId}/contributions/sessions/${sessionId}/review`, owner.token, { decision })
|
||||
const annotations = async () => (await call('GET', `/trees/${treeId}/annotations`, owner.token)).body.annotations as Array<{ id: string; type: string; content: string }>
|
||||
const fileOf = async (photoId: string) => (await prisma.annotationPhotoUpload.findUnique({ where: { id: photoId } }))?.filePath
|
||||
|
||||
it("personne d'extérieur ne pose une photo : sans jeton 401, un compte sans lien avec l'arbre 403", async () => {
|
||||
expect((await upload()).status).toBe(401)
|
||||
const stranger = await account('inconnu')
|
||||
expect((await upload(stranger.token)).status).toBe(403)
|
||||
})
|
||||
|
||||
it('la photo de la famille attend la relecture, puis devient une annotation au moment où elle est acceptée', async () => {
|
||||
const sent = await upload(family)
|
||||
expect(sent.status).toBe(201)
|
||||
const filePath = await fileOf(sent.body.photoId)
|
||||
expect(filePath).toBeTruthy()
|
||||
|
||||
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
|
||||
expect(proposed.status).toBe(201)
|
||||
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false)
|
||||
|
||||
expect((await review(proposed.body.session.id, 'approved')).status).toBe(200)
|
||||
expect((await annotations()).some((a) => a.type === 'photo' && a.content.includes(sent.body.photoId))).toBe(true)
|
||||
expect(await fileOf(sent.body.photoId)).toBeUndefined()
|
||||
expect(existsSync(path.join(storage, filePath as string))).toBe(true)
|
||||
})
|
||||
|
||||
it('refusée, la photo de la famille est effacée du disque', async () => {
|
||||
const sent = await upload(family)
|
||||
const filePath = await fileOf(sent.body.photoId) as string
|
||||
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
|
||||
expect((await review(proposed.body.session.id, 'rejected')).status).toBe(200)
|
||||
expect(await fileOf(sent.body.photoId)).toBeUndefined()
|
||||
expect(existsSync(path.join(storage, filePath))).toBe(false)
|
||||
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(false)
|
||||
})
|
||||
|
||||
it('un dessin accepté est posé par le serveur, sans geste du navigateur', async () => {
|
||||
const before = (await annotations()).length
|
||||
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'drawing', x: 1, y: 2, content: 'M0 0 L10 10', style: { color: '#b4532a' } } }])
|
||||
expect((await review(proposed.body.session.id, 'approved')).status).toBe(200)
|
||||
expect((await annotations()).length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it("une ancienne proposition d'annotation incomplète est écartée sans bloquer le reste de la relecture", async () => {
|
||||
const before = (await annotations()).length
|
||||
const session = await prisma.contributionSession.create({
|
||||
data: {
|
||||
treeId, submittedByLabel: 'Cousine', mode: 'tree_access', status: 'pending', title: 'Avant la 1.5.1',
|
||||
changes: { create: [
|
||||
{ entityType: 'annotation', action: 'create', afterJson: { type: 'drawing', x: 0, y: 0 } },
|
||||
{ entityType: 'annotation', action: 'create', afterJson: { type: 'text', x: 5, y: 5, content: 'Mamie' } },
|
||||
] },
|
||||
},
|
||||
})
|
||||
const reviewed = await review(session.id, 'approved')
|
||||
expect(reviewed.status).toBe(200)
|
||||
expect(reviewed.body.approvedCount).toBe(1)
|
||||
expect(reviewed.body.rejectedCount).toBe(1)
|
||||
expect((await annotations()).length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it("une photo qui n'est pas de cet arbre est refusée dès l'envoi", async () => {
|
||||
const res = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation('/trees/un-autre-arbre/annotation-photos/abc') }])
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('invalid_annotation_photo_path')
|
||||
})
|
||||
|
||||
it("l'état d'une proposition : lisible par qui lit l'arbre, pas par un compte sans lien", async () => {
|
||||
const proposed = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'sticker', x: 0, y: 0, content: '🌳' } }])
|
||||
const url = `/trees/${treeId}/contributions/sessions/${proposed.body.session.id}/status`
|
||||
expect((await call('GET', url, family)).body.status).toBe('pending')
|
||||
const stranger = await account('curieux')
|
||||
expect((await call('GET', url, stranger.token)).status).toBe(403)
|
||||
await review(proposed.body.session.id, 'approved')
|
||||
expect((await call('GET', url, family)).body.status).toBe('approved')
|
||||
})
|
||||
|
||||
it("un envoi jamais proposé depuis 7 jours est effacé au suivant ; celui d'une proposition en attente reste", async () => {
|
||||
const orphan = await upload(family)
|
||||
const kept = await upload(family)
|
||||
await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(kept.body.photoPath) }])
|
||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)
|
||||
await prisma.annotationPhotoUpload.updateMany({ where: { id: { in: [orphan.body.photoId, kept.body.photoId] } }, data: { createdAt: old } })
|
||||
const orphanPath = await fileOf(orphan.body.photoId) as string
|
||||
|
||||
expect((await upload(family)).status).toBe(201)
|
||||
expect(await fileOf(orphan.body.photoId)).toBeUndefined()
|
||||
expect(existsSync(path.join(storage, orphanPath))).toBe(false)
|
||||
expect(await fileOf(kept.body.photoId)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('revue : refusée puis acceptée dans la même relecture, la photo reste sur le disque', async () => {
|
||||
const sent = await upload(family)
|
||||
const filePath = await fileOf(sent.body.photoId) as string
|
||||
const proposed = await propose(family, [
|
||||
{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) },
|
||||
{ entityType: 'annotation', action: 'create', after: { ...photoAnnotation(sent.body.photoPath), x: 99 } },
|
||||
])
|
||||
const session = await prisma.contributionSession.findUnique({ where: { id: proposed.body.session.id }, include: { changes: { orderBy: { createdAt: 'asc' } } } })
|
||||
const [first, second] = session!.changes
|
||||
const res = await call('POST', `/trees/${treeId}/contributions/sessions/${session!.id}/review`, owner.token, {
|
||||
decision: 'approved',
|
||||
changeDecisions: [{ changeId: first.id, decision: 'rejected' }, { changeId: second.id, decision: 'approved' }],
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(existsSync(path.join(storage, filePath))).toBe(true)
|
||||
})
|
||||
|
||||
it("revue : un chemin de photo échappé dans le JSON compte quand même comme cité par une proposition en attente", async () => {
|
||||
const sent = await upload(family)
|
||||
const filePath = await fileOf(sent.body.photoId) as string
|
||||
const plain = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }])
|
||||
const escaped = `{"photoPath":"${sent.body.photoPath.replaceAll('/', '\/')}","width":1,"height":1}`
|
||||
const other = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'photo', x: 1, y: 1, content: escaped } }])
|
||||
expect(other.status).toBe(201)
|
||||
await review(plain.body.session.id, 'rejected')
|
||||
expect(existsSync(path.join(storage, filePath))).toBe(true)
|
||||
await review(other.body.session.id, 'approved')
|
||||
expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(true)
|
||||
expect(existsSync(path.join(storage, filePath))).toBe(true)
|
||||
})
|
||||
|
||||
it('revue : 70 envois simultanés sur un arbre neuf ne dépassent pas le plafond de 50', async () => {
|
||||
const fresh = await call('POST', '/trees', owner.token, { name: 'Arbre neuf', slug: `neuf-${run}`, visitorPassword: 'partage-neuf-1', contributorPassword: 'partage-neuf-1' })
|
||||
const freshId = fresh.body.tree?.id ?? fresh.body.id
|
||||
const access = (await call('POST', `/trees/${freshId}/access/unlock`, undefined, { password: 'partage-neuf-1' })).body.token
|
||||
const results = await Promise.all(Array.from({ length: 70 }, () => call('POST', `/trees/${freshId}/annotation-photos`, access, {
|
||||
fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
|
||||
})))
|
||||
expect(results.filter((r) => r.status === 201).length).toBe(50)
|
||||
expect(await prisma.annotationPhotoUpload.count({ where: { treeId: freshId } })).toBe(50)
|
||||
})
|
||||
|
||||
it('au-delà de 50 photos en attente sur un arbre, la famille est arrêtée ; un gestionnaire non', async () => {
|
||||
const existing = await prisma.annotationPhotoUpload.count({ where: { treeId } })
|
||||
await prisma.annotationPhotoUpload.createMany({
|
||||
data: Array.from({ length: 50 - existing }, (_, index) => ({ id: `rempli-${run}-${index}`, treeId, filePath: `${treeId}/annotation-photos/rempli-${index}.png` })),
|
||||
})
|
||||
expect((await upload(family)).status).toBe(429)
|
||||
expect((await upload(owner.token)).status).toBe(201)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
// se connectent, et une instance neuve accepte quand même son premier compte.
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
@@ -21,7 +27,7 @@ vi.mock('../src/lib/prisma.js', () => ({
|
||||
}))
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean }
|
||||
let env: { ALLOW_REGISTRATION: boolean, SIGNUP_PROMPTS: boolean }
|
||||
|
||||
const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' }
|
||||
|
||||
@@ -44,6 +50,7 @@ afterAll(async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
env.ALLOW_REGISTRATION = false
|
||||
env.SIGNUP_PROMPTS = false
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.user.count.mockResolvedValue(3)
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' })
|
||||
@@ -96,7 +103,7 @@ describe('inscription fermée', () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null })
|
||||
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null, signupPrompts: false })
|
||||
})
|
||||
|
||||
it('instance neuve : le premier compte se crée, sous verrou', async () => {
|
||||
@@ -139,3 +146,22 @@ describe('inscription ouverte (par défaut)', () => {
|
||||
expect(prismaMock.user.count).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Invitations à créer son arbre (SIGNUP_PROMPTS) : éteintes par défaut, jamais annoncées quand l'instance ne crée plus de compte
|
||||
describe('invitations à créer son arbre', () => {
|
||||
it('éteintes par défaut', async () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
|
||||
})
|
||||
|
||||
it('allumées avec SIGNUP_PROMPTS et une inscription ouverte', async () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.SIGNUP_PROMPTS = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(true)
|
||||
})
|
||||
|
||||
it("coupées quand l'inscription est fermée, même allumées", async () => {
|
||||
env.SIGNUP_PROMPTS = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Les sessions fermées se rechargent avant la première requête : sans elles, l'API refuse de démarrer plutôt que
|
||||
// de rouvrir chaque session fermée sans que personne le voie.
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
revokedSession: {
|
||||
upsert: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
describe('sessions fermées au démarrage', () => {
|
||||
let createApp: typeof import('../src/app.js')['createApp']
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
;({ createApp } = await import('../src/app.js'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('recharge les sessions fermées, et un jeton fermé est refusé dès la première requête', async () => {
|
||||
prismaMock.revokedSession.findMany.mockResolvedValue([{ jti: 'ferme-1', expiresAt: new Date(Date.now() + 3600_000) }])
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const { isUserTokenRevoked } = await import('../src/lib/token-revocation.js')
|
||||
expect(isUserTokenRevoked('ferme-1')).toBe(true)
|
||||
expect(prismaMock.revokedSession.deleteMany).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('ne démarre pas si la base ne répond pas', async () => {
|
||||
prismaMock.revokedSession.findMany.mockRejectedValue(new Error('base injoignable'))
|
||||
const app = createApp()
|
||||
await expect(app.ready()).rejects.toThrow('base injoignable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Panneau « Chercher quelqu'un » : seules les sources allumées par l'opérateur répondent, seulement pour
|
||||
// quelqu'un qui lit l'arbre, et la réponse de matchID arrive au format commun du front.
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
|
||||
revokedSession: {
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
user: { count: vi.fn().mockResolvedValue(1) },
|
||||
treeMembership: { findUnique: vi.fn() },
|
||||
userTreeAccess: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.SEARCH_SOURCES = 'deces, inconnue'
|
||||
process.env.MATCHID_TOKEN = ''
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
createApp = (await import('../src/app.js')).createApp
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function userToken(app: FastifyInstance) {
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
const MATCHID_PAYLOAD = {
|
||||
response: {
|
||||
persons: [
|
||||
{
|
||||
id: 'abc123',
|
||||
name: { first: ['Jean', 'Louis'], last: 'Dupont' },
|
||||
sex: 'M',
|
||||
birth: { date: '18980314', location: { city: 'Tulle', departmentCode: '19', country: 'France' } },
|
||||
death: { date: '19871108', location: { city: 'Limoges', departmentCode: '87', country: 'France' } },
|
||||
},
|
||||
{
|
||||
id: 'def456',
|
||||
name: { first: ['Jean'], last: 'Dupont' },
|
||||
sex: 'M',
|
||||
birth: { date: '18980602', location: { city: 'Liège', country: 'Belgique' } },
|
||||
death: { date: '19740121', location: { city: 'Brive-la-Gaillarde', departmentCode: '19', country: 'France' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
describe('search sources', () => {
|
||||
it('lists only the sources the operator enabled', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/search-sources', headers: { authorization: `Bearer ${userToken(app)}` } })
|
||||
expect(response.json()).toEqual({ sources: ['deces'] })
|
||||
const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/search-sources' })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('refuses a visitor without access, and a source that is not enabled', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const anonymous = await app.inject({ method: 'POST', url: '/trees/tree-1/search-sources/deces', payload: { firstName: 'Jean', lastName: 'Dupont' } })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue(null)
|
||||
const stranger = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/search-sources/deces',
|
||||
headers: { authorization: `Bearer ${userToken(app)}` },
|
||||
payload: { firstName: 'Jean', lastName: 'Dupont' },
|
||||
})
|
||||
expect(stranger.statusCode).toBe(403)
|
||||
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
|
||||
const disabled = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/search-sources/wikitree',
|
||||
headers: { authorization: `Bearer ${userToken(app)}` },
|
||||
payload: { firstName: 'Jean', lastName: 'Dupont' },
|
||||
})
|
||||
expect(disabled.statusCode).toBe(404)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends only the name and birth year to matchID and returns the common format', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(MATCHID_PAYLOAD), { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/search-sources/deces',
|
||||
headers: { authorization: `Bearer ${userToken(app)}` },
|
||||
payload: { firstName: 'Jean', lastName: 'Dupont', birthYear: 1898, place: 'Tulle' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
|
||||
const called = new URL(fetchMock.mock.calls[0][0])
|
||||
expect(called.origin + called.pathname).toBe('https://deces.matchid.io/deces/api/v1/search')
|
||||
expect(Object.fromEntries(called.searchParams)).toEqual({ firstName: 'Jean', lastName: 'Dupont', birthDate: '1898', size: '10' })
|
||||
|
||||
const { results } = response.json()
|
||||
expect(results[0]).toEqual({
|
||||
id: 'abc123',
|
||||
firstName: 'Jean Louis',
|
||||
lastName: 'Dupont',
|
||||
sex: 'M',
|
||||
birth: { date: '1898-03-14', place: 'Tulle (19)' },
|
||||
death: { date: '1987-11-08', place: 'Limoges (87)' },
|
||||
url: 'https://deces.matchid.io/id/abc123',
|
||||
match: 'good',
|
||||
})
|
||||
// Né ailleurs que le lieu cherché : proposé, mais à comparer
|
||||
expect(results[1].birth.place).toBe('Liège, Belgique')
|
||||
expect(results[1].match).toBe('partial')
|
||||
})
|
||||
|
||||
it('answers 502 when the service is down', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('busy', { status: 503 })))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/search-sources/deces',
|
||||
headers: { authorization: `Bearer ${userToken(app)}` },
|
||||
payload: { firstName: 'Jean', lastName: 'Dupont' },
|
||||
})
|
||||
expect(response.statusCode).toBe(502)
|
||||
expect(response.json()).toEqual({ error: 'source_unavailable' })
|
||||
})
|
||||
|
||||
it('tells apart an exhausted anonymous quota', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ treeId: 'tree-1', userId: 'user-1', role: 'owner', tree: { deletedAt: null } })
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"message":"temporary anonymous usage expired"}', { status: 422 })))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/search-sources/deces',
|
||||
headers: { authorization: `Bearer ${userToken(app)}` },
|
||||
payload: { firstName: 'Jean', lastName: 'Dupont' },
|
||||
})
|
||||
expect(response.json()).toEqual({ error: 'source_quota' })
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Boîte ftyp minimale : taille, « ftyp », marque principale
|
||||
function ftypBox(brand: string): Buffer {
|
||||
const box = Buffer.alloc(24)
|
||||
box.writeUInt32BE(24, 0)
|
||||
box.write('ftyp', 4, 'latin1')
|
||||
box.write(brand, 8, 'latin1')
|
||||
return box
|
||||
}
|
||||
|
||||
describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => {
|
||||
it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => {
|
||||
expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic')
|
||||
expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull()
|
||||
expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => {
|
||||
expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif')
|
||||
expect(accepted(ftypBox('avif'), 'photo')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => {
|
||||
expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic')
|
||||
expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4')
|
||||
})
|
||||
|
||||
it('les vidéos MP4 et QuickTime ne changent pas', () => {
|
||||
expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime')
|
||||
expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4')
|
||||
})
|
||||
|
||||
it('une image HEIC n’entre dans aucun type de média autorisé', () => {
|
||||
expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false)
|
||||
expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('photos : métadonnées retirées, rotation appliquée', () => {
|
||||
it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => {
|
||||
const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })
|
||||
|
||||
@@ -20,6 +20,7 @@ Chaque push sur `main` lance d'abord la CI (`.github/workflows/ci.yml`). Si elle
|
||||
- `/etc/parenthese/build.env` : variables de build du front et de la landing (`VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST`)
|
||||
- `/etc/cron.d/parenthese-backup` : sauvegarde nocturne par `deploy/backup.sh`
|
||||
- `/etc/genealogy/backup.env` (facultatif) : rétention et envoi S3 des sauvegardes
|
||||
- `/usr/local/sbin/parenthese-ssh-deploy` : copie de `deploy/ssh-deploy.sh`, commande forcée de la clé de déploiement (voir plus bas)
|
||||
|
||||
## Prérequis du serveur
|
||||
|
||||
@@ -28,3 +29,19 @@ Node 20, `pm2` en global, PostgreSQL joignable par `DATABASE_URL`, nginx configu
|
||||
## Déployer à la main
|
||||
|
||||
`bash deploy/deploy.sh --sync` depuis le dépôt sur le serveur. `FORCE_FULL=1` pour tout reconstruire. Ne pas le lancer pendant un déploiement GitHub : le verrou le fera attendre.
|
||||
|
||||
## Clé de déploiement restreinte
|
||||
|
||||
La clé de GitHub ne doit pouvoir que déployer un commit de `main`, jamais lancer une autre commande sur le serveur. On la lie à `deploy/ssh-deploy.sh`, copié hors du dépôt parce que le déploiement réécrit le dépôt :
|
||||
|
||||
```
|
||||
install -m 755 -o root -g root /var/www/parenthese/deploy/ssh-deploy.sh /usr/local/sbin/parenthese-ssh-deploy
|
||||
```
|
||||
|
||||
Puis, dans `authorized_keys`, devant la ligne de la clé `github-actions-parenthese-public` :
|
||||
|
||||
```
|
||||
command="/usr/local/sbin/parenthese-ssh-deploy",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… github-actions-parenthese-public
|
||||
```
|
||||
|
||||
Le workflow n'a pas à changer. Après une mise à jour de `deploy/ssh-deploy.sh`, refaire l'`install`.
|
||||
+5
-1
@@ -133,8 +133,12 @@ predeploy_dump() {
|
||||
BACKEND_CHANGED=0
|
||||
if changed backend ecosystem.config.cjs; then
|
||||
BACKEND_CHANGED=1
|
||||
# Prisma 7 demande Node ^20.19, ^22.12 ou 24+ : avec un Node plus vieux, on s'arrête avant toute installation
|
||||
node -e "const [a, b] = process.versions.node.split('.').map(Number); process.exit((a === 20 && b >= 19) || (a === 22 && b >= 12) || a >= 24 ? 0 : 1)" \
|
||||
|| { echo "ERROR: Node $(node -v) trop ancien pour Prisma 7 (^20.19, ^22.12 ou 24+)"; exit 1; }
|
||||
install_deps backend
|
||||
(cd "$REPO_DIR/backend" && node -e "require('fastify'); require('zod'); require('@prisma/client')") \
|
||||
# Le client Prisma n'existe qu'après prisma:generate (src/generated) : on vérifie ses dépendances, pas le client
|
||||
(cd "$REPO_DIR/backend" && node -e "require('fastify'); require('zod'); require('@prisma/adapter-pg'); require.resolve('@prisma/client/runtime/client')") \
|
||||
|| { echo "ERROR: dépendances backend manquantes après installation"; exit 1; }
|
||||
log "Build backend"
|
||||
(cd "$REPO_DIR/backend" && NODE_ENV='' npm run prisma:generate && NODE_ENV='' npm run build)
|
||||
|
||||
+5
-1
@@ -33,12 +33,16 @@ map $host $parenthese_app_csp {
|
||||
# script-src 'sha256-...' script en ligne du <head> (animation du logo, dans index.html et en/index.html).
|
||||
# S'il change, recalculer l'empreinte : base64(sha256(contenu exact entre
|
||||
# <script> et </script>)), sinon l'animation ne se joue plus (sans autre effet).
|
||||
# 2e 'sha256-...' règles de préchargement des pages (<script type="speculationrules">, le même
|
||||
# texte dans index.html et chaque guide). Même calcul s'il change, sinon les pages
|
||||
# ne sont plus préchargées (sans autre effet). Le mot-clé 'inline-speculation-rules'
|
||||
# ne suffisait pas à Chrome (essayé le 27/10).
|
||||
# posthog comme pour l'app
|
||||
# style-src 'unsafe-inline' attributs style du rendu serveur React
|
||||
# frame-src démonstration de l'app en iframe
|
||||
# frame-ancestors 'none' équivalent de X-Frame-Options DENY
|
||||
map $host $parenthese_landing_csp {
|
||||
default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
|
||||
default "default-src 'self'; script-src 'self' 'sha256-v68KpM0KHUkaezvOmKtoCY2RsfbwoEC9EZYD682DCiU=' 'sha256-eflWcNn8MfPvX2YD4hXJnR3x9nRGu0f7tIEq2j/yfPo='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
|
||||
}
|
||||
|
||||
# Réponses de l'API relayées (JSON, médias des familles) : intégrables seulement par l'app (PDF en iframe).
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Commande forcée de la clé de déploiement GitHub, posée devant elle dans /root/.ssh/authorized_keys :
|
||||
# command="/usr/local/sbin/parenthese-ssh-deploy",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… github-actions-parenthese-public
|
||||
# Avec elle, la clé ne peut plus que déployer un commit qui appartient déjà à main : une clé volée ne lance aucune
|
||||
# autre commande. Le workflow « Deploy Hostinger » n'a pas à changer : il envoie
|
||||
# FORCE_FULL=<0|1> DEPLOY_SHA=<sha> bash -s
|
||||
# suivi d'un script sur l'entrée standard. Seuls FORCE_FULL et DEPLOY_SHA sont lus, le script envoyé est ignoré,
|
||||
# et la suite reprend la même logique (verrou, commit de main, pas de retour en arrière, deploy.sh).
|
||||
# Installé hors du dépôt, parce que le déploiement réécrit le dépôt pendant qu'il tourne :
|
||||
# install -m 755 -o root -g root /var/www/parenthese/deploy/ssh-deploy.sh /usr/local/sbin/parenthese-ssh-deploy
|
||||
set -euo pipefail
|
||||
|
||||
if ! [[ "${SSH_ORIGINAL_COMMAND:-}" =~ ^FORCE_FULL=([01])\ DEPLOY_SHA=([0-9a-f]{40})\ bash\ -s$ ]]; then
|
||||
echo "Commande refusée : cette clé ne sert qu'à déployer" >&2
|
||||
exit 1
|
||||
fi
|
||||
FORCE_FULL="${BASH_REMATCH[1]}"
|
||||
DEPLOY_SHA="${BASH_REMATCH[2]}"
|
||||
# Le script envoyé par le workflow n'est pas exécuté
|
||||
cat > /dev/null
|
||||
|
||||
cd /var/www/parenthese
|
||||
exec flock -w 900 /tmp/parenthese-deploy.lock env PARENTHESE_DEPLOY_LOCKED=1 FORCE_FULL="$FORCE_FULL" DEPLOY_SHA="$DEPLOY_SHA" bash -c '
|
||||
set -euo pipefail
|
||||
git fetch -q origin main
|
||||
# Le commit validé doit appartenir à main
|
||||
if ! git merge-base --is-ancestor "$DEPLOY_SHA" origin/main; then
|
||||
echo "Commit $DEPLOY_SHA absent de main : rien n est déployé" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Un déploiement plus récent est déjà en ligne : pas de retour en arrière
|
||||
if [ "$(git rev-parse HEAD)" != "$DEPLOY_SHA" ] && git merge-base --is-ancestor "$DEPLOY_SHA" HEAD; then
|
||||
echo "Version plus récente déjà déployée, $DEPLOY_SHA ignoré"
|
||||
exit 0
|
||||
fi
|
||||
git reset -q --hard "$DEPLOY_SHA"
|
||||
bash deploy/deploy.sh
|
||||
'
|
||||
@@ -0,0 +1,38 @@
|
||||
# Ajouter des souvenirs depuis Immich
|
||||
|
||||
Les administrateurs d'un arbre disposant d'une connexion Immich peuvent utiliser
|
||||
**Depuis Immich** dans **Ajouter des souvenirs**, ou dans les souvenirs d'un moment
|
||||
de l'onglet **Vie**. Choisir Photos ou Vidéos, parcourir les pages puis sélectionner
|
||||
un média. Dans un moment, plusieurs médias peuvent être sélectionnés avant de
|
||||
terminer la sélection. Enregistrer ensuite le souvenir ou le moment.
|
||||
|
||||
Le sélecteur reprend la présentation de Parenthèse. Cliquer sur une photo ouvre
|
||||
un aperçu agrandi avant de choisir le souvenir. La coche sous chaque vignette
|
||||
permet aussi une sélection rapide. La recherche par contexte utilise la recherche
|
||||
intelligente d'Immich (qui doit être activée et avoir indexé la photothèque).
|
||||
Le filtre par visage utilise les personnes reconnues dans Immich, avec leur
|
||||
portrait et une recherche par nom. Il peut être combiné au contexte. La clé doit
|
||||
aussi disposer de `person.read` pour ce filtre et ses portraits.
|
||||
|
||||
Les fichiers sont copiés dans Parenthèse : ils restent disponibles indépendamment
|
||||
d'Immich après l'enregistrement. Les limites existantes s'appliquent : 5 Mo par
|
||||
photo, 20 Mo par vidéo et quota de l'arbre. Les formats pris en charge sont ceux
|
||||
des envois locaux : JPEG, PNG, WebP, GIF ; MP4, MOV, WebM. Pour les photos,
|
||||
Parenthèse copie la version preview préparée par Immich, ce qui permet aussi
|
||||
d'importer les HEIC/RAW dont Immich a généré l'aperçu. Le nom du fichier suit
|
||||
le format reçu. Les vidéos conservent leur original, sans transcodage ajouté.
|
||||
|
||||
La clé Immich doit disposer de `asset.read`, `asset.view` et `asset.download`, en
|
||||
plus des permissions utilisées pour les portraits. Contrat :
|
||||
[spécification officielle Immich](https://github.com/immich-app/immich/blob/main/open-api/immich-openapi-specs.json).
|
||||
|
||||
La photothèque est accessible uniquement aux administrateurs de l'arbre. La clé
|
||||
et l'adresse Immich restent côté serveur ; la connexion utilise la liste
|
||||
d'instances autorisées existante. Les téléchargements sont bornés en taille et
|
||||
en durée, les redirections sont refusées et le type est vérifié dans les octets.
|
||||
La persistance utilise l'API d'envoi existante : traitement des images, quota,
|
||||
audit et association à la personne ou au moment. Aucune migration de base requise.
|
||||
|
||||
Cette fonction ne synchronise pas automatiquement les changements Immich et ne
|
||||
déduplique pas les imports entre deux sessions. Une validation avec une instance
|
||||
Immich réelle reste nécessaire avant déploiement.
|
||||
@@ -12,6 +12,9 @@ module.exports = {
|
||||
PORT: 4000,
|
||||
// nginx relaie vers 127.0.0.1:4000 (deploy/nginx.conf) : l'API n'écoute pas sur les interfaces publiques
|
||||
HOST: '127.0.0.1',
|
||||
// Instance hébergée app.parenthese.io : invite la famille et la démo à créer leur arbre (SIGNUP_PROMPTS,
|
||||
// éteint par défaut en auto-hébergement)
|
||||
SIGNUP_PROMPTS: 'true',
|
||||
},
|
||||
max_memory_restart: '512M',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
<meta name="description" content="Parenthèse, l'application. Les arbres partagés sont privés : la vitrine publique est sur parenthese.io." />
|
||||
<!-- Arbres familiaux privés (noms, dates) : l'app ne doit pas être indexée, la vitrine est parenthese.io -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<!-- Aperçu d'un lien collé dans WhatsApp, Messenger, iMessage… : le même pour toutes les adresses de l'app.
|
||||
Rien ne vient de l'arbre partagé (ni nom de famille, ni photo) : les robots d'aperçu ne reçoivent que ce texte et l'image de la démo. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Parenthèse" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:title" content="Un arbre de famille à parcourir" />
|
||||
<meta property="og:description" content="Photos, voix et souvenirs de la famille, réunis sur un arbre. S'ouvre depuis ce lien, sans compte." />
|
||||
<meta property="og:image" content="https://app.parenthese.io/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Un arbre de famille avec des portraits et des souvenirs autour de chaque personne" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+817
-959
File diff suppressed because it is too large.
Load diff
+13
-13
@@ -12,28 +12,28 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.56.0",
|
||||
"lucide-react": "^1.56.0",
|
||||
"posthog-js": "^1.438.9",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0"
|
||||
"@lucide/icons": "^1.66.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"lucide-react": "^1.66.0",
|
||||
"posthog-js": "^1.444.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"postcss": "^8.5.30",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
"globals": "^17.14.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss-selector-parser": "^7.1.6"
|
||||
"minimatch": "^11.0.4"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user