Repository: LemmyNet/lemmy
Pull request #5664: Change `local_image` to reference `person_id`, to track thumbnail creators.
Author: login: dessalines
Created: 2025-05-06T16:03:32Z | Merged: 2025-05-19T19:30:02Z

Fixes #5564 

Linked issue #5564: Track post creator as uploader for thumbnail generation
### Requirements

- [x] Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
- [x] Did you check to see if this issue already exists?
- [x] Is this only a feature request? Do not put multiple feature requests in one issue.
- [x] Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
- [x] Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?

### Is your proposal related to a problem?

Automatically generated thumbnails are currently not associated with the user triggering their creation.
This prevents safely deleting thumbnails linked to a user's posts.

### Describe the solution you'd like.

Similar to how local user media uploads are already tracked it would be good to store thumbnail URLs in a similar way. Unlike regular user uploads, thumbnail creation can also be triggered by remote users, so this will require matching `person.id` rather than `local_user.id`.

### Describe alternatives you've considered.

Expensive SQL queries to determine whether other references to the same image exist.

### Additional context

Thumbnails are already tracked in the `local_image` table, but there is no user associated with them.
Linked issue comments: user: login: Nothing4You
created_at: 2025-03-29T04:29:26Z
body: Optionally, this could also track post ids for thumbnail generation, which allows for removal of locally generated nsfw thumbnails for posts that have been removed for being marked nsfw on nsfw-disabled instances.
author_association: COLLABORATOR
user: login: dessalines
created_at: 2025-04-01T01:30:49Z
body: This is probably the best way to handle it.
author_association: MEMBER

Diff:
diff --git a/api_tests/package.json b/api_tests/package.json
index 647e35f9be..6729c596d8 100644
--- a/api_tests/package.json
+++ b/api_tests/package.json
@@ -22,19 +22,20 @@
     "api-test-tags": "jest -i tags.spec.ts"
   },
   "devDependencies": {
-    "@eslint/js": "^9.25.1",
+    "@eslint/js": "^9.26.0",
     "@types/jest": "^29.5.12",
-    "@types/node": "^22.15.3",
-    "@typescript-eslint/eslint-plugin": "^8.31.0",
-    "@typescript-eslint/parser": "^8.31.0",
-    "eslint": "^9.25.1",
-    "eslint-plugin-prettier": "^5.2.6",
+    "@types/node": "^22.15.14",
+    "@typescript-eslint/eslint-plugin": "^8.32.0",
+    "@typescript-eslint/parser": "^8.32.0",
+    "eslint": "^9.26.0",
+    "eslint-plugin-prettier": "^5.4.0",
     "jest": "^29.5.0",
-    "lemmy-js-client": "1.0.0-remove-page-limit.1",
+    "joi": "^17.13.3",
+    "lemmy-js-client": "1.0.0-local-image-user.3",
     "prettier": "^3.5.3",
     "ts-jest": "^29.3.2",
     "tsoa": "^6.6.0",
     "typescript": "^5.8.3",
-    "typescript-eslint": "^8.31.0"
+    "typescript-eslint": "^8.32.0"
   }
 }
diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml
index c1da07afc9..5bd24753b9 100644
--- a/api_tests/pnpm-lock.yaml
+++ b/api_tests/pnpm-lock.yaml
@@ -9,38 +9,41 @@ importers:
   .:
     devDependencies:
       '@eslint/js':
-        specifier: ^9.25.1
-        version: 9.25.1
+        specifier: ^9.26.0
+        version: 9.26.0
       '@types/jest':
         specifier: ^29.5.12
         version: 29.5.14
       '@types/node':
-        specifier: ^22.15.3
-        version: 22.15.3
+        specifier: ^22.15.14
+        version: 22.15.14
       '@typescript-eslint/eslint-plugin':
-        specifier: ^8.31.0
-        version: 8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)(typescript@5.8.3)
+        specifier: ^8.32.0
+        version: 8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3)
       '@typescript-eslint/parser':
-        specifier: ^8.31.0
-        version: 8.31.1(eslint@9.25.1)(typescript@5.8.3)
+        specifier: ^8.32.0
+        version: 8.32.0(eslint@9.26.0)(typescript@5.8.3)
       eslint:
-        specifier: ^9.25.1
-        version: 9.25.1
+        specifier: ^9.26.0
+        version: 9.26.0
       eslint-plugin-prettier:
-        specifier: ^5.2.6
-        version: 5.2.6(eslint@9.25.1)(prettier@3.5.3)
+        specifier: ^5.4.0
+        version: 5.4.0(eslint@9.26.0)(prettier@3.5.3)
       jest:
         specifier: ^29.5.0
-        version: 29.7.0(@types/node@22.15.3)
+        version: 29.7.0(@types/node@22.15.14)
+      joi:
+        specifier: ^17.13.3
+        version: 17.13.3
       lemmy-js-client:
-        specifier: 1.0.0-remove-page-limit.1
-        version: 1.0.0-remove-page-limit.1
+        specifier: 1.0.0-local-image-user.3
+        version: 1.0.0-local-image-user.3
       prettier:
         specifier: ^3.5.3
         version: 3.5.3
       ts-jest:
         specifier: ^29.3.2
-        version: 29.3.2(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.15.3))(typescript@5.8.3)
+        version: 29.3.2(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.15.14))(typescript@5.8.3)
       tsoa:
         specifier: ^6.6.0
         version: 6.6.0
@@ -48,8 +51,8 @@ importers:
         specifier: ^5.8.3
         version: 5.8.3
       typescript-eslint:
-        specifier: ^8.31.0
-        version: 8.31.1(eslint@9.25.1)(typescript@5.8.3)
+        specifier: ^8.32.0
+        version: 8.32.0(eslint@9.26.0)(typescript@5.8.3)
 
 packages:
 
@@ -230,6 +233,12 @@ packages:
     peerDependencies:
       eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
 
+  '@eslint-community/eslint-utils@4.7.0':
+    resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==}
+    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+    peerDependencies:
+      eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
   '@eslint-community/regexpp@4.12.1':
     resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
     engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -250,8 +259,8 @@ packages:
     resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
-  '@eslint/js@9.25.1':
-    resolution: {integrity: sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==}
+  '@eslint/js@9.26.0':
+    resolution: {integrity: sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
   '@eslint/object-schema@2.1.6':
@@ -309,6 +318,9 @@ packages:
   '@hapi/hoek@11.0.7':
     resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==}
 
+  '@hapi/hoek@9.3.0':
+    resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
+
   '@hapi/iron@7.0.1':
     resolution: {integrity: sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==}
 
@@ -341,6 +353,9 @@ packages:
     resolution: {integrity: sha512-05HumSy3LWfXpmJ9cr6HzwhAavrHkJ1ZRCmNE2qJMihdM5YcWreWPfyN0yKT2ZjCM92au3ZkuodjBxOibxM67A==}
     engines: {node: '>=14.0.0'}
 
+  '@hapi/topo@5.1.0':
+    resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
+
   '@hapi/topo@6.0.2':
     resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==}
 
@@ -469,6 +484,10 @@ packages:
   '@jridgewell/trace-mapping@0.3.22':
     resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==}
 
+  '@modelcontextprotocol/sdk@1.11.0':
+    resolution: {integrity: sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==}
+    engines: {node: '>=18'}
+
   '@nodelib/fs.scandir@2.1.5':
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -489,6 +508,15 @@ packages:
     resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
     engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
 
+  '@sideway/address@4.1.5':
+    resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
+
+  '@sideway/formula@3.0.1':
+    resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==}
+
+  '@sideway/pinpoint@2.0.0':
+    resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
+
   '@sinclair/typebox@0.27.8':
     resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
 
@@ -582,8 +610,8 @@ packages:
   '@types/multer@1.4.12':
     resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==}
 
-  '@types/node@22.15.3':
-    resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==}
+  '@types/node@22.15.14':
+    resolution: {integrity: sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==}
 
   '@types/qs@6.9.18':
     resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==}
@@ -606,57 +634,61 @@ packages:
   '@types/yargs@17.0.32':
     resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==}
 
