store profile avatars in R2
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 11:40:50 +09:00
parent 0112956835
commit 19b8e40b72
7 changed files with 171 additions and 7 deletions
+53
View File
@@ -5,6 +5,7 @@
"": { "": {
"name": "atnmy-stdo-auth", "name": "atnmy-stdo-auth",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1081.0",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.14.4",
"@prisma/client": "^6.9.0", "@prisma/client": "^6.9.0",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
@@ -21,6 +22,42 @@
}, },
}, },
"packages": { "packages": {
"@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.14", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-9j0TSNmYVywV+shhBXXkIhaazZwZ+nDSGfG6V47nFLqXdk6mPYk/OJkV4+uRvIg2Ju5F6HTvgM/sfsuTsbAYwg=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1081.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.14", "@aws-sdk/core": "^3.974.29", "@aws-sdk/credential-provider-node": "^3.972.64", "@aws-sdk/middleware-sdk-s3": "^3.972.60", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-mHAdLBibsbFwcW2YHrH5sIQlaXjB3zha7Nkooa2gqTlXxRpVJ+ba/v/mg19B6rJeZmOEteRHFz+INOlAbCbxJg=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@aws-sdk/xml-builder": "^3.972.33", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.0", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-yqKcltLbtRh1ubzhRSldIs8jFHNZlyMlgoIccCC0aDVbrB99nXaBdmfr89mK7obWX/NVg4rAMpCpZ6dCDiVBtA=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Ah36tYkqyaVnaHkx7VseoTYrHUmwgBps3V+wnrC1idhIIMGlviH0FtrX9EIPdAlVHvXC7FQZLhmHBRz+pLaiWg=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.57", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-/vp6i5YEliJqRm5k/BDmYjAyRAMTdkjW6UciVRk9oh/0OfDCWeb/ih7hqte4lFvKXkIbsqe9AdK9LQK6NGardw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/credential-provider-env": "^3.972.55", "@aws-sdk/credential-provider-http": "^3.972.57", "@aws-sdk/credential-provider-login": "^3.972.61", "@aws-sdk/credential-provider-process": "^3.972.55", "@aws-sdk/credential-provider-sso": "^3.972.61", "@aws-sdk/credential-provider-web-identity": "^3.972.61", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-pQIRiQQs+MUlVnJdWJ7/6KS0WxcLRVfut57OFgwC3cnM1F8mXw3Kh4gAVwj6AtvD6CWx8x6+po4ENRcqe64XrQ=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-jtrxWwC7slqxh7DnAWHrwsA3UwCsnlypdYtavGT7EX5p791wxWQys7QzkCZ7JvOMAyylDtPoxyV+ic0zg3rV9g=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.64", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.55", "@aws-sdk/credential-provider-http": "^3.972.57", "@aws-sdk/credential-provider-ini": "^3.972.62", "@aws-sdk/credential-provider-process": "^3.972.55", "@aws-sdk/credential-provider-sso": "^3.972.61", "@aws-sdk/credential-provider-web-identity": "^3.972.61", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/credential-provider-imds": "^4.4.5", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-zyKVYDyMR9VQL/kPi03ygN2vtD9uLMuWRLoJ77KxgZZaS1VlJloI+SzleF9Zg4HWUI+AIu+ZRs8zsJFNqbrxsw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.55", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-x0XjjF0l1WGRtK2vEhTZqCguQuAIZLep9l2+eeEmuxQQjjD3BlGQXY5xADR+l3t576UX+dxRkRtTjEu40l81Vw=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/token-providers": "3.1081.0", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-d/V0VRsz73i+PHhbult/tx0Y1+de1SNQVsXkcQCmpfeBq7uODy/RTxNsOLpT9ZVHxcRNzbQFuywLKC33fUMIxA=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.61", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-Bv4n3NOI6hPy+rmr6Bw9R6LnBVRkcp3ncj2E2IKSYJG+0UkysSitWMvbgndNvMxDw7gE1pQ/ErwkNceuKwj7zQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-f8SREKL6uQUYt8fwreiUgaAJQ31/M+UkWfJMDY/TBJC5FdHgNzxuvRFeZ9eMbnf2ydQ3p4pcKEhaLk35DDIPxw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.29", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/signature-v4-multi-region": "^3.996.38", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/fetch-http-handler": "^5.6.2", "@smithy/node-http-handler": "^4.9.2", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ot6v8J5W8P0w6ryyuIkXP1bHZHTlvwtn83mVCYaBE0GJ6tJX4vPSBx7M98w9O4wmmDruFsDBUMjhEHA+OosUFQ=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.38", "", { "dependencies": { "@aws-sdk/types": "^3.973.15", "@smithy/signature-v4": "^5.6.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1081.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.29", "@aws-sdk/nested-clients": "^3.997.29", "@aws-sdk/types": "^3.973.15", "@smithy/core": "^3.29.0", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-kduAeI6cL+zqwj3gjPh9LhuX7kBZ83msYxutavaR+UPm5K8J7iThJBvNRAsFNyWTji92CSU8dogUgvi9T0BehA=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.15", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.33", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@prisma/client": ["@prisma/client@6.19.3", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg=="], "@prisma/client": ["@prisma/client@6.19.3", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg=="],
@@ -37,6 +74,18 @@
"@prisma/get-platform": ["@prisma/get-platform@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3" } }, "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA=="], "@prisma/get-platform": ["@prisma/get-platform@6.19.3", "", { "dependencies": { "@prisma/debug": "6.19.3" } }, "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA=="],
"@smithy/core": ["@smithy/core@3.29.1", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.6", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.3", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.6.2", "", { "dependencies": { "@smithy/core": "^3.29.1", "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g=="],
"@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
@@ -45,6 +94,8 @@
"@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="], "@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
@@ -105,6 +156,8 @@
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+1
View File
@@ -13,6 +13,7 @@
"db:seed": "bun prisma/seed.ts" "db:seed": "bun prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1081.0",
"@hono/node-server": "^1.14.4", "@hono/node-server": "^1.14.4",
"@prisma/client": "^6.9.0", "@prisma/client": "^6.9.0",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
+40 -2
View File
@@ -1,5 +1,12 @@
import { config } from '../config' import { config } from '../config'
import { prisma } from '../prisma' import { prisma } from '../prisma'
import {
deletePublicFile,
isR2PublicFilesConfigured,
publicFileUrl,
uploadPublicFile,
userAvatarKey
} from '../storage/r2-public-files'
import { hashPassword, verifyPassword } from '../security/password' import { hashPassword, verifyPassword } from '../security/password'
import { redis } from '../redis' import { redis } from '../redis'
import { createOpaqueToken, hashToken } from '../security/tokens' import { createOpaqueToken, hashToken } from '../security/tokens'
@@ -385,6 +392,24 @@ export async function changeUserPassword(userId: string, currentPassword: string
} }
export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> { export async function updateUserProfileImage(userId: string, imageData: Uint8Array, mimeType: string): Promise<void> {
if (isR2PublicFilesConfigured()) {
await uploadPublicFile({
key: userAvatarKey(userId),
body: imageData,
contentType: mimeType
})
await prisma.user.update({
where: { id: userId },
data: {
profileImageData: null,
profileImageMime: mimeType,
profileImageUpdatedAt: new Date()
}
})
return
}
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
@@ -396,6 +421,8 @@ export async function updateUserProfileImage(userId: string, imageData: Uint8Arr
} }
export async function deleteUserProfileImage(userId: string): Promise<void> { export async function deleteUserProfileImage(userId: string): Promise<void> {
await deletePublicFile(userAvatarKey(userId)).catch(() => undefined)
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
@@ -406,7 +433,7 @@ export async function deleteUserProfileImage(userId: string): Promise<void> {
}) })
} }
export async function findUserProfileImage(userId: string): Promise<{ data: Buffer; mimeType: string; updatedAt: Date | null } | null> { export async function findUserProfileImage(userId: string): Promise<{ data?: Buffer; url?: string; mimeType: string; updatedAt: Date | null } | null> {
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { id: userId, isActive: true }, where: { id: userId, isActive: true },
select: { select: {
@@ -416,7 +443,18 @@ export async function findUserProfileImage(userId: string): Promise<{ data: Buff
} }
}) })
if (!user?.profileImageData || !user.profileImageMime) return null if (!user?.profileImageMime) return null
const storedUrl = publicFileUrl(userAvatarKey(userId))
if (!user.profileImageData && storedUrl) {
return {
url: storedUrl,
mimeType: user.profileImageMime,
updatedAt: user.profileImageUpdatedAt
}
}
if (!user.profileImageData) return null
return { return {
data: Buffer.from(user.profileImageData), data: Buffer.from(user.profileImageData),
+6 -3
View File
@@ -653,12 +653,13 @@ authRoutes.get('/me', authRequired, (c) => {
authRoutes.get('/profile/avatar', authRequired, async (c) => { authRoutes.get('/profile/avatar', authRequired, async (c) => {
const image = await findUserProfileImage(c.get('user').id) const image = await findUserProfileImage(c.get('user').id)
if (!image) return c.body(null, 404) if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType) c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store') c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data)) return c.body(new Uint8Array(image.data!))
}) })
authRoutes.get('/users/:userId/avatar', authRequired, async (c) => { authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
@@ -667,12 +668,13 @@ authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
const userId = c.req.param('userId') const userId = c.req.param('userId')
const image = await findUserProfileImage(userId) const image = await findUserProfileImage(userId)
if (!image) return c.body(null, 404) if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType) c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store') c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data)) return c.body(new Uint8Array(image.data!))
}) })
authRoutes.get('/users/avatar', authRequired, async (c) => { authRoutes.get('/users/avatar', authRequired, async (c) => {
@@ -686,12 +688,13 @@ authRoutes.get('/users/avatar', authRequired, async (c) => {
const image = await findUserProfileImage(user.id) const image = await findUserProfileImage(user.id)
if (!image) return c.body(null, 404) if (!image) return c.body(null, 404)
if (image.url) return c.redirect(image.url, 302)
c.header('content-type', image.mimeType) c.header('content-type', image.mimeType)
c.header('cache-control', 'no-store') c.header('cache-control', 'no-store')
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString()) if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
return c.body(new Uint8Array(image.data)) return c.body(new Uint8Array(image.data!))
}) })
authRoutes.put('/profile/avatar', authRequired, async (c) => { authRoutes.put('/profile/avatar', authRequired, async (c) => {
+10 -1
View File
@@ -39,5 +39,14 @@ export const config = {
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI ?? 'http://localhost:4100/auth/google/callback', googleRedirectUri: process.env.GOOGLE_REDIRECT_URI ?? 'http://localhost:4100/auth/google/callback',
launcherOAuthRedirectUri: process.env.LAUNCHER_OAUTH_REDIRECT_URI ?? 'autonomy-studio://auth/google/callback', launcherOAuthRedirectUri: process.env.LAUNCHER_OAUTH_REDIRECT_URI ?? 'autonomy-studio://auth/google/callback',
oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120) oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120),
r2Bucket: process.env.R2_BUCKET?.trim() ?? '',
r2PublicBaseUrl: process.env.R2_PUBLIC_BASE_URL?.trim() ?? '',
r2AccessKeyId: process.env.R2_ACCESS_KEY_ID?.trim() ?? '',
r2SecretAccessKey: process.env.R2_SECRET_ACCESS_KEY?.trim() ?? '',
r2AccountId: process.env.R2_ACCOUNT_ID?.trim() ?? '',
r2Endpoint: process.env.R2_ENDPOINT?.trim() ?? '',
r2Region: process.env.R2_REGION?.trim() || 'auto',
r2ForcePathStyle: process.env.R2_FORCE_PATH_STYLE !== 'false',
r2PublicFilePrefix: (process.env.R2_PUBLIC_FILE_PREFIX ?? 'studio-files/public').replace(/^\/+|\/+$/g, '')
} }
+1 -1
View File
@@ -18,7 +18,7 @@ app.use(
return config.corsOrigins.includes(origin) ? origin : null return config.corsOrigins.includes(origin) ? origin : null
}, },
allowHeaders: ['authorization', 'content-type'], allowHeaders: ['authorization', 'content-type'],
allowMethods: ['GET', 'POST', 'PUT', 'OPTIONS'], allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true credentials: true
}) })
) )
+60
View File
@@ -0,0 +1,60 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { config } from '../config'
let client: S3Client | null = null
export function isR2PublicFilesConfigured(): boolean {
return Boolean(config.r2Bucket && config.r2PublicBaseUrl && config.r2AccessKeyId && config.r2SecretAccessKey)
}
export function userAvatarKey(userId: string): string {
return `${config.r2PublicFilePrefix}/users/${userId}/avatar`
}
export function publicFileUrl(key: string): string | null {
if (!config.r2PublicBaseUrl || !key.startsWith(`${config.r2PublicFilePrefix}/`)) return null
return `${config.r2PublicBaseUrl.replace(/\/$/, '')}/${key.split('/').map(encodeURIComponent).join('/')}`
}
export async function uploadPublicFile(input: {
key: string
body: Uint8Array
contentType: string
}) {
if (!isR2PublicFilesConfigured()) return null
await getClient().send(new PutObjectCommand({
Bucket: config.r2Bucket,
Key: input.key,
Body: Buffer.from(input.body),
ContentLength: input.body.length,
ContentType: input.contentType
}))
return publicFileUrl(input.key)
}
export async function deletePublicFile(key: string) {
if (!isR2PublicFilesConfigured()) return
await getClient().send(new DeleteObjectCommand({
Bucket: config.r2Bucket,
Key: key
}))
}
function getClient() {
if (client) return client
client = new S3Client({
region: config.r2Region,
endpoint: config.r2Endpoint || `https://${config.r2AccountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.r2AccessKeyId,
secretAccessKey: config.r2SecretAccessKey
},
forcePathStyle: config.r2ForcePathStyle
})
return client
}