-  '@typescript-eslint/eslint-plugin@8.31.1':
-    resolution: {integrity: sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==}
+  '@typescript-eslint/eslint-plugin@8.32.0':
+    resolution: {integrity: sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
       eslint: ^8.57.0 || ^9.0.0
       typescript: '>=4.8.4 <5.9.0'
 
-  '@typescript-eslint/parser@8.31.1':
-    resolution: {integrity: sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==}
+  '@typescript-eslint/parser@8.32.0':
+    resolution: {integrity: sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       eslint: ^8.57.0 || ^9.0.0
       typescript: '>=4.8.4 <5.9.0'
 
-  '@typescript-eslint/scope-manager@8.31.1':
-    resolution: {integrity: sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==}
+  '@typescript-eslint/scope-manager@8.32.0':
+    resolution: {integrity: sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
-  '@typescript-eslint/type-utils@8.31.1':
-    resolution: {integrity: sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==}
+  '@typescript-eslint/type-utils@8.32.0':
+    resolution: {integrity: sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       eslint: ^8.57.0 || ^9.0.0
       typescript: '>=4.8.4 <5.9.0'
 
-  '@typescript-eslint/types@8.31.1':
-    resolution: {integrity: sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==}
+  '@typescript-eslint/types@8.32.0':
+    resolution: {integrity: sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
-  '@typescript-eslint/typescript-estree@8.31.1':
-    resolution: {integrity: sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==}
+  '@typescript-eslint/typescript-estree@8.32.0':
+    resolution: {integrity: sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       typescript: '>=4.8.4 <5.9.0'
 
-  '@typescript-eslint/utils@8.31.1':
-    resolution: {integrity: sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==}
+  '@typescript-eslint/utils@8.32.0':
+    resolution: {integrity: sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       eslint: ^8.57.0 || ^9.0.0
       typescript: '>=4.8.4 <5.9.0'
 
-  '@typescript-eslint/visitor-keys@8.31.1':
-    resolution: {integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==}
+  '@typescript-eslint/visitor-keys@8.32.0':
+    resolution: {integrity: sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
   accepts@1.3.8:
     resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
     engines: {node: '>= 0.6'}
 
+  accepts@2.0.0:
+    resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+    engines: {node: '>= 0.6'}
+
   acorn-jsx@5.3.2:
     resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
     peerDependencies:
@@ -742,6 +774,10 @@ packages:
     resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==}
     engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
 
+  body-parser@2.2.0:
+    resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
+    engines: {node: '>=18'}
+
   brace-expansion@1.1.11:
     resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
 
@@ -838,6 +874,10 @@ packages:
     resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
     engines: {node: '>= 0.6'}
 
+  content-disposition@1.0.0:
+    resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
+    engines: {node: '>= 0.6'}
+
   content-type@1.0.5:
     resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
     engines: {node: '>= 0.6'}
@@ -848,10 +888,22 @@ packages:
   cookie-signature@1.0.6:
     resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
 
+  cookie-signature@1.2.2:
+    resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+    engines: {node: '>=6.6.0'}
+
   cookie@0.7.1:
     resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
     engines: {node: '>= 0.6'}
 
+  cookie@0.7.2:
+    resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+    engines: {node: '>= 0.6'}
+
+  cors@2.8.5:
+    resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+    engines: {node: '>= 0.10'}
+
   create-jest@29.7.0:
     resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -979,8 +1031,8 @@ packages:
     resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
 
-  eslint-plugin-prettier@5.2.6:
-    resolution: {integrity: sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==}
+  eslint-plugin-prettier@5.4.0:
+    resolution: {integrity: sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==}
     engines: {node: ^14.18.0 || >=16.0.0}
     peerDependencies:
       '@types/eslint': '>=8.0.0'
@@ -1005,8 +1057,8 @@ packages:
     resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
-  eslint@9.25.1:
-    resolution: {integrity: sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==}
+  eslint@9.26.0:
+    resolution: {integrity: sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     hasBin: true
     peerDependencies:
@@ -1044,6 +1096,14 @@ packages:
     resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
     engines: {node: '>= 0.6'}
 
+  eventsource-parser@3.0.1:
+    resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==}
+    engines: {node: '>=18.0.0'}
+
+  eventsource@3.0.6:
+    resolution: {integrity: sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==}
+    engines: {node: '>=18.0.0'}
+
   execa@5.1.1:
     resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
     engines: {node: '>=10'}
@@ -1056,10 +1116,20 @@ packages:
     resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
     engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
 
+  express-rate-limit@7.5.0:
+    resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==}
+    engines: {node: '>= 16'}
+    peerDependencies:
+      express: ^4.11 || 5 || ^5.0.0-beta.1
+
   express@4.21.2:
     resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
     engines: {node: '>= 0.10.0'}
 
+  express@5.1.0:
+    resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
+    engines: {node: '>= 18'}
+
   fast-deep-equal@3.1.3:
     resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
 
@@ -1101,6 +1171,10 @@ packages:
     resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
     engines: {node: '>= 0.8'}
 
+  finalhandler@2.1.0:
+    resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
+    engines: {node: '>= 0.8'}
+
   find-up@4.1.0:
     resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
     engines: {node: '>=8'}
@@ -1128,6 +1202,10 @@ packages:
     resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
     engines: {node: '>= 0.6'}
 
+  fresh@2.0.0:
+    resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+    engines: {node: '>= 0.8'}
+
   fs-extra@11.3.0:
     resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
     engines: {node: '>=14.14'}
@@ -1237,6 +1315,10 @@ packages:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
 
+  iconv-lite@0.6.3:
+    resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+    engines: {node: '>=0.10.0'}
+
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
@@ -1291,6 +1373,9 @@ packages:
     resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
     engines: {node: '>=0.12.0'}
 
+  is-promise@4.0.0:
+    resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
   is-stream@2.0.1:
     resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
     engines: {node: '>=8'}
@@ -1463,6 +1548,9 @@ packages:
       node-notifier:
         optional: true
 
+  joi@17.13.3:
+    resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
+
   js-tokens@4.0.0:
     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
 
@@ -1506,8 +1594,8 @@ packages:
     resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
     engines: {node: '>=6'}
 
-  lemmy-js-client@1.0.0-remove-page-limit.1:
-    resolution: {integrity: sha512-709IESOyQeQ17KfucqO6cf7c3IobEbgwKUdtvcpHhGXKpG3kHYNsMUZR5e7CgbXgSNJ+2OwY8GyhUnNW+TbkZg==}
+  lemmy-js-client@1.0.0-local-image-user.3:
+    resolution: {integrity: sha512-h7bW7lZ+8EQzmgk4fSUYo/+3doSx/dlC9rxM4omLKayS/Hwp/pe2TD785RS8ZJUiOtFfX1Ui0P8991N9a50beQ==}
 
   leven@3.1.0:
     resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@@ -1558,6 +1646,10 @@ packages:
     resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
     engines: {node: '>= 0.6'}
 
+  media-typer@1.1.0:
+    resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+    engines: {node: '>= 0.8'}
+
   merge-anything@5.1.7:
     resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
     engines: {node: '>=12.13'}
@@ -1565,6 +1657,10 @@ packages:
   merge-descriptors@1.0.3:
     resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
 
+  merge-descriptors@2.0.0:
+    resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+    engines: {node: '>=18'}
+
   merge-stream@2.0.0:
     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
 
@@ -1592,10 +1688,18 @@ packages:
     resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==}
     engines: {node: '>= 0.6'}
 
+  mime-db@1.54.0:
+    resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+    engines: {node: '>= 0.6'}
+
   mime-types@2.1.35:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
 
+  mime-types@3.0.1:
+    resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
+    engines: {node: '>= 0.6'}
+
   mime@1.6.0:
     resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
     engines: {node: '>=4'}
@@ -1636,6 +1740,10 @@ packages:
     resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
     engines: {node: '>= 0.6'}
 
+  negotiator@1.0.0:
+    resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+    engines: {node: '>= 0.6'}
+
   neo-async@2.6.2:
     resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
 
@@ -1653,6 +1761,10 @@ packages:
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     engines: {node: '>=8'}
 
+  object-assign@4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+
   object-inspect@1.13.4:
     resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
     engines: {node: '>= 0.4'}
@@ -1729,6 +1841,10 @@ packages:
   path-to-regexp@0.1.12:
     resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
 
+  path-to-regexp@8.2.0:
+    resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==}
+    engines: {node: '>=16'}
+
   picocolors@1.1.1:
     resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
 
@@ -1740,6 +1856,10 @@ packages:
     resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
     engines: {node: '>= 6'}
 
+  pkce-challenge@5.0.0:
+    resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
+    engines: {node: '>=16.20.0'}
+
   pkg-dir@4.2.0:
     resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
     engines: {node: '>=8'}
@@ -1780,6 +1900,10 @@ packages:
     resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
     engines: {node: '>=0.6'}
 
+  qs@6.14.0:
+    resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
+    engines: {node: '>=0.6'}
+
   queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
@@ -1791,6 +1915,10 @@ packages:
     resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
     engines: {node: '>= 0.8'}
 
+  raw-body@3.0.0:
+    resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
+    engines: {node: '>= 0.8'}
+
   react-is@18.3.1:
     resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
 
@@ -1825,6 +1953,10 @@ packages:
     resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
 
+  router@2.2.0:
+    resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+    engines: {node: '>= 18'}
+
   run-parallel@1.2.0:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
 
@@ -1852,10 +1984,18 @@ packages:
     resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
     engines: {node: '>= 0.8.0'}
 
+  send@1.2.0:
+    resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
+    engines: {node: '>= 18'}
+
   serve-static@1.16.2:
     resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
     engines: {node: '>= 0.8.0'}
 
+  serve-static@2.2.0:
+    resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
+    engines: {node: '>= 18'}
+
   setprototypeof@1.2.0:
     resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
 
@@ -2044,8 +2184,12 @@ packages:
     resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
     engines: {node: '>= 0.6'}
 
-  typescript-eslint@8.31.1:
-    resolution: {integrity: sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA==}
+  type-is@2.0.1:
+    resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
+    engines: {node: '>= 0.6'}
+
+  typescript-eslint@8.32.0:
+    resolution: {integrity: sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
     peerDependencies:
       eslint: ^8.57.0 || ^9.0.0
@@ -2151,6 +2295,14 @@ packages:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
 
+  zod-to-json-schema@3.24.5:
+    resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==}
+    peerDependencies:
+      zod: ^3.24.1
+
+  zod@3.24.4:
+    resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
+
 snapshots:
 
   '@ampproject/remapping@2.2.1':
@@ -2354,9 +2506,14 @@ snapshots:
 
   '@bcoe/v8-coverage@0.2.3': {}
 
-  '@eslint-community/eslint-utils@4.6.1(eslint@9.25.1)':
+  '@eslint-community/eslint-utils@4.6.1(eslint@9.26.0)':
+    dependencies:
+      eslint: 9.26.0
+      eslint-visitor-keys: 3.4.3
+
+  '@eslint-community/eslint-utils@4.7.0(eslint@9.26.0)':
     dependencies:
-      eslint: 9.25.1
+      eslint: 9.26.0
       eslint-visitor-keys: 3.4.3
 
   '@eslint-community/regexpp@4.12.1': {}
@@ -2389,7 +2546,7 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@eslint/js@9.25.1': {}
+  '@eslint/js@9.26.0': {}
 
   '@eslint/object-schema@2.1.6': {}
 
@@ -2478,6 +2635,8 @@ snapshots:
 
   '@hapi/hoek@11.0.7': {}
 
+  '@hapi/hoek@9.3.0': {}
+
   '@hapi/iron@7.0.1':
     dependencies:
       '@hapi/b64': 6.0.1
@@ -2542,6 +2701,10 @@ snapshots:
 
   '@hapi/teamwork@6.0.0': {}
 
+  '@hapi/topo@5.1.0':
+    dependencies:
+      '@hapi/hoek': 9.3.0
+
   '@hapi/topo@6.0.2':
     dependencies:
       '@hapi/hoek': 11.0.7
@@ -2596,7 +2759,7 @@ snapshots:
   '@jest/console@29.7.0':
     dependencies:
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       jest-message-util: 29.7.0
       jest-util: 29.7.0
@@ -2609,14 +2772,14 @@ snapshots:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       ansi-escapes: 4.3.2
       chalk: 4.1.2
       ci-info: 3.9.0
       exit: 0.1.2
       graceful-fs: 4.2.11
       jest-changed-files: 29.7.0
-      jest-config: 29.7.0(@types/node@22.15.3)
+      jest-config: 29.7.0(@types/node@22.15.14)
       jest-haste-map: 29.7.0
       jest-message-util: 29.7.0
       jest-regex-util: 29.6.3
@@ -2641,7 +2804,7 @@ snapshots:
     dependencies:
       '@jest/fake-timers': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       jest-mock: 29.7.0
 
   '@jest/expect-utils@29.7.0':
@@ -2659,7 +2822,7 @@ snapshots:
     dependencies:
       '@jest/types': 29.6.3
       '@sinonjs/fake-timers': 10.3.0
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       jest-message-util: 29.7.0
       jest-mock: 29.7.0
       jest-util: 29.7.0
@@ -2681,7 +2844,7 @@ snapshots:
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
       '@jridgewell/trace-mapping': 0.3.22
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       collect-v8-coverage: 1.0.2
       exit: 0.1.2
@@ -2751,7 +2914,7 @@ snapshots:
       '@jest/schemas': 29.6.3
       '@types/istanbul-lib-coverage': 2.0.6
       '@types/istanbul-reports': 3.0.4
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       '@types/yargs': 17.0.32
       chalk: 4.1.2
 
@@ -2772,6 +2935,21 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.1
       '@jridgewell/sourcemap-codec': 1.4.15
 
+  '@modelcontextprotocol/sdk@1.11.0':
+    dependencies:
+      content-type: 1.0.5
+      cors: 2.8.5
+      cross-spawn: 7.0.6
+      eventsource: 3.0.6
+      express: 5.1.0
+      express-rate-limit: 7.5.0(express@5.1.0)
+      pkce-challenge: 5.0.0
+      raw-body: 3.0.0
+      zod: 3.24.4
+      zod-to-json-schema: 3.24.5(zod@3.24.4)
+    transitivePeerDependencies:
+      - supports-color
+
   '@nodelib/fs.scandir@2.1.5':
     dependencies:
       '@nodelib/fs.stat': 2.0.5
@@ -2789,6 +2967,14 @@ snapshots:
 
   '@pkgr/core@0.2.4': {}
 
+  '@sideway/address@4.1.5':
+    dependencies:
+      '@hapi/hoek': 9.3.0
+
+  '@sideway/formula@3.0.1': {}
+
+  '@sideway/pinpoint@2.0.0': {}
+
   '@sinclair/typebox@0.27.8': {}
 
   '@sinonjs/commons@3.0.1':
@@ -2830,7 +3016,7 @@ snapshots:
 
   '@types/accepts@1.3.7':
     dependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/babel__core@7.20.5':
     dependencies:
@@ -2856,11 +3042,11 @@ snapshots:
   '@types/body-parser@1.19.5':
     dependencies:
       '@types/connect': 3.4.38
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/connect@3.4.38':
     dependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/content-disposition@0.5.8': {}
 
@@ -2869,13 +3055,13 @@ snapshots:
       '@types/connect': 3.4.38
       '@types/express': 5.0.0
       '@types/keygrip': 1.0.6
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/estree@1.0.7': {}
 
   '@types/express-serve-static-core@5.0.6':
     dependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       '@types/qs': 6.9.18
       '@types/range-parser': 1.2.7
       '@types/send': 0.17.4
@@ -2889,7 +3075,7 @@ snapshots:
 
   '@types/graceful-fs@4.1.9':
     dependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/http-assert@1.5.6': {}
 
@@ -2927,7 +3113,7 @@ snapshots:
       '@types/http-errors': 2.0.4
       '@types/keygrip': 1.0.6
       '@types/koa-compose': 3.2.8
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/mime@1.3.5': {}
 
@@ -2935,7 +3121,7 @@ snapshots:
     dependencies:
       '@types/express': 5.0.0
 
-  '@types/node@22.15.3':
+  '@types/node@22.15.14':
     dependencies:
       undici-types: 6.21.0
 
@@ -2946,12 +3132,12 @@ snapshots:
   '@types/send@0.17.4':
     dependencies:
       '@types/mime': 1.3.5
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
 
   '@types/serve-static@1.15.7':
     dependencies:
       '@types/http-errors': 2.0.4
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       '@types/send': 0.17.4
 
   '@types/stack-utils@2.0.3': {}
@@ -2962,15 +3148,15 @@ snapshots:
     dependencies:
       '@types/yargs-parser': 21.0.3
 
-  '@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)(typescript@5.8.3)':
+  '@typescript-eslint/eslint-plugin@8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3)':
     dependencies:
       '@eslint-community/regexpp': 4.12.1
-      '@typescript-eslint/parser': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
-      '@typescript-eslint/scope-manager': 8.31.1
-      '@typescript-eslint/type-utils': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
-      '@typescript-eslint/utils': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
-      '@typescript-eslint/visitor-keys': 8.31.1
-      eslint: 9.25.1
+      '@typescript-eslint/parser': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
+      '@typescript-eslint/scope-manager': 8.32.0
+      '@typescript-eslint/type-utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
+      '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
+      '@typescript-eslint/visitor-keys': 8.32.0
+      eslint: 9.26.0
       graphemer: 1.4.0
       ignore: 5.3.2
       natural-compare: 1.4.0
@@ -2979,40 +3165,40 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/parser@8.31.1(eslint@9.25.1)(typescript@5.8.3)':
+  '@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3)':
     dependencies:
-      '@typescript-eslint/scope-manager': 8.31.1
-      '@typescript-eslint/types': 8.31.1
-      '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3)
-      '@typescript-eslint/visitor-keys': 8.31.1
+      '@typescript-eslint/scope-manager': 8.32.0
+      '@typescript-eslint/types': 8.32.0
+      '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3)
+      '@typescript-eslint/visitor-keys': 8.32.0
       debug: 4.4.0
-      eslint: 9.25.1
+      eslint: 9.26.0
       typescript: 5.8.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/scope-manager@8.31.1':
+  '@typescript-eslint/scope-manager@8.32.0':
     dependencies:
-      '@typescript-eslint/types': 8.31.1
-      '@typescript-eslint/visitor-keys': 8.31.1
+      '@typescript-eslint/types': 8.32.0
+      '@typescript-eslint/visitor-keys': 8.32.0
 
-  '@typescript-eslint/type-utils@8.31.1(eslint@9.25.1)(typescript@5.8.3)':
+  '@typescript-eslint/type-utils@8.32.0(eslint@9.26.0)(typescript@5.8.3)':
     dependencies:
-      '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3)
-      '@typescript-eslint/utils': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
+      '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3)
+      '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
       debug: 4.4.0
-      eslint: 9.25.1
+      eslint: 9.26.0
       ts-api-utils: 2.1.0(typescript@5.8.3)
       typescript: 5.8.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/types@8.31.1': {}
+  '@typescript-eslint/types@8.32.0': {}
 
-  '@typescript-eslint/typescript-estree@8.31.1(typescript@5.8.3)':
+  '@typescript-eslint/typescript-estree@8.32.0(typescript@5.8.3)':
     dependencies:
-      '@typescript-eslint/types': 8.31.1
-      '@typescript-eslint/visitor-keys': 8.31.1
+      '@typescript-eslint/types': 8.32.0
+      '@typescript-eslint/visitor-keys': 8.32.0
       debug: 4.4.0
       fast-glob: 3.3.3
       is-glob: 4.0.3
@@ -3023,20 +3209,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/utils@8.31.1(eslint@9.25.1)(typescript@5.8.3)':
+  '@typescript-eslint/utils@8.32.0(eslint@9.26.0)(typescript@5.8.3)':
     dependencies:
-      '@eslint-community/eslint-utils': 4.6.1(eslint@9.25.1)
-      '@typescript-eslint/scope-manager': 8.31.1
-      '@typescript-eslint/types': 8.31.1
-      '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3)
-      eslint: 9.25.1
+      '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0)
+      '@typescript-eslint/scope-manager': 8.32.0
+      '@typescript-eslint/types': 8.32.0
+      '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3)
+      eslint: 9.26.0
       typescript: 5.8.3
     transitivePeerDependencies:
       - supports-color
 
-  '@typescript-eslint/visitor-keys@8.31.1':
+  '@typescript-eslint/visitor-keys@8.32.0':
     dependencies:
-      '@typescript-eslint/types': 8.31.1
+      '@typescript-eslint/types': 8.32.0
       eslint-visitor-keys: 4.2.0
 
   accepts@1.3.8:
@@ -3044,6 +3230,11 @@ snapshots:
       mime-types: 2.1.35
       negotiator: 0.6.3
 
+  accepts@2.0.0:
+    dependencies:
+      mime-types: 3.0.1
+      negotiator: 1.0.0
+
   acorn-jsx@5.3.2(acorn@8.14.1):
     dependencies:
       acorn: 8.14.1
@@ -3159,6 +3350,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  body-parser@2.2.0:
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.5
+      debug: 4.4.0
+      http-errors: 2.0.0
+      iconv-lite: 0.6.3
+      on-finished: 2.4.1
+      qs: 6.14.0
+      raw-body: 3.0.0
+      type-is: 2.0.1
+    transitivePeerDependencies:
+      - supports-color
+
   brace-expansion@1.1.11:
     dependencies:
       balanced-match: 1.0.2
@@ -3246,21 +3451,34 @@ snapshots:
     dependencies:
       safe-buffer: 5.2.1
 
+  content-disposition@1.0.0:
+    dependencies:
+      safe-buffer: 5.2.1
+
   content-type@1.0.5: {}
 
   convert-source-map@2.0.0: {}
 
   cookie-signature@1.0.6: {}
 
+  cookie-signature@1.2.2: {}
+
   cookie@0.7.1: {}
 
-  create-jest@29.7.0(@types/node@22.15.3):
+  cookie@0.7.2: {}
+
+  cors@2.8.5:
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+
+  create-jest@29.7.0(@types/node@22.15.14):
     dependencies:
       '@jest/types': 29.6.3
       chalk: 4.1.2
       exit: 0.1.2
       graceful-fs: 4.2.11
-      jest-config: 29.7.0(@types/node@22.15.3)
+      jest-config: 29.7.0(@types/node@22.15.14)
       jest-util: 29.7.0
       prompts: 2.4.2
     transitivePeerDependencies:
@@ -3349,9 +3567,9 @@ snapshots:
 
   escape-string-regexp@4.0.0: {}
 
-  eslint-plugin-prettier@5.2.6(eslint@9.25.1)(prettier@3.5.3):
+  eslint-plugin-prettier@5.4.0(eslint@9.26.0)(prettier@3.5.3):
     dependencies:
-      eslint: 9.25.1
+      eslint: 9.26.0
       prettier: 3.5.3
       prettier-linter-helpers: 1.0.0
       synckit: 0.11.4
@@ -3365,19 +3583,20 @@ snapshots:
 
   eslint-visitor-keys@4.2.0: {}
 
-  eslint@9.25.1:
+  eslint@9.26.0:
     dependencies:
-      '@eslint-community/eslint-utils': 4.6.1(eslint@9.25.1)
+      '@eslint-community/eslint-utils': 4.6.1(eslint@9.26.0)
       '@eslint-community/regexpp': 4.12.1
       '@eslint/config-array': 0.20.0
       '@eslint/config-helpers': 0.2.1
       '@eslint/core': 0.13.0
       '@eslint/eslintrc': 3.3.1
-      '@eslint/js': 9.25.1
+      '@eslint/js': 9.26.0
       '@eslint/plugin-kit': 0.2.8
       '@humanfs/node': 0.16.6
       '@humanwhocodes/module-importer': 1.0.1
       '@humanwhocodes/retry': 0.4.2
+      '@modelcontextprotocol/sdk': 1.11.0
       '@types/estree': 1.0.7
       '@types/json-schema': 7.0.15
       ajv: 6.12.6
@@ -3402,6 +3621,7 @@ snapshots:
       minimatch: 3.1.2
       natural-compare: 1.4.0
       optionator: 0.9.4
+      zod: 3.24.4
     transitivePeerDependencies:
       - supports-color
 
@@ -3427,6 +3647,12 @@ snapshots:
 
   etag@1.8.1: {}
 
+  eventsource-parser@3.0.1: {}
+
+  eventsource@3.0.6:
+    dependencies:
+      eventsource-parser: 3.0.1
+
   execa@5.1.1:
     dependencies:
       cross-spawn: 7.0.3
@@ -3449,6 +3675,10 @@ snapshots:
       jest-message-util: 29.7.0
       jest-util: 29.7.0
 
+  express-rate-limit@7.5.0(express@5.1.0):
+    dependencies:
+      express: 5.1.0
+
   express@4.21.2:
     dependencies:
       accepts: 1.3.8
@@ -3485,6 +3715,38 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  express@5.1.0:
+    dependencies:
+      accepts: 2.0.0
+      body-parser: 2.2.0
+      content-disposition: 1.0.0
+      content-type: 1.0.5
+      cookie: 0.7.2
+      cookie-signature: 1.2.2
+      debug: 4.4.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      finalhandler: 2.1.0
+      fresh: 2.0.0
+      http-errors: 2.0.0
+      merge-descriptors: 2.0.0
+      mime-types: 3.0.1
+      on-finished: 2.4.1
+      once: 1.4.0
+      parseurl: 1.3.3
+      proxy-addr: 2.0.7
+      qs: 6.14.0
+      range-parser: 1.2.1
+      router: 2.2.0
+      send: 1.2.0
+      serve-static: 2.2.0
+      statuses: 2.0.1
+      type-is: 2.0.1
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
   fast-deep-equal@3.1.3: {}
 
   fast-diff@1.3.0: {}
@@ -3537,6 +3799,17 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  finalhandler@2.1.0:
+    dependencies:
+      debug: 4.4.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      on-finished: 2.4.1
+      parseurl: 1.3.3
+      statuses: 2.0.1
+    transitivePeerDependencies:
+      - supports-color
+
   find-up@4.1.0:
     dependencies:
       locate-path: 5.0.0
@@ -3563,6 +3836,8 @@ snapshots:
 
   fresh@0.5.2: {}
 
+  fresh@2.0.0: {}
+
   fs-extra@11.3.0:
     dependencies:
       graceful-fs: 4.2.11
@@ -3675,6 +3950,10 @@ snapshots:
     dependencies:
       safer-buffer: 2.1.2
 
+  iconv-lite@0.6.3:
+    dependencies:
+      safer-buffer: 2.1.2
+
   ignore@5.3.2: {}
 
   import-fresh@3.3.1:
@@ -3716,6 +3995,8 @@ snapshots:
 
   is-number@7.0.0: {}
 
+  is-promise@4.0.0: {}
+
   is-stream@2.0.1: {}
 
   is-what@4.1.16: {}
@@ -3788,7 +4069,7 @@ snapshots:
       '@jest/expect': 29.7.0
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       co: 4.6.0
       dedent: 1.5.1
@@ -3808,16 +4089,16 @@ snapshots:
       - babel-plugin-macros
       - supports-color
 
-  jest-cli@29.7.0(@types/node@22.15.3):
+  jest-cli@29.7.0(@types/node@22.15.14):
     dependencies:
       '@jest/core': 29.7.0
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
       chalk: 4.1.2
-      create-jest: 29.7.0(@types/node@22.15.3)
+      create-jest: 29.7.0(@types/node@22.15.14)
       exit: 0.1.2
       import-local: 3.1.0
-      jest-config: 29.7.0(@types/node@22.15.3)
+      jest-config: 29.7.0(@types/node@22.15.14)
       jest-util: 29.7.0
       jest-validate: 29.7.0
       yargs: 17.7.2
@@ -3827,7 +4108,7 @@ snapshots:
       - supports-color
       - ts-node
 
-  jest-config@29.7.0(@types/node@22.15.3):
+  jest-config@29.7.0(@types/node@22.15.14):
     dependencies:
       '@babel/core': 7.23.9
       '@jest/test-sequencer': 29.7.0
@@ -3852,7 +4133,7 @@ snapshots:
       slash: 3.0.0
       strip-json-comments: 3.1.1
     optionalDependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
     transitivePeerDependencies:
       - babel-plugin-macros
       - supports-color
@@ -3881,7 +4162,7 @@ snapshots:
       '@jest/environment': 29.7.0
       '@jest/fake-timers': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       jest-mock: 29.7.0
       jest-util: 29.7.0
 
@@ -3891,7 +4172,7 @@ snapshots:
     dependencies:
       '@jest/types': 29.6.3
       '@types/graceful-fs': 4.1.9
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       anymatch: 3.1.3
       fb-watchman: 2.0.2
       graceful-fs: 4.2.11
@@ -3930,7 +4211,7 @@ snapshots:
   jest-mock@29.7.0:
     dependencies:
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       jest-util: 29.7.0
 
   jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
@@ -3965,7 +4246,7 @@ snapshots:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       emittery: 0.13.1
       graceful-fs: 4.2.11
@@ -3993,7 +4274,7 @@ snapshots:
       '@jest/test-result': 29.7.0
       '@jest/transform': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       cjs-module-lexer: 1.2.3
       collect-v8-coverage: 1.0.2
@@ -4039,7 +4320,7 @@ snapshots:
   jest-util@29.7.0:
     dependencies:
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       chalk: 4.1.2
       ci-info: 3.9.0
       graceful-fs: 4.2.11
@@ -4058,7 +4339,7 @@ snapshots:
     dependencies:
       '@jest/test-result': 29.7.0
       '@jest/types': 29.6.3
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       ansi-escapes: 4.3.2
       chalk: 4.1.2
       emittery: 0.13.1
@@ -4067,23 +4348,31 @@ snapshots:
 
   jest-worker@29.7.0:
     dependencies:
-      '@types/node': 22.15.3
+      '@types/node': 22.15.14
       jest-util: 29.7.0
       merge-stream: 2.0.0
       supports-color: 8.1.1
 
-  jest@29.7.0(@types/node@22.15.3):
+  jest@29.7.0(@types/node@22.15.14):
     dependencies:
       '@jest/core': 29.7.0
       '@jest/types': 29.6.3
       import-local: 3.1.0
-      jest-cli: 29.7.0(@types/node@22.15.3)
+      jest-cli: 29.7.0(@types/node@22.15.14)
     transitivePeerDependencies:
       - '@types/node'
       - babel-plugin-macros
       - supports-color
       - ts-node
 
+  joi@17.13.3:
+    dependencies:
+      '@hapi/hoek': 9.3.0
+      '@hapi/topo': 5.1.0
+      '@sideway/address': 4.1.5
+      '@sideway/formula': 3.0.1
+      '@sideway/pinpoint': 2.0.0
+
   js-tokens@4.0.0: {}
 
   js-yaml@3.14.1:
@@ -4119,7 +4408,7 @@ snapshots:
 
   kleur@3.0.3: {}
 
-  lemmy-js-client@1.0.0-remove-page-limit.1:
+  lemmy-js-client@1.0.0-local-image-user.3:
     dependencies:
       '@tsoa/runtime': 6.6.0
     transitivePeerDependencies:
@@ -4166,12 +4455,16 @@ snapshots:
 
   media-typer@0.3.0: {}
 
+  media-typer@1.1.0: {}
+
   merge-anything@5.1.7:
     dependencies:
       is-what: 4.1.16
 
   merge-descriptors@1.0.3: {}
 
+  merge-descriptors@2.0.0: {}
+
   merge-stream@2.0.0: {}
 
   merge2@1.4.1: {}
@@ -4192,10 +4485,16 @@ snapshots:
 
   mime-db@1.53.0: {}
 
+  mime-db@1.54.0: {}
+
   mime-types@2.1.35:
     dependencies:
       mime-db: 1.52.0
 
+  mime-types@3.0.1:
+    dependencies:
+      mime-db: 1.54.0
+
   mime@1.6.0: {}
 
   mimic-fn@2.1.0: {}
@@ -4224,6 +4523,8 @@ snapshots:
 
   negotiator@0.6.3: {}
 
+  negotiator@1.0.0: {}
+
   neo-async@2.6.2: {}
 
   node-int64@0.4.0: {}
@@ -4236,6 +4537,8 @@ snapshots:
     dependencies:
       path-key: 3.1.1
 
+  object-assign@4.1.1: {}
+
   object-inspect@1.13.4: {}
 
   on-finished@2.4.1:
@@ -4307,12 +4610,16 @@ snapshots:
 
   path-to-regexp@0.1.12: {}
 
+  path-to-regexp@8.2.0: {}
+
   picocolors@1.1.1: {}
 
   picomatch@2.3.1: {}
 
   pirates@4.0.6: {}
 
+  pkce-challenge@5.0.0: {}
+
   pkg-dir@4.2.0:
     dependencies:
       find-up: 4.1.0
@@ -4349,6 +4656,10 @@ snapshots:
     dependencies:
       side-channel: 1.1.0
 
+  qs@6.14.0:
+    dependencies:
+      side-channel: 1.1.0
+
   queue-microtask@1.2.3: {}
 
   range-parser@1.2.1: {}
@@ -4360,6 +4671,13 @@ snapshots:
       iconv-lite: 0.4.24
       unpipe: 1.0.0
 
+  raw-body@3.0.0:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.0
+      iconv-lite: 0.6.3
+      unpipe: 1.0.0
+
   react-is@18.3.1: {}
 
   reflect-metadata@0.2.2: {}
@@ -4384,6 +4702,16 @@ snapshots:
 
   reusify@1.1.0: {}
 
+  router@2.2.0:
+    dependencies:
+      debug: 4.4.0
+      depd: 2.0.0
+      is-promise: 4.0.0
+      parseurl: 1.3.3
+      path-to-regexp: 8.2.0
+    transitivePeerDependencies:
+      - supports-color
+
   run-parallel@1.2.0:
     dependencies:
       queue-microtask: 1.2.3
@@ -4416,6 +4744,22 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  send@1.2.0:
+    dependencies:
+      debug: 4.4.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      fresh: 2.0.0
+      http-errors: 2.0.0
+      mime-types: 3.0.1
+      ms: 2.1.3
+      on-finished: 2.4.1
+      range-parser: 1.2.1
+      statuses: 2.0.1
+    transitivePeerDependencies:
+      - supports-color
+
   serve-static@1.16.2:
     dependencies:
       encodeurl: 2.0.0
@@ -4425,6 +4769,15 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  serve-static@2.2.0:
+    dependencies:
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      parseurl: 1.3.3
+      send: 1.2.0
+    transitivePeerDependencies:
+      - supports-color
+
   setprototypeof@1.2.0: {}
 
   shebang-command@2.0.0:
@@ -4552,12 +4905,12 @@ snapshots:
 
   ts-deepmerge@7.0.2: {}
 
-  ts-jest@29.3.2(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.15.3))(typescript@5.8.3):
+  ts-jest@29.3.2(@babel/core@7.23.9)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(jest@29.7.0(@types/node@22.15.14))(typescript@5.8.3):
     dependencies:
       bs-logger: 0.2.6
       ejs: 3.1.10
       fast-json-stable-stringify: 2.1.0
-      jest: 29.7.0(@types/node@22.15.3)
+      jest: 29.7.0(@types/node@22.15.14)
       jest-util: 29.7.0
       json5: 2.2.3
       lodash.memoize: 4.1.2
@@ -4596,12 +4949,18 @@ snapshots:
       media-typer: 0.3.0
       mime-types: 2.1.35
 
-  typescript-eslint@8.31.1(eslint@9.25.1)(typescript@5.8.3):
+  type-is@2.0.1:
     dependencies:
-      '@typescript-eslint/eslint-plugin': 8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)(typescript@5.8.3)
-      '@typescript-eslint/parser': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
-      '@typescript-eslint/utils': 8.31.1(eslint@9.25.1)(typescript@5.8.3)
-      eslint: 9.25.1
+      content-type: 1.0.5
+      media-typer: 1.1.0
+      mime-types: 3.0.1
+
+  typescript-eslint@8.32.0(eslint@9.26.0)(typescript@5.8.3):
+    dependencies:
+      '@typescript-eslint/eslint-plugin': 8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3)
+      '@typescript-eslint/parser': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
+      '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3)
+      eslint: 9.26.0
       typescript: 5.8.3
     transitivePeerDependencies:
       - supports-color
@@ -4689,3 +5048,9 @@ snapshots:
       yargs-parser: 21.1.1
 
   yocto-queue@0.1.0: {}
+
+  zod-to-json-schema@3.24.5(zod@3.24.4):
+    dependencies:
+      zod: 3.24.4
+
+  zod@3.24.4: {}
diff --git a/api_tests/prepare-drone-federation-test.sh b/api_tests/prepare-drone-federation-test.sh
index c45491ee90..1bd0caa5c7 100755
--- a/api_tests/prepare-drone-federation-test.sh
+++ b/api_tests/prepare-drone-federation-test.sh
@@ -3,8 +3,7 @@
 #   it is expected that this script is called by run-federation-test.sh script.
 set -e
 
-if [ -z "$LEMMY_LOG_LEVEL" ];
-then
+if [ -z "$LEMMY_LOG_LEVEL" ]; then
   LEMMY_LOG_LEVEL=info
 fi
 
@@ -16,11 +15,10 @@ export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queu
 PICTRS_PATH="api_tests/pict-rs"
 PICTRS_EXPECTED_HASH="7f7ac2a45ef9b13403ee139b7512135be6b060ff2f6460e0c800e18e1b49d2fd  api_tests/pict-rs"
 
-# Pictrs setup. Download file with hash check and up to 3 retries. 
+# Pictrs setup. Download file with hash check and up to 3 retries.
 if [ ! -f "$PICTRS_PATH" ]; then
   count=0
-  while [ ! -f "$PICTRS_PATH" ] && [ "$count" -lt 3 ]
-  do
+  while [ ! -f "$PICTRS_PATH" ] && [ "$count" -lt 3 ]; do
     # This one sometimes goes down
     curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.17-pre.9/pict-rs-linux-amd64" -o "$PICTRS_PATH"
     # curl "https://codeberg.org/asonix/pict-rs/releases/download/v0.5.5/pict-rs-linux-amd64" -o "$PICTRS_PATH"
diff --git a/api_tests/src/comment.spec.ts b/api_tests/src/comment.spec.ts
index 853427d262..ab7320faee 100644
--- a/api_tests/src/comment.spec.ts
+++ b/api_tests/src/comment.spec.ts
@@ -54,7 +54,7 @@ let postOnAlphaRes: PostResponse;
 
 beforeAll(async () => {
   await setupLogins();
-  await Promise.all([followBeta(alpha), followBeta(gamma)]);
+  await Promise.allSettled([followBeta(alpha), followBeta(gamma)]);
   betaCommunity = (await resolveBetaCommunity(alpha)).community;
   if (betaCommunity) {
     postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
@@ -705,7 +705,7 @@ test("Check that activity from another instance is sent to third instance", asyn
     commentRes.comment_view,
   );
 
-  await Promise.all([unfollowRemotes(alpha), unfollowRemotes(gamma)]);
+  await Promise.allSettled([unfollowRemotes(alpha), unfollowRemotes(gamma)]);
 });
 
 test("Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.", async () => {
diff --git a/api_tests/src/image.spec.ts b/api_tests/src/image.spec.ts
index 4a715c8248..fcbcb08ea4 100644
--- a/api_tests/src/image.spec.ts
+++ b/api_tests/src/image.spec.ts
@@ -37,16 +37,13 @@ import {
 beforeAll(setupLogins);
 
 afterAll(async () => {
-  await Promise.all([unfollows(), deleteAllMedia(alpha)]);
+  await Promise.allSettled([unfollows(), deleteAllMedia(alpha)]);
 });
 
 test("Upload image and delete it", async () => {
   const health = await alpha.imageHealth();
   expect(health.success).toBeTruthy();
 
-  // Before running this test, you need to delete all previous images in the DB
-  await deleteAllMedia(alpha);
-
   // Upload test image. We use a simple string buffer as pictrs doesn't require an actual image
   // in testing mode.
   const upload_form: UploadImage = {
diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts
index b1fa060600..d93453cc82 100644
--- a/api_tests/src/post.spec.ts
+++ b/api_tests/src/post.spec.ts
@@ -462,7 +462,7 @@ test("Search for a post", async () => {
   expect(betaPost?.post.name).toBeDefined();
 });
 
-test.only("Enforce site ban federation for local user", async () => {
+test("Enforce site ban federation for local user", async () => {
   if (!betaCommunity) {
     throw "Missing beta community";
   }
diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts
index 0998e47c15..5dd525e869 100644
--- a/api_tests/src/shared.ts
+++ b/api_tests/src/shared.ts
@@ -768,7 +768,7 @@ export async function unfollowRemotes(api: LemmyHttp): Promise<MyUserInfo> {
   let my_user = await getMyUser(api);
   let remoteFollowed =
     my_user.follows.filter(c => c.community.local == false) ?? [];
-  await Promise.all(
+  await Promise.allSettled(
     remoteFollowed.map(cu => followCommunity(api, false, cu.community.id)),
   );
 
@@ -930,7 +930,7 @@ export async function deleteAllMedia(api: LemmyHttp) {
   const imagesRes = await api.listMediaAdmin({
     limit: imageFetchLimit,
   });
-  Promise.all(
+  Promise.allSettled(
     imagesRes.images
       .map(image => {
         const form: DeleteImageParams = {
@@ -943,14 +943,14 @@ export async function deleteAllMedia(api: LemmyHttp) {
 }
 
 export async function unfollows() {
-  await Promise.all([
+  await Promise.allSettled([
     unfollowRemotes(alpha),
     unfollowRemotes(beta),
     unfollowRemotes(gamma),
     unfollowRemotes(delta),
     unfollowRemotes(epsilon),
   ]);
-  await Promise.all([
+  await Promise.allSettled([
     purgeAllPosts(alpha),
     purgeAllPosts(beta),
     purgeAllPosts(gamma),
@@ -962,7 +962,7 @@ export async function unfollows() {
 export async function purgeAllPosts(api: LemmyHttp) {
   // The best way to get all federated items, is to find the posts
   let res = await api.getPosts({ type_: "All", limit: 50 });
-  await Promise.all(
+  await Promise.allSettled(
     Array.from(new Set(res.posts.map(p => p.post.id)))
       .map(post_id => api.purgePost({ post_id }))
       // Ignore errors
diff --git a/crates/api/src/local_user/list_media.rs b/crates/api/src/local_user/list_media.rs
index 12300818b8..a537f538a9 100644
--- a/crates/api/src/local_user/list_media.rs
+++ b/crates/api/src/local_user/list_media.rs
@@ -19,9 +19,9 @@ pub async fn list_media(
     None
   };
 
-  let images = LocalImageView::get_all_paged_by_local_user_id(
+  let images = LocalImageView::get_all_paged_by_person_id(
     &mut context.pool(),
-    local_user_view.local_user.id,
+    local_user_view.person.id,
     cursor_data,
     data.page_back,
     data.limit,
diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs
index 08cf1c1376..9b59b10556 100644
--- a/crates/api_common/src/request.rs
+++ b/crates/api_common/src/request.rs
@@ -219,7 +219,7 @@ pub async fn generate_post_link_metadata(
   };
 
   let image_url = if is_image_post {
-    post.url
+    post.url.clone()
   } else {
     metadata.opengraph_data.image.clone()
   };
@@ -233,7 +233,7 @@ pub async fn generate_post_link_metadata(
       .ok()
       .or(Some(url.into()))
   } else if let (true, Some(url)) = (allow_generate_thumbnail, image_url.clone()) {
-    generate_pictrs_thumbnail(&url, &context)
+    generate_pictrs_thumbnail(&post, &url, &context)
       .await
       .map_err(|e| warn!("Failed to generate thumbnail: {e}"))
       .ok()
@@ -443,7 +443,11 @@ pub async fn delete_image_from_pictrs(alias: &str, context: &LemmyContext) -> Le
 }
 
 /// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.
-async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> LemmyResult<Url> {
+async fn generate_pictrs_thumbnail(
+  post: &Post,
+  image_url: &Url,
+  context: &LemmyContext,
+) -> LemmyResult<Url> {
   let pictrs_config = context.settings().pictrs()?;
 
   match pictrs_config.image_mode {
@@ -482,10 +486,10 @@ async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> L
     .ok_or(LemmyErrorType::PictrsResponseError(res.msg))?;
 
   let form = LocalImageForm {
-    // This is none because its an internal request.
-    // IE, a local user shouldn't get to delete the thumbnails for their link posts
-    local_user_id: None,
     pictrs_alias: image.file.clone(),
+    // For thumbnails, the person_id is the post creator
+    person_id: post.creator_id,
+    thumbnail_for_post_id: Some(Some(post.id)),
   };
   let protocol_and_hostname = context.settings().get_protocol_and_hostname();
   let thumbnail_url = image.image_url(&protocol_and_hostname)?;
diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs
index fe9f1e25ce..bb47b286ae 100644
--- a/crates/api_common/src/utils.rs
+++ b/crates/api_common/src/utils.rs
@@ -549,19 +549,15 @@ pub async fn purge_post_images(
   }
 }
 
-/// Delete a local_user's images
+/// Delete local images attributed to a person
 async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
-  if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), person_id).await {
-    let pictrs_uploads =
-      LocalImageView::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id)
-        .await?;
-
-    // Delete their images
-    for upload in pictrs_uploads {
-      delete_image_from_pictrs(&upload.local_image.pictrs_alias, context)
-        .await
-        .ok();
-    }
+  let pictrs_uploads = LocalImageView::get_all_by_person_id(&mut context.pool(), person_id).await?;
+
+  // Delete their images
+  for upload in pictrs_uploads {
+    delete_image_from_pictrs(&upload.local_image.pictrs_alias, context)
+      .await
+      .ok();
   }
   Ok(())
 }
diff --git a/crates/db_schema/src/impls/images.rs b/crates/db_schema/src/impls/images.rs
index 45db1f1477..7f442359da 100644
--- a/crates/db_schema/src/impls/images.rs
+++ b/crates/db_schema/src/impls/images.rs
@@ -1,5 +1,5 @@
 use crate::{
-  newtypes::{DbUrl, LocalUserId},
+  newtypes::{DbUrl, PersonId},
   source::images::{ImageDetails, ImageDetailsInsertForm, LocalImage, LocalImageForm, RemoteImage},
   utils::{get_conn, DbPool},
 };
@@ -65,14 +65,14 @@ impl LocalImage {
   pub async fn delete_by_alias_and_user(
     pool: &mut DbPool<'_>,
     alias: &str,
-    local_user_id: LocalUserId,
+    person_id: PersonId,
   ) -> LemmyResult<Self> {
     let conn = &mut get_conn(pool).await?;
     diesel::delete(
       local_image::table.filter(
         local_image::pictrs_alias
           .eq(alias)
-          .and(local_image::local_user_id.eq(local_user_id)),
+          .and(local_image::person_id.eq(person_id)),
       ),
     )
     .get_result(conn)
diff --git a/crates/db_schema/src/source/images.rs b/crates/db_schema/src/source/images.rs
index 33ea44ea4d..4ccb4c3784 100644
--- a/crates/db_schema/src/source/images.rs
+++ b/crates/db_schema/src/source/images.rs
@@ -1,4 +1,4 @@
-use crate::newtypes::{DbUrl, LocalUserId};
+use crate::newtypes::{DbUrl, PersonId, PostId};
 use chrono::{DateTime, Utc};
 use serde::{Deserialize, Serialize};
 use serde_with::skip_serializing_none;
@@ -25,26 +25,26 @@ use {
 )]
 #[cfg_attr(feature = "full", ts(export))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
-#[cfg_attr(
-  feature = "full",
-  diesel(belongs_to(crate::source::local_user::LocalUser))
-)]
+#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))]
 #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
 #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
 #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))]
 pub struct LocalImage {
-  #[cfg_attr(feature = "full", ts(optional))]
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
   pub published: DateTime<Utc>,
+  pub person_id: PersonId,
+  #[cfg_attr(feature = "full", ts(optional))]
+  /// This means the image is an auto-generated thumbnail, for a post.
+  pub thumbnail_for_post_id: Option<PostId>,
 }
 
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
 pub struct LocalImageForm {
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
+  pub person_id: PersonId,
+  pub thumbnail_for_post_id: Option<Option<PostId>>,
 }
 
 /// Stores all images which are hosted on remote domains. When attempting to proxy an image, it
diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs
index df2b215ef2..d6baacdae9 100644
--- a/crates/db_schema_file/src/schema.rs
+++ b/crates/db_schema_file/src/schema.rs
@@ -393,9 +393,10 @@ diesel::table! {
 
 diesel::table! {
     local_image (pictrs_alias) {
-        local_user_id -> Nullable<Int4>,
         pictrs_alias -> Text,
         published -> Timestamptz,
+        person_id -> Int4,
+        thumbnail_for_post_id -> Nullable<Int4>,
     }
 }
 
@@ -1117,7 +1118,8 @@ diesel::joinable!(inbox_combined -> person_post_mention (person_post_mention_id)
 diesel::joinable!(inbox_combined -> private_message (private_message_id));
 diesel::joinable!(instance_actions -> instance (instance_id));
 diesel::joinable!(instance_actions -> person (person_id));
-diesel::joinable!(local_image -> local_user (local_user_id));
+diesel::joinable!(local_image -> person (person_id));
+diesel::joinable!(local_image -> post (thumbnail_for_post_id));
 diesel::joinable!(local_site -> site (site_id));
 diesel::joinable!(local_site_rate_limit -> local_site (local_site_id));
 diesel::joinable!(local_user -> person (person_id));
diff --git a/crates/db_views/local_image/src/impls.rs b/crates/db_views/local_image/src/impls.rs
index e8458bf801..b03db0f9c9 100644
--- a/crates/db_views/local_image/src/impls.rs
+++ b/crates/db_views/local_image/src/impls.rs
@@ -1,27 +1,27 @@
 use crate::LocalImageView;
-use diesel::{ExpressionMethods, JoinOnDsl, QueryDsl, SelectableHelper};
+use diesel::{ExpressionMethods, QueryDsl, SelectableHelper};
 use diesel_async::RunQueryDsl;
 use i_love_jesus::SortDirection;
 use lemmy_db_schema::{
-  newtypes::{LocalUserId, PaginationCursor},
+  newtypes::{PaginationCursor, PersonId},
   source::images::{local_image_keys as key, LocalImage},
   traits::PaginationCursorBuilder,
   utils::{get_conn, limit_fetch, paginate, DbPool},
 };
-use lemmy_db_schema_file::schema::{local_image, local_user, person};
+use lemmy_db_schema_file::schema::{local_image, person, post};
 use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
 
 impl LocalImageView {
   #[diesel::dsl::auto_type(no_type_alias)]
   fn joins() -> _ {
     local_image::table
-      .inner_join(local_user::table)
-      .inner_join(person::table.on(local_user::person_id.eq(person::id)))
+      .inner_join(person::table)
+      .left_join(post::table)
   }
 
-  pub async fn get_all_paged_by_local_user_id(
+  pub async fn get_all_paged_by_person_id(
     pool: &mut DbPool<'_>,
-    user_id: LocalUserId,
+    person_id: PersonId,
     cursor_data: Option<LocalImage>,
     page_back: Option<bool>,
     limit: Option<i64>,
@@ -30,7 +30,7 @@ impl LocalImageView {
     let limit = limit_fetch(limit)?;
 
     let query = Self::joins()
-      .filter(local_image::local_user_id.eq(user_id))
+      .filter(local_image::person_id.eq(person_id))
       .select(Self::as_select())
       .limit(limit)
       .into_boxed();
@@ -44,13 +44,13 @@ impl LocalImageView {
       .with_lemmy_type(LemmyErrorType::NotFound)
   }
 
-  pub async fn get_all_by_local_user_id(
+  pub async fn get_all_by_person_id(
     pool: &mut DbPool<'_>,
-    user_id: LocalUserId,
+    person_id: PersonId,
   ) -> LemmyResult<Vec<Self>> {
     let conn = &mut get_conn(pool).await?;
     Self::joins()
-      .filter(local_image::local_user_id.eq(user_id))
+      .filter(local_image::person_id.eq(person_id))
       .select(Self::as_select())
       .load::<Self>(conn)
       .await
diff --git a/crates/db_views/local_image/src/lib.rs b/crates/db_views/local_image/src/lib.rs
index b7f7879808..035d646967 100644
--- a/crates/db_views/local_image/src/lib.rs
+++ b/crates/db_views/local_image/src/lib.rs
@@ -1,4 +1,4 @@
-use lemmy_db_schema::source::{images::LocalImage, person::Person};
+use lemmy_db_schema::source::{images::LocalImage, person::Person, post::Post};
 use serde::{Deserialize, Serialize};
 use serde_with::skip_serializing_none;
 #[cfg(feature = "full")]
@@ -21,4 +21,7 @@ pub struct LocalImageView {
   pub local_image: LocalImage,
   #[cfg_attr(feature = "full", diesel(embed))]
   pub person: Person,
+  #[cfg_attr(feature = "full", diesel(embed))]
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub post: Option<Post>,
 }
diff --git a/crates/routes/src/images/delete.rs b/crates/routes/src/images/delete.rs
index 7230ce5353..410224a436 100644
--- a/crates/routes/src/images/delete.rs
+++ b/crates/routes/src/images/delete.rs
@@ -131,7 +131,7 @@ pub async fn delete_image(
   LocalImage::delete_by_alias_and_user(
     &mut context.pool(),
     &data.filename,
-    local_user_view.local_user.id,
+    local_user_view.person.id,
   )
   .await?;
 
diff --git a/crates/routes/src/images/upload.rs b/crates/routes/src/images/upload.rs
index 334f5b8911..f07080f08f 100644
--- a/crates/routes/src/images/upload.rs
+++ b/crates/routes/src/images/upload.rs
@@ -221,8 +221,9 @@ pub async fn do_upload_image(
     // but still a user may upload multiple and so we need to store all links in db for
     // to allow deletion via web ui.
     let form = LocalImageForm {
-      local_user_id: Some(local_user_view.local_user.id),
       pictrs_alias: image.file.to_string(),
+      person_id: local_user_view.person.id,
+      thumbnail_for_post_id: None,
     };
 
     let protocol_and_hostname = context.settings().get_protocol_and_hostname();
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 68f07bc543..c1a67ab722 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -66,7 +66,7 @@ services:
     # entrypoint: /sbin/tini -- /usr/local/bin/pict-rs -p /mnt -m 4 --image-format webp
     environment:
       - PICTRS_OPENTELEMETRY_URL=http://otel:4137
-      - PICTRS__API_KEY=API_KEY
+      - PICTRS__SERVER__API_KEY=my-pictrs-key
       - PICTRS__MEDIA__VIDEO_CODEC=vp9
       - PICTRS__MEDIA__GIF__MAX_WIDTH=256
       - PICTRS__MEDIA__GIF__MAX_HEIGHT=256
diff --git a/docker/federation/docker-compose.yml b/docker/federation/docker-compose.yml
index 7148216ad3..99d4017263 100644
--- a/docker/federation/docker-compose.yml
+++ b/docker/federation/docker-compose.yml
@@ -53,6 +53,8 @@ services:
     user: 991:991
     volumes:
       - ./volumes/pictrs_alpha:/mnt:Z
+    environment:
+      - PICTRS__SERVER__API_KEY=my-pictrs-key
 
   lemmy-alpha-ui:
     <<: *ui-default
diff --git a/docker/federation/lemmy_delta.hjson b/docker/federation/lemmy_delta.hjson
index acfddc3048..ba2a32218b 100644
--- a/docker/federation/lemmy_delta.hjson
+++ b/docker/federation/lemmy_delta.hjson
@@ -10,4 +10,8 @@
   database: {
     connection: "postgres://lemmy:password@postgres_delta:5432/lemmy"
   }
+  pictrs: {
+    api_key: "my-pictrs-key"
+    image_mode: StoreLinkPreviews
+  }
 }
diff --git a/docker/lemmy.hjson b/docker/lemmy.hjson
index e28a49b6d5..ae2ce2a35d 100644
--- a/docker/lemmy.hjson
+++ b/docker/lemmy.hjson
@@ -20,7 +20,7 @@
 
   pictrs: {
     url: "http://pictrs:8080/"
-    # api_key: "API_KEY"
+    api_key: "my-pictrs-key"
     image_mode: None
   }
 
diff --git a/migrations/2025-05-06-145536_local_image_person/down.sql b/migrations/2025-05-06-145536_local_image_person/down.sql
new file mode 100644
index 0000000000..f76b0e6254
--- /dev/null
+++ b/migrations/2025-05-06-145536_local_image_person/down.sql
@@ -0,0 +1,46 @@
+ALTER TABLE local_image
+    ADD COLUMN local_user_id int REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE;
+
+UPDATE
+    local_image AS li
+SET
+    local_user_id = lu.id
+FROM
+    local_user AS lu
+WHERE
+    li.person_id = lu.person_id;
+
+-- You need to have the exact correct column order, so this needs to be re-created
+--
+-- Rename the table
+ALTER TABLE local_image RENAME TO local_image_old;
+
+-- Rename a few constraints
+ALTER TABLE local_image_old RENAME CONSTRAINT image_upload_pkey TO image_upload_pkey_old;
+
+-- Create the old one again
+CREATE TABLE local_image (
+    local_user_id integer,
+    pictrs_alias text NOT NULL,
+    published timestamp with time zone DEFAULT now() NOT NULL
+);
+
+ALTER TABLE ONLY local_image
+    ADD CONSTRAINT image_upload_pkey PRIMARY KEY (pictrs_alias);
+
+CREATE INDEX idx_image_upload_local_user_id ON local_image USING btree (local_user_id);
+
+ALTER TABLE ONLY local_image
+    ADD CONSTRAINT image_upload_local_user_id_fkey FOREIGN KEY (local_user_id) REFERENCES local_user (id) ON UPDATE CASCADE ON DELETE CASCADE;
+
+-- Insert the data again
+INSERT INTO local_image (local_user_id, pictrs_alias, published)
+SELECT
+    local_user_id,
+    pictrs_alias,
+    published
+FROM
+    local_image_old;
+
+DROP TABLE local_image_old;
+
diff --git a/migrations/2025-05-06-145536_local_image_person/up.sql b/migrations/2025-05-06-145536_local_image_person/up.sql
new file mode 100644
index 0000000000..9cde9f3a4d
--- /dev/null
+++ b/migrations/2025-05-06-145536_local_image_person/up.sql
@@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_for_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
+
+-- Update historical person_id columns
+-- Note: The local_user_id rows are null for thumbnails, so there's nothing you can do there.
+UPDATE
+    local_image AS li
+SET
+    person_id = lu.person_id
+FROM
+    local_user AS lu
+WHERE
+    li.local_user_id = lu.id;
+
+-- Remove the default
+ALTER TABLE local_image
+    ALTER COLUMN person_id DROP DEFAULT;
+
+-- Remove the local_user_id column
+ALTER TABLE local_image
+    DROP COLUMN local_user_id;
+
+CREATE INDEX idx_image_upload_person_id ON local_image (person_id);
+


Reviews:
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T16:09:16Z
user: login: Nutomic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T09:16:27Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T19:05:33Z
user: login: Nothing4You
body: In `main`, we currently have the following paths leading to image deletion:

- deleting all images associated with a person
  - a local admin issuing an instance ban with content removal
  - receiving a federated ban with content removal from the user's home instance
  - a local admin purging a user
  - a local user deleting their own account
  - receiving a federated user account deletion with content removal
- deleting a individual images
  - a local user deleting their own upload
  - various methods for deleting or replacing images linked to objects
    - site banner and icon by local admins
    - community banner and icon by local mods
    - user avatar and banner by a local user themself

The following paths leading to image purges:
- a local admin calling the API to delete an individual image by filename
- a local admin purging a post
  - purges both thumbnail URL and post URL

I still think the individual post purges could use some improvements, but I'm not yet sure about the best solution for that, so that probably doesn't need to be part of this PR.

Overall I believe that all deletion and purge paths on `main` have appropriate validation.
If this gets backported to 0.19 it will need an additional review there, as the API methods involved have diverged quite a bit already.

Only a minor nitpick for an unnecessary double `Option`, does that even work in its current state? I don't think any test code currently hits that path?
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T22:07:48Z
user: login: Nothing4You
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T22:19:49Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T23:38:03Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T23:39:56Z
user: login: Nothing4You
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T08:26:47Z
user: login: Nutomic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T09:29:30Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:55:07Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T13:56:39Z
user: login: Nothing4You
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T15:05:14Z
user: login: Nothing4You
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-10T13:00:04Z
user: login: dessalines
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T18:21:39Z
user: login: Nutomic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T08:20:09Z
user: login: Nutomic
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T09:40:06Z
user: login: Nutomic
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T09:40:12Z

Review comments:
diff_hunk: @@ -551,10 +551,11 @@ pub async fn purge_post_images(
 
 /// Delete a local_user's images
 async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
+  // This is somewhat pointless, since local_image only stores person_id. But it makes
+  // sure they're local, and their images have local rows.
   if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), person_id).await {
     let pictrs_uploads =
-      LocalImageView::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id)
-        .await?;
+      LocalImageView::get_all_by_person_id(&mut context.pool(), local_user.person.id).await?;
path: crates/api_common/src/utils.rs
user: login: dessalines
body: @Nothing4You I'm not sure if this is correct anymore (since one of the points of this PR should be to allow the deletion of local thumbnails generated by federated users). 

But I'm afraid of touching it, because of the problems with deleting federated images we had before. 

created_at: 2025-05-06T16:09:16Z
author_association: MEMBER
diff_hunk: @@ -551,10 +551,11 @@ pub async fn purge_post_images(
 
 /// Delete a local_user's images
 async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
+  // This is somewhat pointless, since local_image only stores person_id. But it makes
+  // sure they're local, and their images have local rows.
   if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), person_id).await {
     let pictrs_uploads =
-      LocalImageView::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id)
-        .await?;
+      LocalImageView::get_all_by_person_id(&mut context.pool(), local_user.person.id).await?;
path: crates/api_common/src/utils.rs
user: login: Nutomic
body: This is called when an account gets banned with remove content or gets purged, so it should also delete images for remote users. Which means you can remove the whole `if let Ok(local_user) = LocalUserView::read_person` condition.
created_at: 2025-05-07T09:16:27Z
author_association: MEMBER
diff_hunk: @@ -551,10 +551,11 @@ pub async fn purge_post_images(
 
 /// Delete a local_user's images
 async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
+  // This is somewhat pointless, since local_image only stores person_id. But it makes
+  // sure they're local, and their images have local rows.
   if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), person_id).await {
     let pictrs_uploads =
-      LocalImageView::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id)
-        .await?;
+      LocalImageView::get_all_by_person_id(&mut context.pool(), local_user.person.id).await?;
path: crates/api_common/src/utils.rs
user: login: dessalines
body: Alright I'll remove, but nothing4u should look before merging.
created_at: 2025-05-08T19:05:33Z
author_association: MEMBER
diff_hunk: @@ -443,7 +446,12 @@ pub async fn delete_image_from_pictrs(alias: &str, context: &LemmyContext) -> Le
 }
 
 /// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.
-async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> LemmyResult<Url> {
+async fn generate_pictrs_thumbnail(
+  creator_id: PersonId,
+  post_id: Option<PostId>,
path: crates/api_common/src/request.rs
user: login: Nothing4You
body: ```suggestion
  post_id: PostId,
```
this doesn't need to be an `Option`
created_at: 2025-05-08T20:00:11Z
author_association: COLLABORATOR
diff_hunk: @@ -233,7 +236,7 @@ pub async fn generate_post_link_metadata(
       .ok()
       .or(Some(url.into()))
   } else if let (true, Some(url)) = (allow_generate_thumbnail, image_url.clone()) {
-    generate_pictrs_thumbnail(&url, &context)
+    generate_pictrs_thumbnail(post.creator_id, Some(post.id), &url, &context)
path: crates/api_common/src/request.rs
user: login: Nothing4You
body: ```suggestion
    generate_pictrs_thumbnail(post.creator_id, post.id, &url, &context)
```
created_at: 2025-05-08T20:00:45Z
author_association: COLLABORATOR
diff_hunk: @@ -25,26 +25,25 @@ use {
 )]
 #[cfg_attr(feature = "full", ts(export))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
-#[cfg_attr(
-  feature = "full",
-  diesel(belongs_to(crate::source::local_user::LocalUser))
-)]
+#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))]
 #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
 #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
 #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))]
 pub struct LocalImage {
-  #[cfg_attr(feature = "full", ts(optional))]
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
   pub published: DateTime<Utc>,
+  pub person_id: PersonId,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub thumbnail_and_post_id: Option<PostId>,
 }
 
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
 pub struct LocalImageForm {
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
+  pub person_id: PersonId,
+  pub thumbnail_and_post_id: Option<Option<PostId>>,
path: crates/db_schema/src/source/images.rs
user: login: Nothing4You
body: ```suggestion
  pub thumbnail_and_post_id: Option<PostId>,
```
is there a reason for this being a double option?
since this is only used for inserts and nothing gets updated it should be fine to use a single option level?
created_at: 2025-05-08T20:33:24Z
author_association: COLLABORATOR
diff_hunk: @@ -551,17 +551,13 @@ pub async fn purge_post_images(
 
 /// Delete a local_user's images
 async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
path: crates/api_common/src/utils.rs
user: login: Nothing4You
body: ```suggestion
/// Delete local images attributed to a person
async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> {
```
at least the comment should be adjusted, if not the function name as well.
created_at: 2025-05-08T22:19:49Z
author_association: COLLABORATOR
diff_hunk: @@ -443,7 +446,12 @@ pub async fn delete_image_from_pictrs(alias: &str, context: &LemmyContext) -> Le
 }
 
 /// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.
-async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> LemmyResult<Url> {
+async fn generate_pictrs_thumbnail(
+  creator_id: PersonId,
+  post_id: Option<PostId>,
path: crates/api_common/src/request.rs
user: login: dessalines
body: Oops, good call, thx.
created_at: 2025-05-08T23:38:03Z
author_association: MEMBER
diff_hunk: @@ -25,26 +25,25 @@ use {
 )]
 #[cfg_attr(feature = "full", ts(export))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
-#[cfg_attr(
-  feature = "full",
-  diesel(belongs_to(crate::source::local_user::LocalUser))
-)]
+#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))]
 #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
 #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
 #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))]
 pub struct LocalImage {
-  #[cfg_attr(feature = "full", ts(optional))]
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
   pub published: DateTime<Utc>,
+  pub person_id: PersonId,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub thumbnail_and_post_id: Option<PostId>,
 }
 
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
 pub struct LocalImageForm {
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
+  pub person_id: PersonId,
+  pub thumbnail_and_post_id: Option<Option<PostId>>,
path: crates/db_schema/src/source/images.rs
user: login: dessalines
body: Yes in diesel for any update forms of an optional column, you should use a double optional, because `Some(None)` is how you clear out the column. We might not be using it here but its good practice to match the column.

For insert forms, you only need a single option (or if theres a default), but this one is serving as both.

We aren't doing this in a lot of places where we should be, so I'll create a cleanup issue for this.

https://github.com/LemmyNet/lemmy/issues/5672
created_at: 2025-05-08T23:39:56Z
author_association: MEMBER
diff_hunk: @@ -25,26 +25,25 @@ use {
 )]
 #[cfg_attr(feature = "full", ts(export))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
-#[cfg_attr(
-  feature = "full",
-  diesel(belongs_to(crate::source::local_user::LocalUser))
-)]
+#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))]
 #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
 #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
 #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))]
 pub struct LocalImage {
-  #[cfg_attr(feature = "full", ts(optional))]
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
   pub published: DateTime<Utc>,
+  pub person_id: PersonId,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub thumbnail_and_post_id: Option<PostId>,
 }
 
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
 pub struct LocalImageForm {
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
+  pub person_id: PersonId,
+  pub thumbnail_and_post_id: Option<Option<PostId>>,
path: crates/db_schema/src/source/images.rs
user: login: Nothing4You
body: how is this one serving as both?
I don't see `local_image` being updated anywhere, are you referring to potential future use?
`LocalImage` currently only has `create` and `delete_by_*` functions.
created_at: 2025-05-09T08:26:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_and_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
path: migrations/2025-05-06-145536_local_image_person/up.sql
user: login: Nutomic
body: The name is a bit strange, why not simply post_id?
created_at: 2025-05-09T09:29:29Z
author_association: MEMBER
diff_hunk: @@ -25,26 +25,25 @@ use {
 )]
 #[cfg_attr(feature = "full", ts(export))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
-#[cfg_attr(
-  feature = "full",
-  diesel(belongs_to(crate::source::local_user::LocalUser))
-)]
+#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::person::Person)))]
 #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
 #[cfg_attr(feature = "full", diesel(primary_key(pictrs_alias)))]
 #[cfg_attr(feature = "full", cursor_keys_module(name = local_image_keys))]
 pub struct LocalImage {
-  #[cfg_attr(feature = "full", ts(optional))]
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
   pub published: DateTime<Utc>,
+  pub person_id: PersonId,
+  #[cfg_attr(feature = "full", ts(optional))]
+  pub thumbnail_and_post_id: Option<PostId>,
 }
 
 #[derive(Debug, Clone)]
 #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
 #[cfg_attr(feature = "full", diesel(table_name = local_image))]
 pub struct LocalImageForm {
-  pub local_user_id: Option<LocalUserId>,
   pub pictrs_alias: String,
+  pub person_id: PersonId,
+  pub thumbnail_and_post_id: Option<Option<PostId>>,
path: crates/db_schema/src/source/images.rs
user: login: dessalines
body: Yep... In simpler tables without auto-generated pkeys, its fine to just have the insert form and update form be the same. No need to have separate ones really. If we ever do add an update, then the existing form will serve for both. 
created_at: 2025-05-09T13:55:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_and_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
path: migrations/2025-05-06-145536_local_image_person/up.sql
user: login: dessalines
body: I thought about that, but since the api will return this field as part of `local_image`, I think its better to make it very clear that this column means its both a thumbnail and this is the post id. IMO since we're not even using the `post_id` yet, this could be a `is_thumbnail` column which would be clearer, but I think nothing4u is right that we might as well add the post_id now just in case it's necessary in the future.

I've also already added a comment about it too.
created_at: 2025-05-09T13:56:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_and_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
path: migrations/2025-05-06-145536_local_image_person/up.sql
user: login: Nothing4You
body: what about `thumbnail_for_post_id`?
created_at: 2025-05-09T15:05:14Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_and_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
path: migrations/2025-05-06-145536_local_image_person/up.sql
user: login: dessalines
body: @Nutomic lmk if you still want this done, otherwise it can be merged.
created_at: 2025-05-13T18:21:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+-- Since local thumbnails could be generated from posts of external users,
+-- use the person_id instead of local_user_id for the LocalImage table.
+--
+-- Also connect the thumbnail to a post id.
+--
+-- See https://github.com/LemmyNet/lemmy/issues/5564
+ALTER TABLE local_image
+    ADD COLUMN person_id int NOT NULL DEFAULT 0 REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE,
+    ADD COLUMN thumbnail_and_post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE;
path: migrations/2025-05-06-145536_local_image_person/up.sql
user: login: Nutomic
body: Yes its better to rename, `thumbnail_for_post_id` sounds good.
created_at: 2025-05-15T08:20:08Z
author_association: MEMBER
diff_hunk: @@ -443,7 +446,12 @@ pub async fn delete_image_from_pictrs(alias: &str, context: &LemmyContext) -> Le
 }
 
 /// Retrieves the image with local pict-rs and generates a thumbnail. Returns the thumbnail url.
-async fn generate_pictrs_thumbnail(image_url: &Url, context: &LemmyContext) -> LemmyResult<Url> {
+async fn generate_pictrs_thumbnail(
+  creator_id: PersonId,
+  post_id: PostId,
path: crates/api_common/src/request.rs
user: login: Nutomic
body: Easier to pass `&Post` here. Otherwise looks good to merge.
created_at: 2025-05-16T09:40:05Z
author_association: MEMBER

PR comments:
user: login: Nutomic
created_at: 2025-05-07T09:18:56Z
body: With this change a user can delete the thumbnail for a post he created. To prevent this you need to add another db column `local_image.is_thumbnail`, and if it is true prevent deletion.

https://github.com/LemmyNet/lemmy/blob/main/crates/routes/src/images/delete.rs#L126
author_association: MEMBER
user: login: Nothing4You
created_at: 2025-05-07T10:49:36Z
body: if this includes tracking post ids for thumbnail generation as mentioned in https://github.com/LemmyNet/lemmy/issues/5564#issuecomment-2763086842, this could be used to deduct whether an image is a thumbnail implicitly. in case we start using this information differently in the future, it would be a safe migration without data loss, but for now we can just derive the thumbnail aspect from that.

i'm not sure whether users should be able to delete their autogenerated thumbnails; there may be valid use cases for this, including a post being deleted or submitting a wrong URL which contains private data, but if this is allowed it needs to be made very clear to the user that they may break thumbnails this way.

for the ux side, this might make sense to have separate listings for uploads and thumbnails of a users media.

will take a thorough look at the code later or tomorrow, don't have much time today.
author_association: COLLABORATOR
user: login: dessalines
created_at: 2025-05-07T22:40:22Z
body: > With this change a user can delete the thumbnail for a post he created. To prevent this you need to add another db column local_image.is_thumbnail, and if it is true prevent deletion.

Hrm... didn't think of that. But maybe its actually a good thing... like if a user links to their own site they didn't want to, or a thumbnail is generated from a link on another platform that's their own image. I'm open to adding that column tho. In a sense they "own" the post, since they made it, so its up to them whether to keep the thumbnail or not.

> tracking post ids for thumbnail generation

I think its better to track users rather than post_ids, because local uploads can also come from comments / private messages, so that column would need to be optional. More importantly, the main use case here is to handle user ban/remove properly, so `person_id` is a better tracker for that.

author_association: MEMBER
user: login: Nutomic
created_at: 2025-05-08T07:42:25Z
body: The idea is to store a nullable post_id in addition to person_id to tell that the image is a post thumbnail. But I dont like to add extra data which is unused and "may be used in the future" (but likely never will be).
author_association: MEMBER
user: login: Nothing4You
created_at: 2025-05-08T10:40:47Z
body: The post id can be exposed via API as well and provide context for the generated thumbnails.
The user experience could have one listing with manual uploads and a separate listing for automatically generated thumbnails, which in turn could provide them with a link to the corresponding post. That alone I believe would have value for users already and would implicitly also allow deducing that it's a thumbnail, rather than having a bool or text column.
author_association: COLLABORATOR
user: login: dessalines
created_at: 2025-05-08T14:26:59Z
body: I'll go ahead and add this, and call it `thumbnail_and_post_id` , to clarify.
author_association: MEMBER
user: login: dessalines
created_at: 2025-05-08T17:15:56Z
body: Ugh, so frustrating. API Tests are passing locally and I can't replicate the errors in CI.

EDIT: fixed it.
author_association: MEMBER
user: login: dessalines
created_at: 2025-05-08T19:14:26Z
body: I also just made sure that:

- User-specific API media fetches **don't** return thumbnails.
- Deleting / purging a user won't delete thumbnails.

Admins can still see all images, including thumbnails, in the panel.

EDIT: reverted these two changes now. 
author_association: MEMBER
user: login: Nothing4You
created_at: 2025-05-08T19:26:09Z
body: > User-specific API media fetches don't return thumbnails

If we want to allow users to delete thumbnails from their posts they need to be obtainable from the API somehow.
We can probably add an optional query parameter that would change the listing to only thumbnails.

> Deleting / purging a user won't delete thumbnails.

Considering that going forward we can reliably associate thumbnails with the posts/users they're originating from, this is now a safe action to do and doesn't need to be excluded.
Both user purges and user deletions that include deletion of their content should also nuke their thumbnails.
This should implicitly happen by just filtering on the person_id and doesn't need to consider anything else there.
It should also be done when we receive a user deletion from federation and it includes deletion of their content.
author_association: COLLABORATOR
user: login: dessalines
created_at: 2025-05-08T20:14:45Z
body: In that case I'll just revert my last two commits, and this should be ready to go.

I don't think a filter for whether to include thumbnails is really necessary rn, we can just include all of them.
author_association: MEMBER
user: login: Nothing4You
created_at: 2025-05-08T22:26:51Z
body: > I don't think a filter for whether to include thumbnails is really necessary rn, we can just include all of them.

Can we still include include post information for the thumbnail ones in the API response or do you want to leave that for another PR? Without that frontends can't tell if it's generated or uploaded. It should probably be added to the `LocalImageView`.
author_association: COLLABORATOR
user: login: dessalines
created_at: 2025-05-08T23:44:33Z
body: I can add the optional post to `LocalImageView`, i'll do that now. 

But the existing `local_image.thumbnail_and_post_id` is already enough to differentiate.
author_association: MEMBER