From 153638a208987c2346bb9f4990177009bfd4ecbc Mon Sep 17 00:00:00 2001 From: Tangles Date: Wed, 4 Mar 2026 17:16:22 +1100 Subject: [PATCH 01/13] try out new media storage format --- package.json | 1 + pnpm-lock.yaml | 325 ++++++++++++++++- .../[kind]/[mediaId]/[fileName]/route.ts | 27 +- src/app/share/[fileName]/route.ts | 42 ++- .../image/[imageId]/[fileName]/route.ts | 17 +- .../[kind]/[mediaId]/[fileName]/route.ts | 27 +- src/lib/media-storage.ts | 344 +++++++++++++++--- 7 files changed, 711 insertions(+), 72 deletions(-) diff --git a/package.json b/package.json index 79ef026..7b70617 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@aws-sdk/client-secrets-manager": "^3.954.0", "@aws-sdk/client-ssm": "^3.968.0", "@aws-sdk/credential-providers": "^3.969.0", + "@aws-sdk/s3-request-presigner": "^3.986.0", "@energiz3r/icon-library": "^0.0.6", "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f468d01..b3fb0bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@aws-sdk/credential-providers': specifier: ^3.969.0 version: 3.985.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.986.0 + version: 3.986.0 '@energiz3r/icon-library': specifier: ^0.0.6 version: 0.0.6(@types/react@19.2.13) @@ -259,6 +262,10 @@ packages: resolution: {integrity: sha512-hFiezao0lCEddPhSQEF6vCu+TepUN3edKxWYbswMoH87XpUvHJmFVX5+zttj4qi33saGiuOaJciswWcN6YSA9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.16': + resolution: {integrity: sha512-Nasoyb5K4jfvncTKQyA13q55xHoz9as01NVYP05B0Kzux/X5UhMn3qXsZDyWOSXkfSCAIrMBKmVVWbI0vUapdQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.973.7': resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} engines: {node: '>=20.0.0'} @@ -379,6 +386,10 @@ packages: resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.16': + resolution: {integrity: sha512-U4K1rqyJYvT/zgTI3+rN+MToa51dFnnq1VSsVJuJWPNEKcEnuZVqf7yTpkJJMkYixVW5TTi1dgupd+nmJ0JyWw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.7': resolution: {integrity: sha512-VtZ7tMIw18VzjG+I6D6rh2eLkJfTtByiFoCIauGDtTTPBEUMQUiGaJ/zZrPlCY6BsvLLeFKz3+E5mntgiOWmIg==} engines: {node: '>=20.0.0'} @@ -407,6 +418,10 @@ packages: resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.986.0': + resolution: {integrity: sha512-+yopxtoXwRXZ2Ai9H4GzkN+T2D07sGrURYcm7Eh2OQe3p+Ys/3VrR6UrzILssaJGYtR2vQqVKnGJBHVYqaM1EQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.986.0': resolution: {integrity: sha512-Upw+rw7wCH93E6QWxqpAqJLrUmJYVUAWrk4tCOBnkeuwzGERZvJFL5UQ6TAJFj9T18Ih+vNFaACh8J5aP4oTBw==} engines: {node: '>=20.0.0'} @@ -423,6 +438,10 @@ packages: resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.4': + resolution: {integrity: sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-arn-parser@3.972.2': resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} @@ -443,6 +462,10 @@ packages: resolution: {integrity: sha512-EhSBGWSGQ6Jcbt6jRyX1/0EV7rf+6RGbIIskN0MTtHk0k8uj5FAa1FZhLf+1ETfnDTy/BT39t5IUOQiZL5X1jQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.6': + resolution: {integrity: sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.4': resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} engines: {node: '>=20.0.0'} @@ -476,6 +499,10 @@ packages: resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.9': + resolution: {integrity: sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.3': resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} engines: {node: '>=18.0.0'} @@ -1678,6 +1705,10 @@ packages: '@rushstack/eslint-patch@1.15.0': resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} + '@smithy/abort-controller@4.2.10': + resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} + engines: {node: '>=18.0.0'} + '@smithy/abort-controller@4.2.8': resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} engines: {node: '>=18.0.0'} @@ -1710,6 +1741,10 @@ packages: resolution: {integrity: sha512-IH7G3hWxUhd2Z6HtvjZ1EiyDBCRYRr2sngOB9KUWf96XQ8JP2O5ascUH6TouW5YCIMFaVnKADEscM/vUfI3TvA==} engines: {node: '>=18.0.0'} + '@smithy/core@3.23.7': + resolution: {integrity: sha512-/+ldRdtiO5Cb26afAZOG1FZM0x7D4AYdjpyOv2OScJw+4C7X+OLdRnNKF5UyUE0VpPgSKr3rnF/kvprRA4h2kg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.8': resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} engines: {node: '>=18.0.0'} @@ -1742,6 +1777,10 @@ packages: resolution: {integrity: sha512-qF4EcrEtEf2P6f2kGGuSVe1lan26cn7PsWJBC3vZJ6D16Fm5FSN06udOMVoW6hjzQM3W7VDFwtyUG2szQY50dA==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.12': + resolution: {integrity: sha512-muS5tFw+A/uo+U+yig06vk1776UFM+aAp9hFM8efI4ZcHhTcgv6NTeK4x7ltHeMPBwnhEjcf0MULTyxNkSNxDw==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.3.9': resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} engines: {node: '>=18.0.0'} @@ -1790,6 +1829,10 @@ packages: resolution: {integrity: sha512-4OS3TP3IWZysT8KlSG/UwfKdelJmuQ2CqVNfrkjm2Rsm146/DuSTfXiD1ulgWpp9L6lJmPYfWTp7/m4b4dQSdQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.4.21': + resolution: {integrity: sha512-CoVGZaqIC0tEjz0ga3ciwCMA5fd/4lIOwO2wx0fH+cTi1zxSFZnMJbIiIF9G1d4vRSDyTupDrpS3FKBBJGkRZg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-retry@4.4.30': resolution: {integrity: sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==} engines: {node: '>=18.0.0'} @@ -1802,10 +1845,18 @@ packages: resolution: {integrity: sha512-BQsdoi7ma4siJAzD0S6MedNPhiMcTdTLUqEUjrHeT1TJppBKWnwqySg34Oh/uGRhJeBd1sAH2t5tghBvcyD6tw==} engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.11': + resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-serde@4.2.9': resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.10': + resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} + engines: {node: '>=18.0.0'} + '@smithy/middleware-stack@4.2.8': resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} engines: {node: '>=18.0.0'} @@ -1814,6 +1865,10 @@ packages: resolution: {integrity: sha512-pid7ksBr7nm0X/3paIlGo9Fh3UK1pQ5yH0007tBmdkVvv+AsBZAOzC2dmLhlzDWKkSB+ZCiiyDArjAW3klkbMg==} engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.10': + resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} + engines: {node: '>=18.0.0'} + '@smithy/node-config-provider@4.3.8': resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} @@ -1826,10 +1881,18 @@ packages: resolution: {integrity: sha512-kQNJFwzYA9y+Fj3h9t1ToXYOJBobwUVEc6/WX45urJXyErgG0WOsres8Se8BAiFCMe8P06OkzRgakv7bQ5S+6Q==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.13': + resolution: {integrity: sha512-o8CP8w6tlUA0lk+Qfwm6Ed0jCWk3bEY6iBOJjdBaowbXKCSClk8zIHQvUL6RUZMvuNafF27cbRCMYqw6O1v4aA==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.4.9': resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.10': + resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.8': resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} engines: {node: '>=18.0.0'} @@ -1838,6 +1901,10 @@ packages: resolution: {integrity: sha512-ibHwLxq4KlbfueoNxMNrZkG+O7V/5XKrewhDGYn0p9DYKCsdsofuWHKdX3QW4zHlAUfLStqdCUSDi/q/9WSjwA==} engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.10': + resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} + engines: {node: '>=18.0.0'} + '@smithy/protocol-http@5.3.8': resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} engines: {node: '>=18.0.0'} @@ -1846,6 +1913,10 @@ packages: resolution: {integrity: sha512-PRy4yZqsKI3Eab8TLc16Dj2NzC4dnw/8E95+++Jc+wwlkjBpAq3tNLqkLHMmSvDfxKQ+X5PmmCYt+rM/GcMKPA==} engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.10': + resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-builder@4.2.8': resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} engines: {node: '>=18.0.0'} @@ -1854,6 +1925,10 @@ packages: resolution: {integrity: sha512-/AIDaq0+ehv+QfeyAjCUFShwHIt+FA1IodsV/2AZE5h4PUZcQYv5sjmy9V67UWfsBoTjOPKUFYSRfGoNW9T2UQ==} engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.10': + resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} + engines: {node: '>=18.0.0'} + '@smithy/querystring-parser@4.2.8': resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} engines: {node: '>=18.0.0'} @@ -1878,6 +1953,14 @@ packages: resolution: {integrity: sha512-tA5Cm11BHQCk/67y6VPIWydLh/pMY90jqOEWIr/2VAzTOoDwGpwp0C/AuHBc3/xWSOA5m5PXLN+lIOrsnTm/PQ==} engines: {node: '>=18.0.0'} + '@smithy/shared-ini-file-loader@4.4.5': + resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.10': + resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.3.8': resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} @@ -1890,6 +1973,10 @@ packages: resolution: {integrity: sha512-gQP2J3qB/Wmc26gdmB8gA6zq2o2spG5sEU3o7TaTATBJEk29sYGWdEFoGEy91BczSpifTo0DQhVYjZXBEVcrpA==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.1': + resolution: {integrity: sha512-Xf9UFHlAihewfkmLNZ6I/Ek6kcYBKoU3cbRS9Z4q++9GWoW0YFbAHs7wMbuXm+nGuKHZ5OKheZMuDdaWPv8DJw==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.12.0': resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} engines: {node: '>=18.0.0'} @@ -1898,6 +1985,14 @@ packages: resolution: {integrity: sha512-ow30Ze/DD02KH2p0eMyIF2+qJzGyNb0kFrnTRtPpuOkQ4hrgvLdaU4YC6r/K8aOrCML4FH0Cmm0aI4503L1Hwg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.10': + resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.8': resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} engines: {node: '>=18.0.0'} @@ -1978,6 +2073,10 @@ packages: resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.10': + resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} + engines: {node: '>=18.0.0'} + '@smithy/util-middleware@4.2.8': resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} engines: {node: '>=18.0.0'} @@ -2002,6 +2101,10 @@ packages: resolution: {integrity: sha512-IOBEiJTOltSx6MAfwkx/GSVM8/UCJxdtw13haP5OEL543lb1DN6TAypsxv+qcj4l/rKcpapbS6zK9MQGBOhoaA==} engines: {node: '>=18.0.0'} + '@smithy/util-stream@4.5.16': + resolution: {integrity: sha512-c7awZV6cxY0czgDDSr+Bz0XfRtg8AwW2BWhrHhLJISrpmwv8QzA2qzTllWyMVNdy1+UJr9vCm29hzuh3l8TTFw==} + engines: {node: '>=18.0.0'} + '@smithy/util-uri-escape@4.2.0': resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} @@ -3060,6 +3163,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-builder@1.0.0: + resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} + fast-xml-parser@5.3.4: resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true @@ -3068,6 +3174,10 @@ packages: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true + fast-xml-parser@5.4.1: + resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4996,6 +5106,22 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 + '@aws-sdk/core@3.973.16': + dependencies: + '@aws-sdk/types': 3.973.4 + '@aws-sdk/xml-builder': 3.972.9 + '@smithy/core': 3.23.7 + '@smithy/node-config-provider': 4.3.10 + '@smithy/property-provider': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.1 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@aws-sdk/core@3.973.7': dependencies: '@aws-sdk/types': 3.973.1 @@ -5344,6 +5470,23 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.16': + dependencies: + '@aws-sdk/core': 3.973.16 + '@aws-sdk/types': 3.973.4 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/core': 3.23.7 + '@smithy/node-config-provider': 4.3.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/smithy-client': 4.12.1 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.16 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.7': dependencies: '@aws-sdk/core': 3.973.7 @@ -5481,13 +5624,24 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.986.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.986.0 + '@aws-sdk/types': 3.973.4 + '@aws-sdk/util-format-url': 3.972.6 + '@smithy/middleware-endpoint': 4.4.21 + '@smithy/protocol-http': 5.3.10 + '@smithy/smithy-client': 4.12.1 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.986.0': dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.7 - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 + '@aws-sdk/middleware-sdk-s3': 3.972.16 + '@aws-sdk/types': 3.973.4 + '@smithy/protocol-http': 5.3.10 + '@smithy/signature-v4': 5.3.10 + '@smithy/types': 4.13.0 tslib: 2.8.1 '@aws-sdk/token-providers@3.985.0': @@ -5519,6 +5673,11 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@aws-sdk/types@3.973.4': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-arn-parser@3.972.2': dependencies: tslib: 2.8.1 @@ -5555,6 +5714,13 @@ snapshots: '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.6': + dependencies: + '@aws-sdk/types': 3.973.4 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.4': dependencies: tslib: 2.8.1 @@ -5594,6 +5760,12 @@ snapshots: fast-xml-parser: 5.3.6 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.9': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.4.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.3': {} '@babel/runtime@7.28.6': {} @@ -6567,6 +6739,11 @@ snapshots: '@rushstack/eslint-patch@1.15.0': {} + '@smithy/abort-controller@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/abort-controller@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -6630,6 +6807,19 @@ snapshots: '@smithy/uuid': 1.1.1 tslib: 2.8.1 + '@smithy/core@3.23.7': + dependencies: + '@smithy/middleware-serde': 4.2.11 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-body-length-browser': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-stream': 4.5.16 + '@smithy/util-utf8': 4.2.1 + '@smithy/uuid': 1.1.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.8': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -6684,6 +6874,14 @@ snapshots: '@smithy/util-base64': 4.3.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.12': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.3.9': dependencies: '@smithy/protocol-http': 5.3.8 @@ -6763,6 +6961,17 @@ snapshots: '@smithy/util-middleware': 4.2.9 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.4.21': + dependencies: + '@smithy/core': 3.23.7 + '@smithy/middleware-serde': 4.2.11 + '@smithy/node-config-provider': 4.3.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.10 + '@smithy/util-middleware': 4.2.10 + tslib: 2.8.1 + '@smithy/middleware-retry@4.4.30': dependencies: '@smithy/node-config-provider': 4.3.8 @@ -6793,12 +7002,23 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/middleware-serde@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-serde@4.2.9': dependencies: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/middleware-stack@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/middleware-stack@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -6809,6 +7029,13 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/node-config-provider@4.3.10': + dependencies: + '@smithy/property-provider': 4.2.10 + '@smithy/shared-ini-file-loader': 4.4.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-config-provider@4.3.8': dependencies: '@smithy/property-provider': 4.2.8 @@ -6831,6 +7058,14 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.4.13': + dependencies: + '@smithy/abort-controller': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/querystring-builder': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/node-http-handler@4.4.9': dependencies: '@smithy/abort-controller': 4.2.8 @@ -6839,6 +7074,11 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/property-provider@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -6849,6 +7089,11 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/protocol-http@5.3.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/protocol-http@5.3.8': dependencies: '@smithy/types': 4.12.0 @@ -6859,6 +7104,12 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/querystring-builder@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.1 + tslib: 2.8.1 + '@smithy/querystring-builder@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -6871,6 +7122,11 @@ snapshots: '@smithy/util-uri-escape': 4.2.1 tslib: 2.8.1 + '@smithy/querystring-parser@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/querystring-parser@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -6899,6 +7155,22 @@ snapshots: '@smithy/types': 4.12.1 tslib: 2.8.1 + '@smithy/shared-ini-file-loader@4.4.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.10': + dependencies: + '@smithy/is-array-buffer': 4.2.1 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-middleware': 4.2.10 + '@smithy/util-uri-escape': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.3.8': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -6930,6 +7202,16 @@ snapshots: '@smithy/util-stream': 4.5.14 tslib: 2.8.1 + '@smithy/smithy-client@4.12.1': + dependencies: + '@smithy/core': 3.23.7 + '@smithy/middleware-endpoint': 4.4.21 + '@smithy/middleware-stack': 4.2.10 + '@smithy/protocol-http': 5.3.10 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.16 + tslib: 2.8.1 + '@smithy/types@4.12.0': dependencies: tslib: 2.8.1 @@ -6938,6 +7220,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.13.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.10': + dependencies: + '@smithy/querystring-parser': 4.2.10 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/url-parser@4.2.8': dependencies: '@smithy/querystring-parser': 4.2.8 @@ -7051,6 +7343,11 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/util-middleware@4.2.10': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + '@smithy/util-middleware@4.2.8': dependencies: '@smithy/types': 4.12.0 @@ -7095,6 +7392,17 @@ snapshots: '@smithy/util-utf8': 4.2.1 tslib: 2.8.1 + '@smithy/util-stream@4.5.16': + dependencies: + '@smithy/fetch-http-handler': 5.3.12 + '@smithy/node-http-handler': 4.4.13 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.1 + '@smithy/util-buffer-from': 4.2.1 + '@smithy/util-hex-encoding': 4.2.1 + '@smithy/util-utf8': 4.2.1 + tslib: 2.8.1 + '@smithy/util-uri-escape@4.2.0': dependencies: tslib: 2.8.1 @@ -8253,6 +8561,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-builder@1.0.0: {} + fast-xml-parser@5.3.4: dependencies: strnum: 2.1.2 @@ -8261,6 +8571,11 @@ snapshots: dependencies: strnum: 2.1.2 + fast-xml-parser@5.4.1: + dependencies: + fast-xml-builder: 1.0.0 + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 diff --git a/src/app/media/[kind]/[mediaId]/[fileName]/route.ts b/src/app/media/[kind]/[mediaId]/[fileName]/route.ts index 43ee298..2cf496c 100644 --- a/src/app/media/[kind]/[mediaId]/[fileName]/route.ts +++ b/src/app/media/[kind]/[mediaId]/[fileName]/route.ts @@ -4,10 +4,12 @@ import { authOptions } from "@/lib/auth"; import { getMediaForUser, type MediaKind } from "@/lib/media-store"; import { contentTypeForExt } from "@/lib/media-types"; import { - getMediaBuffer, - getMediaBufferRange, getMediaBufferSize, + getMediaSignedUrl, + getMediaRangeStream, + getMediaStream, pendingVideoPreviewPng, + usesS3StorageBackend, } from "@/lib/media-storage"; export const runtime = "nodejs"; @@ -102,6 +104,19 @@ export async function GET( requestedSize === "original" && (parsedKind === "video" || (parsedKind === "other" && (media.mimeType ?? "").toLowerCase().startsWith("audio/"))); + if (usesS3StorageBackend()) { + const responseExt = + requestedSize === "original" ? media.ext : parsedKind === "image" ? media.ext : "png"; + const signedUrl = await getMediaSignedUrl({ + kind: parsedKind, + baseName: media.baseName, + ext: media.ext, + size: requestedSize, + uploadedAt: new Date(media.uploadedAt), + responseContentType: contentTypeForExt(responseExt), + }); + return Response.redirect(signedUrl, 307); + } if (isRangeStreamableOriginal) { const uploadedAt = new Date(media.uploadedAt); const total = await getMediaBufferSize({ @@ -123,7 +138,7 @@ export async function GET( }, }); } - const data = await getMediaBufferRange({ + const stream = await getMediaRangeStream({ kind: parsedKind, baseName: media.baseName, ext: media.ext, @@ -132,7 +147,7 @@ export async function GET( start: byteRange.start, end: byteRange.end, }); - return new Response(new Uint8Array(data), { + return new Response(stream, { status: 206, headers: { "Content-Type": contentTypeForExt(media.ext), @@ -147,7 +162,7 @@ export async function GET( }); } } - const data = await getMediaBuffer({ + const stream = await getMediaStream({ kind: parsedKind, baseName: media.baseName, ext: media.ext, @@ -156,7 +171,7 @@ export async function GET( }); const responseExt = requestedSize === "original" ? media.ext : parsedKind === "image" ? media.ext : "png"; - return new Response(new Uint8Array(data), { + return new Response(stream, { headers: { "Content-Type": contentTypeForExt(responseExt), ...(isRangeStreamableOriginal ? { "Accept-Ranges": "bytes" } : {}), diff --git a/src/app/share/[fileName]/route.ts b/src/app/share/[fileName]/route.ts index ea4efc0..3d72bb4 100644 --- a/src/app/share/[fileName]/route.ts +++ b/src/app/share/[fileName]/route.ts @@ -1,10 +1,12 @@ import type { NextRequest } from "next/server"; import { getAlbumShareByCode, getImage, getShareByCode } from "@/lib/metadata-store"; import { - getMediaBuffer, - getMediaBufferRange, getMediaBufferSize, + getMediaSignedUrl, + getMediaRangeStream, + getMediaStream, pendingVideoPreviewPng, + usesS3StorageBackend, } from "@/lib/media-storage"; import { getSharedMediaByCode, getSharedMediaByCodeAndExt } from "@/lib/media-store"; import { contentTypeForExt } from "@/lib/media-types"; @@ -126,14 +128,25 @@ export async function GET( : parsed.size === "x640" ? "lg" : parsed.size; - const data = await getMediaBuffer({ + if (usesS3StorageBackend()) { + const signedUrl = await getMediaSignedUrl({ + kind: "image", + baseName: image.baseName, + ext: image.ext, + size: imageRequestedSize, + uploadedAt: new Date(image.uploadedAt), + responseContentType: contentTypeForExt(image.ext), + }); + return withPublicImageCors(Response.redirect(signedUrl, 307)); + } + const stream = await getMediaStream({ kind: "image", baseName: image.baseName, ext: image.ext, size: imageRequestedSize, uploadedAt: new Date(image.uploadedAt), }); - return withPublicImageCors(new Response(new Uint8Array(data), { headers: publicCacheHeaders(image.ext) })); + return withPublicImageCors(new Response(stream, { headers: publicCacheHeaders(image.ext) })); } } @@ -160,6 +173,19 @@ export async function GET( requestedSize === "original" && (media.kind === "video" || (media.kind === "other" && (media.mimeType ?? "").toLowerCase().startsWith("audio/"))); + if (usesS3StorageBackend()) { + const responseExt = + requestedSize === "original" ? media.ext : media.kind === "image" ? media.ext : "png"; + const signedUrl = await getMediaSignedUrl({ + kind: media.kind, + baseName: media.baseName, + ext: media.ext, + size: requestedSize, + uploadedAt: new Date(media.uploadedAt), + responseContentType: contentTypeForExt(responseExt), + }); + return withPublicImageCors(Response.redirect(signedUrl, 307)); + } if (isRangeStreamableOriginal) { const uploadedAt = new Date(media.uploadedAt); const total = await getMediaBufferSize({ @@ -183,7 +209,7 @@ export async function GET( }), ); } - const data = await getMediaBufferRange({ + const stream = await getMediaRangeStream({ kind: media.kind, baseName: media.baseName, ext: media.ext, @@ -196,11 +222,11 @@ export async function GET( headers.set("Content-Range", `bytes ${byteRange.start}-${byteRange.end}/${total}`); headers.set("Content-Length", String(byteRange.end - byteRange.start + 1)); headers.set("Accept-Ranges", "bytes"); - return withPublicImageCors(new Response(new Uint8Array(data), { status: 206, headers })); + return withPublicImageCors(new Response(stream, { status: 206, headers })); } } - const data = await getMediaBuffer({ + const stream = await getMediaStream({ kind: media.kind, baseName: media.baseName, ext: media.ext, @@ -213,7 +239,7 @@ export async function GET( if (isRangeStreamableOriginal) { headers.set("Accept-Ranges", "bytes"); } - return withPublicImageCors(new Response(new Uint8Array(data), { headers })); + return withPublicImageCors(new Response(stream, { headers })); } catch { if (!parsed) { return withPublicImageCors(new Response("Service temporarily unavailable.", { status: 503 })); diff --git a/src/app/share/album/[shareId]/image/[imageId]/[fileName]/route.ts b/src/app/share/album/[shareId]/image/[imageId]/[fileName]/route.ts index 0308341..711ed0a 100644 --- a/src/app/share/album/[shareId]/image/[imageId]/[fileName]/route.ts +++ b/src/app/share/album/[shareId]/image/[imageId]/[fileName]/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { getAlbumShareById, getImage } from "@/lib/metadata-store"; -import { getMediaBuffer } from "@/lib/media-storage"; +import { getMediaSignedUrl, getMediaStream, usesS3StorageBackend } from "@/lib/media-storage"; import { unavailableImageResponse } from "@/lib/unavailable-image"; export const runtime = "nodejs"; @@ -65,14 +65,25 @@ export async function GET( return unavailableImageResponse(parsed.ext); } - const data = await getMediaBuffer({ + if (usesS3StorageBackend()) { + const signedUrl = await getMediaSignedUrl({ + kind: "image", + baseName: image.baseName, + ext: image.ext, + size: parsed.size === "x640" ? "lg" : parsed.size, + uploadedAt: new Date(image.uploadedAt), + responseContentType: contentTypeForExt(image.ext), + }); + return Response.redirect(signedUrl, 307); + } + const data = await getMediaStream({ kind: "image", baseName: image.baseName, ext: image.ext, size: parsed.size === "x640" ? "lg" : parsed.size, uploadedAt: new Date(image.uploadedAt), }); - return new Response(new Uint8Array(data), { + return new Response(data, { headers: publicCacheHeaders(image.ext), }); } catch { diff --git a/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts b/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts index a30dcf8..993ec11 100644 --- a/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts +++ b/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts @@ -3,10 +3,12 @@ import { getAlbumShareById } from "@/lib/metadata-store"; import { getMedia, type MediaKind } from "@/lib/media-store"; import { contentTypeForExt } from "@/lib/media-types"; import { - getMediaBuffer, - getMediaBufferRange, getMediaBufferSize, + getMediaSignedUrl, + getMediaRangeStream, + getMediaStream, pendingVideoPreviewPng, + usesS3StorageBackend, } from "@/lib/media-storage"; import { unavailableImageResponse } from "@/lib/unavailable-image"; @@ -115,6 +117,19 @@ export async function GET( requestedSize === "original" && (media.kind === "video" || (media.kind === "other" && (media.mimeType ?? "").toLowerCase().startsWith("audio/"))); + if (usesS3StorageBackend()) { + const responseExt = + requestedSize === "original" ? media.ext : media.kind === "image" ? media.ext : "png"; + const signedUrl = await getMediaSignedUrl({ + kind: media.kind, + baseName: media.baseName, + ext: media.ext, + size: requestedSize, + uploadedAt: new Date(media.uploadedAt), + responseContentType: contentTypeForExt(responseExt), + }); + return withPublicCors(Response.redirect(signedUrl, 307)); + } if (isRangeStreamableOriginal) { const uploadedAt = new Date(media.uploadedAt); @@ -139,7 +154,7 @@ export async function GET( }), ); } - const data = await getMediaBufferRange({ + const stream = await getMediaRangeStream({ kind: media.kind, baseName: media.baseName, ext: media.ext, @@ -152,11 +167,11 @@ export async function GET( headers.set("Content-Range", `bytes ${byteRange.start}-${byteRange.end}/${total}`); headers.set("Content-Length", String(byteRange.end - byteRange.start + 1)); headers.set("Accept-Ranges", "bytes"); - return withPublicCors(new Response(new Uint8Array(data), { status: 206, headers })); + return withPublicCors(new Response(stream, { status: 206, headers })); } } - const data = await getMediaBuffer({ + const stream = await getMediaStream({ kind: media.kind, baseName: media.baseName, ext: media.ext, @@ -169,7 +184,7 @@ export async function GET( if (isRangeStreamableOriginal) { headers.set("Accept-Ranges", "bytes"); } - return withPublicCors(new Response(new Uint8Array(data), { headers })); + return withPublicCors(new Response(stream, { headers })); } catch { return withPublicCors(await unavailableImageResponse(parsed.ext)); } diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index 10da367..119c265 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -1,7 +1,8 @@ import path from "path"; import os from "os"; -import { promises as fs } from "fs"; -import { execFile } from "child_process"; +import { createReadStream, promises as fs } from "fs"; +import { execFile, spawn } from "child_process"; +import { Readable } from "stream"; import { promisify } from "util"; import sharp from "sharp"; import { @@ -12,6 +13,7 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { CODE_EXTENSIONS, CSV_EXTENSIONS, @@ -49,6 +51,96 @@ const s3Client = }) : null; const execFileAsync = promisify(execFile); +const MEDIA_DIRECT_URL_TTL_SECONDS = Number.parseInt( + process.env.MEDIA_DIRECT_URL_TTL_SECONDS ?? "120", + 10, +); + +function toWebReadableStream(body: unknown): ReadableStream { + if (!body) { + throw new Error("Storage response body is empty."); + } + if (typeof (body as { transformToWebStream?: unknown }).transformToWebStream === "function") { + return (body as { transformToWebStream: () => ReadableStream }).transformToWebStream(); + } + if (body instanceof Readable) { + return Readable.toWeb(body) as ReadableStream; + } + if (typeof (body as { getReader?: unknown }).getReader === "function") { + return body as ReadableStream; + } + if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") { + const iterator = (body as AsyncIterable)[Symbol.asyncIterator](); + return new ReadableStream({ + async pull(controller) { + const next = await iterator.next(); + if (next.done) { + controller.close(); + return; + } + controller.enqueue(next.value); + }, + async cancel() { + if (typeof iterator.return === "function") { + await iterator.return(); + } + }, + }); + } + throw new Error("Unsupported storage response stream type."); +} + +function isAsyncIterableUint8Array(value: unknown): value is AsyncIterable { + return ( + typeof value === "object" && + value !== null && + typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function" + ); +} + +function webReaderToAsyncIterable(reader: { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + releaseLock?: () => void; +}): AsyncIterable { + return { + [Symbol.asyncIterator]() { + return { + async next() { + const result = await reader.read(); + if (result.done) { + return { done: true, value: undefined as Uint8Array | undefined }; + } + return { done: false, value: result.value ?? new Uint8Array() }; + }, + async return() { + if (typeof reader.releaseLock === "function") { + reader.releaseLock(); + } + return { done: true, value: undefined as Uint8Array | undefined }; + }, + }; + }, + }; +} + +async function readWebStreamToBuffer(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Buffer[] = []; + try { + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + if (result.value) { + chunks.push(Buffer.from(result.value)); + } + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks); +} function datePathParts(uploadedAt: Date): { year: string; month: string; day: string } { return { @@ -69,6 +161,28 @@ function buildStorageKey( return path.posix.join("uploads", year, month, day, kind, size, `${baseName}.${ext}`); } +function mediaDirectUrlTtlSeconds(): number { + if (!Number.isFinite(MEDIA_DIRECT_URL_TTL_SECONDS) || MEDIA_DIRECT_URL_TTL_SECONDS <= 0) { + return 120; + } + return Math.max(30, Math.min(900, MEDIA_DIRECT_URL_TTL_SECONDS)); +} + +function mediaStorageKey(input: { + kind: "image" | "video" | "document" | "other"; + baseName: string; + ext: string; + size: MediaSize; + uploadedAt: Date; +}): string { + const requestedExt = input.kind === "image" || input.size === "original" ? input.ext : "png"; + return buildStorageKey(input.kind, input.baseName, requestedExt, input.size, input.uploadedAt); +} + +export function usesS3StorageBackend(): boolean { + return STORAGE_BACKEND === "s3" && Boolean(s3Client && S3_BUCKET); +} + function absolutePathForKey(key: string): string { return path.join(DATA_DIR, key); } @@ -154,12 +268,7 @@ async function readKey(key: string): Promise { Key: key, }), ); - const chunks: Buffer[] = []; - const stream = response.Body as AsyncIterable; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); + return readWebStreamToBuffer(toWebReadableStream(response.Body)); } return fs.readFile(absolutePathForKey(key)); } @@ -193,12 +302,7 @@ async function readKeyRange(key: string, start: number, end: number): Promise; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)); - } - return Buffer.concat(chunks); + return readWebStreamToBuffer(toWebReadableStream(response.Body)); } const length = end - start + 1; const handle = await fs.open(absolutePathForKey(key), "r"); @@ -211,6 +315,43 @@ async function readKeyRange(key: string, start: number, end: number): Promise> { + if (STORAGE_BACKEND === "s3") { + if (!s3Client || !S3_BUCKET) { + throw new Error("S3 is not configured."); + } + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + }), + ); + return toWebReadableStream(response.Body); + } + return Readable.toWeb(createReadStream(absolutePathForKey(key))) as ReadableStream; +} + +async function readKeyRangeStream( + key: string, + start: number, + end: number, +): Promise> { + if (STORAGE_BACKEND === "s3") { + if (!s3Client || !S3_BUCKET) { + throw new Error("S3 is not configured."); + } + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + Range: `bytes=${start}-${end}`, + }), + ); + return toWebReadableStream(response.Body); + } + return Readable.toWeb(createReadStream(absolutePathForKey(key), { start, end })) as ReadableStream; +} + function asPreviewPng(text: string): Promise { const svg = ` @@ -335,34 +476,105 @@ async function tryGenerateDocumentPreview( return null; } -async function tryGenerateVideoPreview(originalVideoBuffer: Buffer): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tanglepic-video-")); - const inputPath = path.join(tmpDir, "input-video"); - const outputPath = path.join(tmpDir, "preview.png"); - try { - await fs.writeFile(inputPath, originalVideoBuffer); - await execFileAsync( +async function tryGenerateVideoPreviewFromStream(input: NodeJS.ReadableStream): Promise { + return new Promise((resolve) => { + const ffmpeg = spawn( "ffmpeg", [ - "-y", + "-hide_banner", + "-loglevel", + "error", "-ss", "00:00:01", "-i", - inputPath, + "pipe:0", "-frames:v", "1", "-vf", "scale='min(1024,iw)':-2", - outputPath, + "-f", + "image2pipe", + "-vcodec", + "png", + "pipe:1", ], - { timeout: 30_000 }, + { stdio: ["pipe", "pipe", "pipe"] }, ); - return await fs.readFile(outputPath); - } catch { - return null; - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + const chunks: Buffer[] = []; + let stderr = ""; + const timeout = setTimeout(() => { + ffmpeg.kill("SIGKILL"); + }, 30_000); + + ffmpeg.stdout.on("data", (chunk: Buffer) => { + chunks.push(Buffer.from(chunk)); + }); + ffmpeg.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + ffmpeg.once("error", () => { + clearTimeout(timeout); + resolve(null); + }); + ffmpeg.once("close", (code) => { + clearTimeout(timeout); + if (code === 0 && chunks.length > 0) { + resolve(Buffer.concat(chunks)); + return; + } + if (stderr.length > 0) { + // eslint-disable-next-line no-console + console.warn(`ffmpeg thumbnail generation failed: ${stderr}`); + } + resolve(null); + }); + input.once("error", () => { + ffmpeg.kill("SIGKILL"); + clearTimeout(timeout); + resolve(null); + }); + ffmpeg.stdin.on("error", () => { + // ignore broken pipe; close handler resolves outcome. + }); + input.pipe(ffmpeg.stdin); + }); +} + +async function openKeyNodeStream(key: string): Promise { + if (STORAGE_BACKEND === "s3") { + if (!s3Client || !S3_BUCKET) { + throw new Error("S3 is not configured."); + } + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + }), + ); + const body = response.Body; + if (!body) { + throw new Error("Storage response body is empty."); + } + if (body instanceof Readable) { + return body; + } + if (typeof (body as { getReader?: unknown }).getReader === "function") { + const reader = ( + body as { + getReader: () => { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + releaseLock?: () => void; + }; + } + ).getReader(); + return Readable.from(webReaderToAsyncIterable(reader)); + } + if (isAsyncIterableUint8Array(body)) { + return Readable.from(body); + } + throw new Error("Unsupported storage response stream type."); } + return createReadStream(absolutePathForKey(key)); } export async function pendingVideoPreviewPng(size: Exclude): Promise { @@ -611,8 +823,7 @@ export async function getMediaBuffer(input: { size: MediaSize; uploadedAt: Date; }): Promise { - const requestedExt = input.kind === "image" || input.size === "original" ? input.ext : "png"; - const key = buildStorageKey(input.kind, input.baseName, requestedExt, input.size, input.uploadedAt); + const key = mediaStorageKey(input); return await readKey(key); } @@ -623,8 +834,7 @@ export async function getMediaBufferSize(input: { size: MediaSize; uploadedAt: Date; }): Promise { - const requestedExt = input.kind === "image" || input.size === "original" ? input.ext : "png"; - const key = buildStorageKey(input.kind, input.baseName, requestedExt, input.size, input.uploadedAt); + const key = mediaStorageKey(input); return getKeySize(key); } @@ -637,24 +847,70 @@ export async function getMediaBufferRange(input: { start: number; end: number; }): Promise { - const requestedExt = input.kind === "image" || input.size === "original" ? input.ext : "png"; - const key = buildStorageKey(input.kind, input.baseName, requestedExt, input.size, input.uploadedAt); + const key = mediaStorageKey(input); return readKeyRange(key, input.start, input.end); } +export async function getMediaStream(input: { + kind: "image" | "video" | "document" | "other"; + baseName: string; + ext: string; + size: MediaSize; + uploadedAt: Date; +}): Promise> { + const key = mediaStorageKey(input); + return readKeyStream(key); +} + +export async function getMediaRangeStream(input: { + kind: "image" | "video" | "document" | "other"; + baseName: string; + ext: string; + size: MediaSize; + uploadedAt: Date; + start: number; + end: number; +}): Promise> { + const key = mediaStorageKey(input); + return readKeyRangeStream(key, input.start, input.end); +} + +export async function getMediaSignedUrl(input: { + kind: "image" | "video" | "document" | "other"; + baseName: string; + ext: string; + size: MediaSize; + uploadedAt: Date; + responseContentType?: string; +}): Promise { + if (!s3Client || !S3_BUCKET) { + throw new Error("S3 is not configured."); + } + const key = mediaStorageKey(input); + const presign = getSignedUrl as unknown as ( + client: unknown, + command: unknown, + options: { expiresIn: number }, + ) => Promise; + return presign( + s3Client, + new GetObjectCommand({ + Bucket: S3_BUCKET, + Key: key, + ...(input.responseContentType ? { ResponseContentType: input.responseContentType } : {}), + }), + { expiresIn: mediaDirectUrlTtlSeconds() }, + ); +} + export async function generateVideoPreviewFromStoredMedia(input: { baseName: string; ext: string; uploadedAt: Date; }): Promise<{ sizeSm: number; sizeLg: number; width?: number; height?: number }> { - const originalBuffer = await getMediaBuffer({ - kind: "video", - baseName: input.baseName, - ext: input.ext, - size: "original", - uploadedAt: input.uploadedAt, - }); - const videoFrame = await tryGenerateVideoPreview(originalBuffer); + const originalKey = buildStorageKey("video", input.baseName, input.ext, "original", input.uploadedAt); + const originalStream = await openKeyNodeStream(originalKey); + const videoFrame = await tryGenerateVideoPreviewFromStream(originalStream); const lgBufferSource = videoFrame ?? (await asPreviewPng("Video Preview")); const lgBuffer = await sharp(lgBufferSource) .resize({ width: 1024, withoutEnlargement: true }) From ad28195553f0a08568e33954884040eef0ec7452 Mon Sep 17 00:00:00 2001 From: Tangles Date: Wed, 4 Mar 2026 17:22:58 +1100 Subject: [PATCH 02/13] wildcard branch allow for git action runs --- infra/cdk/lib/cicd-stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/cdk/lib/cicd-stack.ts b/infra/cdk/lib/cicd-stack.ts index 26107fa..0ed2b5e 100644 --- a/infra/cdk/lib/cicd-stack.ts +++ b/infra/cdk/lib/cicd-stack.ts @@ -61,7 +61,7 @@ export class CiCdStack extends cdk.Stack { this.createRole("DevDeployRole", { provider, policy: basePolicy, - githubSub: `repo:${props.githubOrg}/${props.githubRepo}:ref:refs/heads/main`, + githubSub: `repo:${props.githubOrg}/${props.githubRepo}:ref:refs/heads/*`, roleName: `${props.config.appName}-github-deploy-dev`, }); From 887424ed5d76321a26ee8bdc2696c44070193a54 Mon Sep 17 00:00:00 2001 From: Tangles Date: Wed, 4 Mar 2026 17:55:35 +1100 Subject: [PATCH 03/13] add setting to keep share url in browser, s3 allow cors --- infra/cdk/lib/data-stack.ts | 18 ++++ src/app/api/admin/settings/route.ts | 2 + src/app/share/[fileName]/route.ts | 96 ++++++++++++++++++- src/components/admin-settings.tsx | 15 ++- .../0007_share_html_navigation_setting.sql | 2 + src/db/schema.ts | 1 + src/lib/metadata-store.ts | 10 ++ 7 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 src/db/migrations/0007_share_html_navigation_setting.sql diff --git a/infra/cdk/lib/data-stack.ts b/infra/cdk/lib/data-stack.ts index 0f41cd5..969dfbd 100644 --- a/infra/cdk/lib/data-stack.ts +++ b/infra/cdk/lib/data-stack.ts @@ -48,6 +48,24 @@ export class DataStack extends cdk.Stack { lifecycleRules: imageBucketLifecycleRules, removalPolicy: cdk.RemovalPolicy.RETAIN, autoDeleteObjects: false, + cors: [ + { + allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.HEAD], + allowedOrigins: ["*"], + allowedHeaders: ["*"], + exposedHeaders: [ + "Accept-Ranges", + "Content-Length", + "Content-Range", + "Content-Type", + "ETag", + "Last-Modified", + "x-amz-request-id", + "x-amz-id-2", + ], + maxAge: 3600, + }, + ], }); const dbSecurityGroup = new ec2.SecurityGroup(this, "DbSecurityGroup", { diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 8557e53..f179cb1 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -38,6 +38,7 @@ export async function POST(request: Request): Promise { supportEnabled?: boolean; signupsEnabled?: boolean; uploadsEnabled?: boolean; + shareHtmlNavigationEnabled?: boolean; resumableThresholdBytes?: number; }; @@ -74,6 +75,7 @@ export async function POST(request: Request): Promise { supportEnabled: payload.supportEnabled, signupsEnabled: payload.signupsEnabled, uploadsEnabled: payload.uploadsEnabled, + shareHtmlNavigationEnabled: payload.shareHtmlNavigationEnabled, resumableThresholdBytes: payload.resumableThresholdBytes, }); diff --git a/src/app/share/[fileName]/route.ts b/src/app/share/[fileName]/route.ts index 3d72bb4..744073b 100644 --- a/src/app/share/[fileName]/route.ts +++ b/src/app/share/[fileName]/route.ts @@ -1,5 +1,5 @@ import type { NextRequest } from "next/server"; -import { getAlbumShareByCode, getImage, getShareByCode } from "@/lib/metadata-store"; +import { getAlbumShareByCode, getAppSettings, getImage, getShareByCode } from "@/lib/metadata-store"; import { getMediaBufferSize, getMediaSignedUrl, @@ -58,6 +58,60 @@ function publicCacheHeaders(ext: string): Headers { }); } +function isDocumentNavigation(request: NextRequest): boolean { + const destination = request.headers.get("sec-fetch-dest"); + const mode = request.headers.get("sec-fetch-mode"); + const accept = request.headers.get("accept") ?? ""; + return destination === "document" || mode === "navigate" || accept.includes("text/html"); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function signedMediaViewerHtml(input: { src: string; mimeType: string; fileName: string }): string { + const safeSrc = escapeHtml(input.src); + const safeName = escapeHtml(input.fileName); + const type = input.mimeType.toLowerCase(); + const body = type.startsWith("image/") + ? `${safeName}` + : type.startsWith("video/") + ? `` + : type.startsWith("audio/") + ? `` + : `

Open file

`; + + return ` + + + + + ${safeName} + + + + ${body} + +`; +} + function parseByteRange(rangeHeader: string, total: number): { start: number; end: number } | null { const match = /^bytes=(\d*)-(\d*)$/i.exec(rangeHeader.trim()); if (!match) { @@ -91,6 +145,9 @@ export async function GET( ): Promise { const { fileName } = await params; const parsed = parseFileName(fileName); + const allowHtmlNavigationMode = usesS3StorageBackend() + ? (await getAppSettings()).shareHtmlNavigationEnabled + : false; try { if (!parsed && /^[A-Za-z0-9]+$/.test(fileName)) { const albumShare = await getAlbumShareByCode(fileName); @@ -129,14 +186,31 @@ export async function GET( ? "lg" : parsed.size; if (usesS3StorageBackend()) { + const responseExt = imageRequestedSize === "original" ? image.ext : "png"; + const mimeType = contentTypeForExt(responseExt); const signedUrl = await getMediaSignedUrl({ kind: "image", baseName: image.baseName, ext: image.ext, size: imageRequestedSize, uploadedAt: new Date(image.uploadedAt), - responseContentType: contentTypeForExt(image.ext), + responseContentType: mimeType, }); + if (allowHtmlNavigationMode && isDocumentNavigation(request)) { + const html = signedMediaViewerHtml({ + src: signedUrl, + mimeType, + fileName, + }); + return withPublicImageCors( + new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }), + ); + } return withPublicImageCors(Response.redirect(signedUrl, 307)); } const stream = await getMediaStream({ @@ -176,14 +250,30 @@ export async function GET( if (usesS3StorageBackend()) { const responseExt = requestedSize === "original" ? media.ext : media.kind === "image" ? media.ext : "png"; + const mimeType = contentTypeForExt(responseExt); const signedUrl = await getMediaSignedUrl({ kind: media.kind, baseName: media.baseName, ext: media.ext, size: requestedSize, uploadedAt: new Date(media.uploadedAt), - responseContentType: contentTypeForExt(responseExt), + responseContentType: mimeType, }); + if (allowHtmlNavigationMode && isDocumentNavigation(request)) { + const html = signedMediaViewerHtml({ + src: signedUrl, + mimeType, + fileName, + }); + return withPublicImageCors( + new Response(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }, + }), + ); + } return withPublicImageCors(Response.redirect(signedUrl, 307)); } if (isRangeStreamableOriginal) { diff --git a/src/components/admin-settings.tsx b/src/components/admin-settings.tsx index 4259fa5..9cd6398 100644 --- a/src/components/admin-settings.tsx +++ b/src/components/admin-settings.tsx @@ -10,6 +10,7 @@ type AppSettings = { supportEnabled: boolean; signupsEnabled: boolean; uploadsEnabled: boolean; + shareHtmlNavigationEnabled: boolean; resumableThresholdBytes: number; }; @@ -34,6 +35,9 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { const [supportEnabled, setSupportEnabled] = useState(initial.supportEnabled); const [signupsEnabled, setSignupsEnabled] = useState(initial.signupsEnabled); const [uploadsEnabled, setUploadsEnabled] = useState(initial.uploadsEnabled); + const [shareHtmlNavigationEnabled, setShareHtmlNavigationEnabled] = useState( + initial.shareHtmlNavigationEnabled, + ); const [resumableThresholdMb, setResumableThresholdMb] = useState( Math.max(1, Math.round(initial.resumableThresholdBytes / (1024 * 1024))), ); @@ -57,6 +61,7 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { supportEnabled, signupsEnabled, uploadsEnabled, + shareHtmlNavigationEnabled, resumableThresholdBytes: Math.max(1024 * 1024, Number(resumableThresholdMb) * 1024 * 1024), }), }); @@ -143,7 +148,7 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { /> -
+
+
+ {message ? {message} : null} + {error ? {error} : null} +
+ + +
+
+

Create DB backup

+

+ Generates a data-only `.sql` backup by reading all public tables and saves it to storage root. +

+

NOTE: `IS_ENABLED = true;` must be set in `/src/app/api/admin/settings/db-backup/route.ts` to enable this unsafe feature.

+
+ + {backupError ? {backupError} : null} +
+ {backupReport ? ( +
+
+
backend: {backupReport.backend}
+
file: {backupReport.fileName}
+
storage path: {backupReport.storagePath}
+
tables: {backupReport.tableCount}
+
total rows: {backupReport.totalRows}
+
+
+ Show table row counts +
+                  {backupReport.tables.map((entry) => `${entry.tableName}: ${entry.rowCount}`).join("\n") || "(none)"}
+                
+
+
+ ) : null} +
+
+ +
+
+

Legacy image storage migration

+

+ Moves pre-media images from legacy storage paths into the new `/image/...` folder layout. +

+
+ + {migrationError ? {migrationError} : null} +
+ {migrationReport ? ( +
+
+
backend: {migrationReport.backend}
+
images checked: {migrationReport.checkedImages}
+
files migrated: {migrationReport.migrated}
+
already migrated: {migrationReport.skippedAlreadyMigrated}
+
missing legacy source: {migrationReport.missingLegacySource}
+
errors: {migrationReport.errors}
+
+
+ Show detailed output +
+                  {`Migrated examples:
+${migrationReport.migratedExamples.join("\n") || "(none)"}
+
+Skipped examples:
+${migrationReport.skippedExamples.join("\n") || "(none)"}
+
+Missing examples:
+${migrationReport.missingExamples.join("\n") || "(none)"}
+
+Error examples:
+${migrationReport.errorExamples.join("\n") || "(none)"}`}
+                
+
+
+ ) : null} +
+
+ + + ); +} diff --git a/src/components/admin-pg-dump-import.tsx b/src/components/admin-pg-dump-import.tsx deleted file mode 100644 index f4837e5..0000000 --- a/src/components/admin-pg-dump-import.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { useState } from "react"; - -type ImportResponse = { - message?: string; - error?: string; -}; - -export default function AdminPgDumpImport() { - const [file, setFile] = useState(null); - const [s3Key, setS3Key] = useState(""); - const [isUploading, setIsUploading] = useState(false); - const [error, setError] = useState(null); - const [message, setMessage] = useState(null); - - async function runImport() { - const trimmedKey = s3Key.trim(); - if (!file && !trimmedKey) { - setError("Choose a .sql/.dump file or enter an S3 object key."); - return; - } - - setError(null); - setMessage(null); - setIsUploading(true); - try { - const response = file - ? await (async () => { - const formData = new FormData(); - formData.append("dump", file); - if (trimmedKey) { - formData.append("s3Key", trimmedKey); - } - return fetch("/api/admin/settings/pg-dump-import", { - method: "POST", - body: formData, - }); - })() - : await fetch("/api/admin/settings/pg-dump-import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ s3Key: trimmedKey }), - }); - - const payload = (await response.json()) as ImportResponse; - if (!response.ok) { - setError(payload.error ?? "Import failed."); - return; - } - setMessage(payload.message ?? "Import completed."); - } catch { - setError("Import failed."); - } finally { - setIsUploading(false); - } - } - - return ( -
-

Import PostgreSQL dump

-

- Upload a `.sql` or `.dump` PostgreSQL dump file. The server runs `psql` or `pg_restore` - against the current database. -

- - -
- - {message ? {message} : null} - {error ? {error} : null} -
-
- ); -} diff --git a/src/components/admin-settings.tsx b/src/components/admin-settings.tsx index 9cd6398..941d186 100644 --- a/src/components/admin-settings.tsx +++ b/src/components/admin-settings.tsx @@ -14,18 +14,7 @@ type AppSettings = { resumableThresholdBytes: number; }; -type LegacyMigrationReport = { - backend: "local" | "s3"; - checkedImages: number; - migrated: number; - skippedAlreadyMigrated: number; - missingLegacySource: number; - errors: number; - migratedExamples: string[]; - skippedExamples: string[]; - missingExamples: string[]; - errorExamples: string[]; -}; + export default function AdminSettings({ initial }: { initial: AppSettings }) { const [motd, setMotd] = useState(initial.motd); @@ -43,9 +32,6 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { ); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - const [migrationBusy, setMigrationBusy] = useState(false); - const [migrationError, setMigrationError] = useState(null); - const [migrationReport, setMigrationReport] = useState(null); async function save() { setError(null); @@ -75,23 +61,7 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { setSaved(true); } - async function runLegacyMigration() { - setMigrationBusy(true); - setMigrationError(null); - setMigrationReport(null); - const response = await fetch("/api/admin/settings/migrate-legacy-images", { - method: "POST", - }); - if (!response.ok) { - const payload = (await response.json()) as { error?: string }; - setMigrationError(payload.error ?? "Unable to run migration."); - setMigrationBusy(false); - return; - } - const payload = (await response.json()) as { report?: LegacyMigrationReport }; - setMigrationReport(payload.report ?? null); - setMigrationBusy(false); - } + return (
@@ -194,52 +164,6 @@ export default function AdminSettings({ initial }: { initial: AppSettings }) { {error ? {error} : null}
-
-

Legacy image storage migration

-

- Moves pre-media images from legacy storage paths into the new `/image/...` folder layout. -

-
- - {migrationError ? {migrationError} : null} -
- {migrationReport ? ( -
-
-
backend: {migrationReport.backend}
-
images checked: {migrationReport.checkedImages}
-
files migrated: {migrationReport.migrated}
-
already migrated: {migrationReport.skippedAlreadyMigrated}
-
missing legacy source: {migrationReport.missingLegacySource}
-
errors: {migrationReport.errors}
-
-
- Show detailed output -
-{`Migrated examples:
-${migrationReport.migratedExamples.join("\n") || "(none)"}
-
-Skipped examples:
-${migrationReport.skippedExamples.join("\n") || "(none)"}
-
-Missing examples:
-${migrationReport.missingExamples.join("\n") || "(none)"}
-
-Error examples:
-${migrationReport.errorExamples.join("\n") || "(none)"}`}
-              
-
-
- ) : null} -
- ); } diff --git a/src/lib/db-sql-backup.ts b/src/lib/db-sql-backup.ts new file mode 100644 index 0000000..b89659b --- /dev/null +++ b/src/lib/db-sql-backup.ts @@ -0,0 +1,236 @@ +import path from "path"; +import { promises as fs } from "fs"; +import postgres from "postgres"; +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; + +type StorageBackend = "local" | "s3"; + +type TableColumn = { + columnName: string; +}; + +type BackupTableSummary = { + tableName: string; + rowCount: number; +}; + +export type DbBackupResult = { + fileName: string; + backend: StorageBackend; + storagePath: string; + tableCount: number; + totalRows: number; + tables: BackupTableSummary[]; +}; + +const DATA_DIR = path.join(process.cwd(), "data"); +const STORAGE_BACKEND = (process.env.STORAGE_BACKEND as StorageBackend) || "local"; +const S3_BUCKET = process.env.S3_BUCKET; +const S3_REGION = process.env.S3_REGION; +const S3_ENDPOINT = process.env.S3_ENDPOINT; + +const s3Client = + STORAGE_BACKEND === "s3" && S3_BUCKET && S3_REGION + ? new S3Client({ + region: S3_REGION, + endpoint: S3_ENDPOINT, + forcePathStyle: Boolean(S3_ENDPOINT), + }) + : null; + +type PgRow = Record; + +function resolveConnectionString(): string | undefined { + const host = process.env.PGHOST; + const database = process.env.PGDATABASE; + const user = process.env.PGUSER; + const password = process.env.PGPASSWORD; + const port = process.env.PGPORT ?? "5432"; + + if (host && database && user && password) { + const encodedPassword = encodeURIComponent(password); + return `postgres://${user}:${encodedPassword}@${host}:${port}/${database}`; + } + + return process.env.DATABASE_URL; +} + +function quoteIdentifier(name: string): string { + return `"${name.replace(/"/g, "\"\"")}"`; +} + +function quoteLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function valueToSql(value: unknown): string { + if (value === null || value === undefined) { + return "NULL"; + } + if (typeof value === "boolean") { + return value ? "TRUE" : "FALSE"; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return "NULL"; + } + return String(value); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Date) { + return quoteLiteral(value.toISOString()); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const hex = Buffer.from(value).toString("hex"); + return `'\\x${hex}'::bytea`; + } + if (Array.isArray(value)) { + return `ARRAY[${value.map((item) => valueToSql(item)).join(", ")}]`; + } + if (typeof value === "object") { + return `${quoteLiteral(JSON.stringify(value))}::jsonb`; + } + return quoteLiteral(String(value)); +} + +function buildBackupFileName(now: Date): string { + const iso = now.toISOString().replace(/[:.]/g, "-"); + return `db-backup-${iso}.sql`; +} + +export async function createSqlBackupAndStore(): Promise { + const connectionString = resolveConnectionString(); + if (!connectionString) { + throw new Error("Database is not configured."); + } + + const useSsl = process.env.PGSSLMODE === "require"; + const sql = postgres(connectionString, { + max: 1, + ssl: useSsl ? "require" : undefined, + }); + + try { + const now = new Date(); + const fileName = buildBackupFileName(now); + const lines: string[] = []; + lines.push("-- tanglepic SQL backup (data-only)"); + lines.push(`-- generated_at_utc: ${now.toISOString()}`); + lines.push(""); + lines.push("BEGIN;"); + lines.push(""); + + const tables = await sql<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name ASC + `; + + const tableSummaries: BackupTableSummary[] = []; + let totalRows = 0; + + for (const table of tables) { + const tableName = table.table_name; + const columns = await sql<{ column_name: string }[]>` + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ${tableName} + ORDER BY ordinal_position ASC + `; + if (columns.length === 0) { + tableSummaries.push({ tableName, rowCount: 0 }); + continue; + } + const quotedColumns = columns.map((column) => quoteIdentifier(column.column_name)); + + const pkColumns = await sql<{ attname: string }[]>` + SELECT a.attname + FROM pg_index i + JOIN pg_class c ON c.oid = i.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey) + WHERE i.indisprimary = TRUE + AND n.nspname = 'public' + AND c.relname = ${tableName} + ORDER BY array_position(i.indkey, a.attnum) + `; + + const orderClause = + pkColumns.length > 0 + ? ` ORDER BY ${pkColumns.map((column) => quoteIdentifier(column.attname)).join(", ")}` + : ""; + const tableRows = await sql.unsafe( + `SELECT * FROM ${quoteIdentifier("public")}.${quoteIdentifier(tableName)}${orderClause}`, + ); + + lines.push(`-- table: ${tableName} (${tableRows.length} row${tableRows.length === 1 ? "" : "s"})`); + for (const row of tableRows) { + const values = columns.map((column) => valueToSql(row[column.column_name])); + lines.push( + `INSERT INTO ${quoteIdentifier("public")}.${quoteIdentifier(tableName)} (${quotedColumns.join(", ")}) VALUES (${values.join(", ")});`, + ); + } + lines.push(""); + tableSummaries.push({ tableName, rowCount: tableRows.length }); + totalRows += tableRows.length; + } + + const serialColumns = await sql<{ table_name: string; column_name: string; sequence_name: string | null }[]>` + SELECT + table_name, + column_name, + pg_get_serial_sequence(format('%I.%I', table_schema, table_name), column_name) AS sequence_name + FROM information_schema.columns + WHERE table_schema = 'public' + ORDER BY table_name ASC, ordinal_position ASC + `; + for (const entry of serialColumns) { + if (!entry.sequence_name) { + continue; + } + lines.push( + `SELECT setval(${quoteLiteral(entry.sequence_name)}, COALESCE((SELECT MAX(${quoteIdentifier(entry.column_name)}) FROM ${quoteIdentifier("public")}.${quoteIdentifier(entry.table_name)}), 1), (SELECT MAX(${quoteIdentifier(entry.column_name)}) IS NOT NULL FROM ${quoteIdentifier("public")}.${quoteIdentifier(entry.table_name)}));`, + ); + } + + lines.push(""); + lines.push("COMMIT;"); + lines.push(""); + + const body = Buffer.from(lines.join("\n"), "utf8"); + let storagePath = ""; + if (STORAGE_BACKEND === "s3") { + if (!s3Client || !S3_BUCKET) { + throw new Error("S3 is not configured."); + } + await s3Client.send( + new PutObjectCommand({ + Bucket: S3_BUCKET, + Key: fileName, + Body: body, + ContentType: "application/sql", + }), + ); + storagePath = fileName; + } else { + await fs.mkdir(DATA_DIR, { recursive: true }); + storagePath = path.join(DATA_DIR, fileName); + await fs.writeFile(storagePath, body); + } + + return { + fileName, + backend: STORAGE_BACKEND, + storagePath, + tableCount: tables.length, + totalRows, + tables: tableSummaries, + }; + } finally { + await sql.end({ timeout: 5 }); + } +} + From cccd0cbb772c25618c5cc038a27330766f50d74f Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 00:56:17 +1100 Subject: [PATCH 06/13] docker font preview fixes --- Dockerfile | 6 ++- src/app/api/media/video-preview/route.ts | 4 -- src/lib/media-storage.ts | 47 ++++++++++++++++++++---- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index b9e67e8..d21b39f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,8 +34,10 @@ ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED=1 -# Needed for admin migration import route (`psql` / `pg_restore`). -RUN apk add --no-cache postgresql-client +# Needed for admin migration import route (`psql` / `pg_restore`) +# and document/text preview rendering (`fontconfig` + actual fonts). +RUN apk add --no-cache postgresql-client fontconfig ttf-dejavu \ + && fc-cache -f RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/src/app/api/media/video-preview/route.ts b/src/app/api/media/video-preview/route.ts index a34ce6f..abe2e66 100644 --- a/src/app/api/media/video-preview/route.ts +++ b/src/app/api/media/video-preview/route.ts @@ -61,7 +61,3 @@ export async function POST(request: Request): Promise { return NextResponse.json({ error: message }, { status: 500 }); } } - -export const balls = () => { - return "balls"; -}; \ No newline at end of file diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index c77d129..affe3e0 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -55,6 +55,13 @@ const MEDIA_DIRECT_URL_TTL_SECONDS = Number.parseInt( process.env.MEDIA_DIRECT_URL_TTL_SECONDS ?? "120", 10, ); +const TMP_CANDIDATES = [ + process.env.TANGLEPIC_TMP_DIR, + "/dev/shm", + "/var/tmp", + os.tmpdir(), + path.join(process.cwd(), "data", "tmp"), +].filter((value): value is string => Boolean(value && value.trim().length > 0)); function toWebReadableStream(body: unknown): ReadableStream { if (!body) { @@ -162,6 +169,19 @@ function buildStorageKey( return path.posix.join("uploads", year, month, day, kind, size, `${baseName}.${ext}`); } +async function createWorkingDir(prefix: string): Promise { + let lastError: Error | null = null; + for (const candidate of TMP_CANDIDATES) { + try { + await fs.mkdir(candidate, { recursive: true }); + return await fs.mkdtemp(path.join(candidate, prefix)); + } catch (error) { + lastError = error instanceof Error ? error : new Error("Unable to create temp directory."); + } + } + throw lastError ?? new Error("Unable to create temp directory."); +} + function mediaDirectUrlTtlSeconds(): number { if (!Number.isFinite(MEDIA_DIRECT_URL_TTL_SECONDS) || MEDIA_DIRECT_URL_TTL_SECONDS <= 0) { return 120; @@ -353,11 +373,16 @@ async function readKeyRangeStream( return Readable.toWeb(createReadStream(absolutePathForKey(key), { start, end })) as ReadableStream; } -function asPreviewPng(text: string): Promise { +function asPreviewPng(_text: string): Promise { const svg = ` - ${text} + + + + + + `; return sharp(Buffer.from(svg)).png().toBuffer(); } @@ -392,11 +417,18 @@ async function asTextPreviewPng(label: string, text: string): Promise { ${escapeXml(label)} ${lineNodes} `; - return sharp(Buffer.from(svg)).png().toBuffer(); + try { + console.log("Generating text preview PNG with fontconfig."); + return await sharp(Buffer.from(svg)).png().toBuffer(); + } catch { + // Fall back to a font-free placeholder if fontconfig is unavailable. + console.warn("Fontconfig is unavailable, falling back to a font-free placeholder."); + return asPreviewPng("File Preview"); + } } async function tryGeneratePdfPreview(buffer: Buffer): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tanglepic-pdf-")); + const tmpDir = await createWorkingDir("tanglepic-pdf-"); const inputPath = path.join(tmpDir, "input.pdf"); const outputPrefix = path.join(tmpDir, "preview"); const outputPath = `${outputPrefix}.png`; @@ -414,7 +446,7 @@ async function tryGeneratePdfPreview(buffer: Buffer): Promise { } async function tryGenerateOfficePreview(buffer: Buffer, ext: string): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "tanglepic-office-")); + const tmpDir = await createWorkingDir("tanglepic-office-"); const inputPath = path.join(tmpDir, `input.${ext}`); const pdfPath = path.join(tmpDir, "input.pdf"); const outputPrefix = path.join(tmpDir, "preview"); @@ -485,10 +517,10 @@ async function tryGenerateVideoPreviewFromStream(input: NodeJS.ReadableStream): "-hide_banner", "-loglevel", "error", - "-ss", - "00:00:01", "-i", "pipe:0", + "-ss", + "00:00:01", "-frames:v", "1", "-vf", @@ -524,7 +556,6 @@ async function tryGenerateVideoPreviewFromStream(input: NodeJS.ReadableStream): return; } if (stderr.length > 0) { - // eslint-disable-next-line no-console console.warn(`ffmpeg thumbnail generation failed: ${stderr}`); } resolve(null); From db56e5984db4ffdb2f0d05b44a5f076cd7331712 Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 00:59:01 +1100 Subject: [PATCH 07/13] revert lint script change --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2ae7631..b493505 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev-quick": "next dev --turbopack -p 3000", "build": "next build", "start": "next start", - "lint": "pnpm exec eslint", + "lint": "next lint", + "lint:es": "pnpm exec eslint", "db:push": "drizzle-kit push --config ./drizzle.config.ts", "db:push:force": "drizzle-kit push --force --config ./drizzle.config.ts", "cdk:install": "pnpm --dir infra/cdk install", From 14e75722ae4151aad97c637f5ca764ebfdd0150b Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 01:00:45 +1100 Subject: [PATCH 08/13] skipped linting in ci/cd --- .github/workflows/deploy-dev.yml | 4 ++-- .github/workflows/deploy-prod.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 32e066d..f1910a6 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -32,8 +32,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm lint + #- name: Lint + # run: pnpm lint - name: Build run: pnpm build diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index f8962a8..8a3a931 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -41,8 +41,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Lint - run: pnpm lint + #- name: Lint + # run: pnpm lint - name: Build run: pnpm build From 55ee3c07018cb4191bbbf0439320b1a23553dd3e Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 10:24:43 +1100 Subject: [PATCH 09/13] added password reset --- .env.example | 4 +- docker-compose.yml | 2 + docs/runtime-environment.md | 2 + infra/cdk/lib/app-stack.ts | 2 + package.json | 1 + pnpm-lock.yaml | 67 ++++++++++- src/app/api/auth/forgot-password/route.ts | 62 ++++++++++ src/app/api/auth/reset-password/route.ts | 72 ++++++++++++ src/app/page.tsx | 2 +- src/app/reset-password/page.tsx | 109 ++++++++++++++++++ src/components/auth-forms.tsx | 97 ++++++++++++++-- .../migrations/0008_password_reset_tokens.sql | 3 + src/db/schema.ts | 2 + src/lib/password-reset.ts | 60 ++++++++++ 14 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 src/app/api/auth/forgot-password/route.ts create mode 100644 src/app/api/auth/reset-password/route.ts create mode 100644 src/app/reset-password/page.tsx create mode 100644 src/db/migrations/0008_password_reset_tokens.sql create mode 100644 src/lib/password-reset.ts diff --git a/.env.example b/.env.example index 4c4143f..979bb3c 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,6 @@ DATABASE_URL=postgresql://latex:latex@db:5432/latex STORAGE_BACKEND=local ADMIN_BOOTSTRAP_TOKEN="set-a-random-one-time-bootstrap-token" RUN_DB_MIGRATIONS_ON_STARTUP=false -DB_PUSH_PW="only set this if you want to use the /api/admin/settings/db-push endpoint" \ No newline at end of file +DB_PUSH_PW="only set this if you want to use the /api/admin/settings/db-push endpoint" +RESEND_API_KEY= +PASSWORD_RESET_TOKEN_TTL_MINUTES=30 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bae32c9..aa5e819 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: - RATE_LIMIT_MAX_ATTEMPTS=${RATE_LIMIT_MAX_ATTEMPTS:-20} - BILLING_ROLE_ARN=${BILLING_ROLE_ARN} - BILLING_CE_REGION=${BILLING_CE_REGION:-us-east-1} + - RESEND_API_KEY=${RESEND_API_KEY} + - PASSWORD_RESET_TOKEN_TTL_MINUTES=${PASSWORD_RESET_TOKEN_TTL_MINUTES} restart: unless-stopped depends_on: db: diff --git a/docs/runtime-environment.md b/docs/runtime-environment.md index 7145caa..0e97424 100644 --- a/docs/runtime-environment.md +++ b/docs/runtime-environment.md @@ -6,6 +6,8 @@ The app supports both local and AWS runtime settings. - `NEXTAUTH_URL` - `NEXTAUTH_SECRET` +- `PASSWORD_RESET_TOKEN_TTL_MINUTES` (optional, defaults to `30`) +- `RESEND_API_KEY` (required for password reset email delivery) - `ADMIN_BOOTSTRAP_TOKEN` (required only for one-time `/promote-admin` bootstrap) - `STORAGE_BACKEND` (`local` or `s3`) - `RUN_DB_MIGRATIONS_ON_STARTUP` (`true` only for local/dev smoke use) diff --git a/infra/cdk/lib/app-stack.ts b/infra/cdk/lib/app-stack.ts index 7ef2f4c..5b00a1c 100644 --- a/infra/cdk/lib/app-stack.ts +++ b/infra/cdk/lib/app-stack.ts @@ -98,6 +98,7 @@ export class AppStack extends cdk.Stack { RATE_LIMIT_MAX_ATTEMPTS: "20", BILLING_ROLE_ARN: billingReaderRole.roleArn, BILLING_CE_REGION: "us-east-1", + PASSWORD_RESET_TOKEN_TTL_MINUTES: "30", }, secrets: { NEXTAUTH_SECRET: ecs.Secret.fromSecretsManager(props.appSecret, "NEXTAUTH_SECRET"), @@ -105,6 +106,7 @@ export class AppStack extends cdk.Stack { DB_PUSH_PW: ecs.Secret.fromSecretsManager(props.appSecret, "DB_PUSH_PW"), PGUSER: ecs.Secret.fromSecretsManager(props.dbCredentialsSecret, "username"), PGPASSWORD: ecs.Secret.fromSecretsManager(props.dbCredentialsSecret, "password"), + RESEND_API_KEY: ecs.Secret.fromSecretsManager(props.appSecret, "RESEND_API_KEY"), }, readonlyRootFilesystem: true, }); diff --git a/package.json b/package.json index b493505..d26927c 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-hook-form": "^7.64.0", "react-markdown": "^10.1.0", "remark": "^15.0.1", + "resend": "^6.9.3", "sharp": "^0.33.5", "strip-markdown": "^6.0.0", "stripe": "^19.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ea232e..dc139ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-auth: specifier: ^4.24.11 - version: 4.24.13(next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 4.24.13(next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(nodemailer@7.0.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) postgres: specifier: ^3.4.7 version: 3.4.8 @@ -140,6 +140,9 @@ importers: remark: specifier: ^15.0.1 version: 15.0.1 + resend: + specifier: ^6.9.3 + version: 6.9.3 sharp: specifier: ^0.33.5 version: 0.33.5 @@ -2204,6 +2207,9 @@ packages: resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -3302,6 +3308,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-xml-builder@1.0.0: resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} @@ -4039,6 +4048,10 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nodemailer@7.0.13: + resolution: {integrity: sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4164,6 +4177,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.3: + resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -4376,6 +4392,15 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + resend@6.9.3: + resolution: {integrity: sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4500,6 +4525,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -4588,6 +4616,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.84.1: + resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -4759,6 +4790,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -7733,6 +7768,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/utils@0.3.0': {} '@stripe/react-stripe-js@5.6.0(@stripe/stripe-js@8.7.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -8943,6 +8980,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-xml-builder@1.0.0: {} fast-xml-parser@5.3.4: @@ -9763,7 +9802,7 @@ snapshots: natural-compare@1.4.0: {} - next-auth@4.24.13(next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next-auth@4.24.13(next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(nodemailer@7.0.13)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@babel/runtime': 7.28.6 '@panva/hkdf': 1.2.1 @@ -9777,6 +9816,8 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) uuid: 8.3.2 + optionalDependencies: + nodemailer: 7.0.13 next@15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: @@ -9803,6 +9844,9 @@ snapshots: node-releases@2.0.36: {} + nodemailer@7.0.13: + optional: true + normalize-path@3.0.0: {} oauth@0.9.15: {} @@ -9931,6 +9975,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.3: {} + postcss-import@15.1.0(postcss@8.5.6): dependencies: postcss: 8.5.6 @@ -10156,6 +10202,11 @@ snapshots: transitivePeerDependencies: - supports-color + resend@6.9.3: + dependencies: + postal-mime: 2.7.3 + svix: 1.84.1 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -10344,6 +10395,11 @@ snapshots: stable-hash@0.0.5: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + state-local@1.0.7: {} stop-iteration-iterator@1.1.0: @@ -10453,6 +10509,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.84.1: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tabbable@6.4.0: {} tailwind-merge@2.2.2: @@ -10688,6 +10749,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@8.3.2: {} vfile-message@4.0.3: diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..0e4653f --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,62 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { + buildPasswordResetUrl, + createPasswordResetToken, + getPasswordResetExpiryDate, + sendPasswordResetEmail, +} from "@/lib/password-reset"; + +export const runtime = "nodejs"; + +const GENERIC_SUCCESS_MESSAGE = + "If an account with that email exists, a password reset link has been sent."; +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export async function POST(request: Request): Promise { + const payload = (await request.json()) as { email?: string }; + const email = payload?.email?.trim().toLowerCase(); + + if (!email) { + return NextResponse.json({ error: "Email is required." }, { status: 400 }); + } + if (!EMAIL_REGEX.test(email)) { + return NextResponse.json({ error: "Email format is invalid." }, { status: 400 }); + } + + const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); + if (!user) { + return NextResponse.json({ ok: true, message: GENERIC_SUCCESS_MESSAGE }); + } + + const { token, tokenHash } = createPasswordResetToken(); + const tokenExpiresAt = getPasswordResetExpiryDate(); + + await db + .update(users) + .set({ + passwordResetTokenHash: tokenHash, + passwordResetTokenExpiresAt: tokenExpiresAt, + }) + .where(eq(users.id, user.id)); + + const resetUrl = buildPasswordResetUrl(request, token); + let emailSent = false; + try { + emailSent = await sendPasswordResetEmail({ + to: user.email, + resetUrl, + }); + } catch (error) { + console.error("[password-reset] Failed to send reset email.", error); + } + + if (!emailSent && process.env.NODE_ENV !== "production") { + console.info("[password-reset] Resend is not configured. Reset link:", resetUrl); + } + + return NextResponse.json({ ok: true, message: GENERIC_SUCCESS_MESSAGE, emailSent }); +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..ed14dd3 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,72 @@ +import bcrypt from "bcryptjs"; +import { and, eq, gt } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { hashPasswordResetToken } from "@/lib/password-reset"; + +export const runtime = "nodejs"; + +function isValidPassword(value: string): boolean { + return value.length > 6 && /[a-zA-Z]/.test(value) && /[0-9]/.test(value); +} + +export async function POST(request: Request): Promise { + const payload = (await request.json()) as { + token?: string; + password?: string; + confirmPassword?: string; + }; + + const token = payload?.token?.trim(); + const password = payload?.password; + const confirmPassword = payload?.confirmPassword; + + if (!token || !password || !confirmPassword) { + return NextResponse.json( + { error: "Reset token, password, and confirm password are required." }, + { status: 400 }, + ); + } + + if (!isValidPassword(password)) { + return NextResponse.json( + { error: "Password must be >6 chars and include letters and numbers." }, + { status: 400 }, + ); + } + + if (password !== confirmPassword) { + return NextResponse.json({ error: "Passwords do not match." }, { status: 400 }); + } + + const tokenHash = hashPasswordResetToken(token); + const now = new Date(); + + const [user] = await db + .select({ id: users.id }) + .from(users) + .where( + and( + eq(users.passwordResetTokenHash, tokenHash), + gt(users.passwordResetTokenExpiresAt, now), + ), + ) + .limit(1); + + if (!user) { + return NextResponse.json({ error: "Reset token is invalid or has expired." }, { status: 400 }); + } + + const passwordHash = await bcrypt.hash(password, 12); + await db + .update(users) + .set({ + passwordHash, + passwordResetTokenHash: null, + passwordResetTokenExpiresAt: null, + }) + .where(eq(users.id, user.id)); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 8c7a754..2ccbc2c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -91,7 +91,7 @@ export default async function Home() { {/*

I C U AGAIN

*/}

- u r {session?.user?.email ?? session?.user?.name ?? "user"}. wb <3 + u r {session?.user?.name ?? session?.user?.email ?? "...who r u?"}. wb <3

u hav {userStats?.imageCount ?? 0} imgs uploaded using{" "} diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx new file mode 100644 index 0000000..90edf90 --- /dev/null +++ b/src/app/reset-password/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; + +function isValidPassword(value: string): boolean { + return value.length > 6 && /[a-zA-Z]/.test(value) && /[0-9]/.test(value); +} + +function getFormString(formData: FormData, key: string): string { + const value = formData.get(key); + return typeof value === "string" ? value : ""; +} + +export default function ResetPasswordPage() { + const params = useSearchParams(); + const token = useMemo(() => params.get("token")?.trim() ?? "", [params]); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(null); + + if (!token) { + setError("Reset token is missing. Use the full link from your email."); + return; + } + + const formData = new FormData(event.currentTarget); + const password = getFormString(formData, "password"); + const confirmPassword = getFormString(formData, "confirmPassword"); + + if (!isValidPassword(password)) { + setError("Password must be >6 chars and include letters and numbers."); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password, confirmPassword }), + }); + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + setError(payload.error ?? "Unable to reset password."); + return; + } + setSuccess("Password updated. Redirecting to login..."); + setTimeout(() => { + router.push("/"); + }, 1200); + } finally { + setIsSubmitting(false); + } + } + + return ( +

+
+

reset ur password

+

+ enter a new password with letters and numbers. minimum length is 7. +

+
+ + + +
+ {error ?

{error}

: null} + {success ?

{success}

: null} + + back to login + +
+
+ ); +} diff --git a/src/components/auth-forms.tsx b/src/components/auth-forms.tsx index 1c9d795..82c0354 100644 --- a/src/components/auth-forms.tsx +++ b/src/components/auth-forms.tsx @@ -4,10 +4,17 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { signIn } from "next-auth/react"; +function getFormString(formData: FormData, key: string): string | null { + const value = formData.get(key); + return typeof value === "string" ? value : null; +} + export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean }) { - const [mode, setMode] = useState<"login" | "signup">("login"); + const [mode, setMode] = useState<"login" | "signup" | "forgot">("login"); const [signUpError, setSignUpError] = useState(null); const [signInError, setSignInError] = useState(null); + const [forgotError, setForgotError] = useState(null); + const [forgotSuccess, setForgotSuccess] = useState(null); const router = useRouter(); async function handleSignUp(event: React.FormEvent) { @@ -15,10 +22,10 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean setSignUpError(null); const formData = new FormData(event.currentTarget); - const username = formData.get("signupUsername")?.toString().trim(); - const email = formData.get("signupEmail")?.toString().trim(); - const password = formData.get("signupPassword")?.toString(); - const confirmPassword = formData.get("signupConfirmPassword")?.toString(); + const username = getFormString(formData, "signupUsername")?.trim(); + const email = getFormString(formData, "signupEmail")?.trim(); + const password = getFormString(formData, "signupPassword") ?? undefined; + const confirmPassword = getFormString(formData, "signupConfirmPassword") ?? undefined; if (!username || !email || !password || !confirmPassword) { setSignUpError("bruh"); @@ -71,8 +78,8 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean setSignInError(null); const formData = new FormData(event.currentTarget); - const email = formData.get("email")?.toString().trim(); - const password = formData.get("password")?.toString(); + const email = getFormString(formData, "email")?.trim(); + const password = getFormString(formData, "password") ?? undefined; if (!email || !password) { setSignInError("Email and password are required."); @@ -93,6 +100,39 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean router.push("/gallery"); } + async function handleForgotPassword(event: React.FormEvent) { + event.preventDefault(); + setForgotError(null); + setForgotSuccess(null); + + const formData = new FormData(event.currentTarget); + const email = getFormString(formData, "forgotEmail")?.trim(); + + if (!email) { + setForgotError("Email is required."); + return; + } + + const response = await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + setForgotError(payload.error ?? "Unable to process password reset request."); + return; + } + + const payload = (await response.json()) as { message?: string; emailSent?: boolean }; + const suffix = + payload.emailSent === false + ? " Resend is not configured, so for local dev check server logs for the reset link." + : ""; + setForgotSuccess((payload.message ?? "If that account exists, a reset link has been sent.") + suffix); + } + return (
@@ -101,6 +141,8 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean onClick={() => { setMode("login"); setSignUpError(null); + setForgotError(null); + setForgotSuccess(null); }} className={`rounded px-3 py-1 ${ mode === "login" ? "bg-black text-white" : "border border-neutral-200" @@ -113,6 +155,8 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean onClick={() => { setMode("signup"); setSignInError(null); + setForgotError(null); + setForgotSuccess(null); }} className={`rounded px-3 py-1 ${ mode === "signup" ? "bg-black text-white" : "border border-neutral-200" @@ -179,6 +223,35 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean ) : null}
+ ) : mode === "forgot" ? ( +
+

forgot ur password?

+
+ + + + {forgotError ?

{forgotError}

: null} + {forgotSuccess ?

{forgotSuccess}

: null} +
+
) : (

the legend returns

@@ -200,6 +273,16 @@ export default function AuthForms({ signupsEnabled }: { signupsEnabled: boolean + {signInError ? (

{signInError}

) : null} diff --git a/src/db/migrations/0008_password_reset_tokens.sql b/src/db/migrations/0008_password_reset_tokens.sql new file mode 100644 index 0000000..c57a963 --- /dev/null +++ b/src/db/migrations/0008_password_reset_tokens.sql @@ -0,0 +1,3 @@ +ALTER TABLE "users" + ADD COLUMN "password_reset_token_hash" text, + ADD COLUMN "password_reset_token_expires_at" timestamp; diff --git a/src/db/schema.ts b/src/db/schema.ts index d57cda8..f73f3d8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -41,6 +41,8 @@ export const users = pgTable("users", { username: text("username").notNull(), email: text("email").notNull().unique(), passwordHash: text("password_hash").notNull(), + passwordResetTokenHash: text("password_reset_token_hash"), + passwordResetTokenExpiresAt: timestamp("password_reset_token_expires_at", { mode: "date" }), groupId: text("group_id").references(() => groups.id), theme: text("theme").notNull().default("dark"), createdAt: timestamp("created_at", { mode: "date" }).notNull(), diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts new file mode 100644 index 0000000..a9113fd --- /dev/null +++ b/src/lib/password-reset.ts @@ -0,0 +1,60 @@ +import { createHash, randomBytes } from "crypto"; +import { Resend } from "resend"; + +const DEFAULT_TOKEN_TTL_MINUTES = 30; + +export function hashPasswordResetToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createPasswordResetToken(): { token: string; tokenHash: string } { + const token = randomBytes(32).toString("hex"); + return { token, tokenHash: hashPasswordResetToken(token) }; +} + +export function getPasswordResetExpiryDate(): Date { + const ttlMinutes = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES ?? DEFAULT_TOKEN_TTL_MINUTES); + const safeTtlMinutes = + Number.isFinite(ttlMinutes) && ttlMinutes >= 1 && ttlMinutes <= 24 * 60 + ? ttlMinutes + : DEFAULT_TOKEN_TTL_MINUTES; + return new Date(Date.now() + safeTtlMinutes * 60 * 1000); +} + +function getPublicAppOrigin(request: Request): string { + const configured = process.env.NEXTAUTH_URL?.trim(); + if (configured) { + return configured.replace(/\/$/, ""); + } + return new URL(request.url).origin; +} + +export function buildPasswordResetUrl(request: Request, token: string): string { + const origin = getPublicAppOrigin(request); + const url = new URL("/reset-password", origin); + url.searchParams.set("token", token); + return url.toString(); +} + +export function isResendConfigured(): boolean { + return Boolean(process.env.RESEND_API_KEY?.trim()); +} + +export async function sendPasswordResetEmail(input: { + to: string; + resetUrl: string; +}): Promise { + const apiKey = process.env.RESEND_API_KEY?.trim(); + if (!apiKey) { + return false; + } + + const resend = new Resend(apiKey); + await resend.emails.send({ + from: "noreply@latex.gg", + to: input.to, + subject: "psswrd rst", + html: `

A password reset was requested for your account.

Reset your password

If you did not request this, you can ignore this email.

`, + }); + return true; +} From 9fe7d36dfbc3e53e057b6555da74874dad05d086 Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 16:19:29 +1100 Subject: [PATCH 10/13] video, pdf, doc - preview generation fix --- Dockerfile | 8 +- README.md | 21 +- infra/cdk/lib/app-stack.ts | 2 +- infra/cdk/lib/cicd-stack.ts | 1 + infra/cdk/lib/config.ts | 2 + package.json | 4 +- src/app/gallery/page.tsx | 3 + .../[kind]/[mediaId]/[fileName]/route.ts | 12 +- src/app/share/[fileName]/route.ts | 6 +- .../[kind]/[mediaId]/[fileName]/route.ts | 4 +- src/components/gallery-client.tsx | 3 + src/components/gallery-tabs.tsx | 3 + .../viewers/file-viewer-content.tsx | 27 ++- src/lib/FileIconHelper.tsx | 19 ++ src/lib/media-storage.ts | 188 +++++------------- 15 files changed, 123 insertions(+), 180 deletions(-) diff --git a/Dockerfile b/Dockerfile index d21b39f..224f2df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,9 +34,11 @@ ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED=1 -# Needed for admin migration import route (`psql` / `pg_restore`) -# and document/text preview rendering (`fontconfig` + actual fonts). -RUN apk add --no-cache postgresql-client fontconfig ttf-dejavu \ +# Needed for admin migration import route (`psql` / `pg_restore`), +# document preview generation (`pdftoppm` + `soffice`), +# document/text preview rendering (`fontconfig` + actual fonts), +# and video preview frame extraction (`ffmpeg`). +RUN apk add --no-cache postgresql-client poppler-utils libreoffice fontconfig ttf-dejavu ffmpeg \ && fc-cache -f RUN addgroup --system --gid 1001 nodejs diff --git a/README.md b/README.md index 9a2a237..b712c86 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,15 @@ To run the app directly without docker, correctly set your ## TODO: +- [ ] alternative 'tiles' layout for 'guest' album view, button to switch between that and current fullwidth layout, and a slider to adjust tile size + + +### TODO LATERER: + +- [ ] TUI + +### DONE: + - [x] give album view the same controls / layout as gallery view - [x] next / prev buttons when viewing image in modal on a gallery / album - [x] support .gifs @@ -111,11 +120,9 @@ To run the app directly without docker, correctly set your - [x] ability to rename albums (just click on the title and start typing) - [x] ability to caption images in an album (caption applies to the image in the context of the image-in-that-album, not to the image, as images can be in more than one album) - [x] add landon's [ditherspace](https://landonjsmith.com/projects/ditherspace.html) to this app +- [x] encrypted S3 storage - [x] keyboard shortcuts for img view -- [ ] alternative 'tiles' layout for 'guest' album view, button to switch between that and current fullwidth layout, and a slider to adjust tile size - -### TODO LATERER: - -- [ ] TUI -- [ ] support video formats -- [ ] support any file format +- [x] password reset +- [x] support video formats +- [x] support any file format (documents, archives) +- [x] generate previews for documents \ No newline at end of file diff --git a/infra/cdk/lib/app-stack.ts b/infra/cdk/lib/app-stack.ts index 5b00a1c..032e05e 100644 --- a/infra/cdk/lib/app-stack.ts +++ b/infra/cdk/lib/app-stack.ts @@ -98,7 +98,7 @@ export class AppStack extends cdk.Stack { RATE_LIMIT_MAX_ATTEMPTS: "20", BILLING_ROLE_ARN: billingReaderRole.roleArn, BILLING_CE_REGION: "us-east-1", - PASSWORD_RESET_TOKEN_TTL_MINUTES: "30", + PASSWORD_RESET_TOKEN_TTL_MINUTES: props.config.passwordResetTokenTtlMinutes.toString(), }, secrets: { NEXTAUTH_SECRET: ecs.Secret.fromSecretsManager(props.appSecret, "NEXTAUTH_SECRET"), diff --git a/infra/cdk/lib/cicd-stack.ts b/infra/cdk/lib/cicd-stack.ts index 0ed2b5e..d14e621 100644 --- a/infra/cdk/lib/cicd-stack.ts +++ b/infra/cdk/lib/cicd-stack.ts @@ -39,6 +39,7 @@ export class CiCdStack extends cdk.Stack { "ssm:*", "sts:GetCallerIdentity", ], + // TODO: Restrict to only the necessary resources resources: ["*"], }), new iam.PolicyStatement({ diff --git a/infra/cdk/lib/config.ts b/infra/cdk/lib/config.ts index 6cbb5f8..3ebb194 100644 --- a/infra/cdk/lib/config.ts +++ b/infra/cdk/lib/config.ts @@ -17,11 +17,13 @@ export type EnvironmentConfig = { s3UseKmsEncryption: boolean; s3Versioned: boolean; s3NoncurrentVersionExpirationDays: number; + passwordResetTokenTtlMinutes: number; }; const BASE = { appName: "latex", region: "ap-southeast-2", + passwordResetTokenTtlMinutes: 30, } as const; const CONFIG_BY_ENV: Record> = { diff --git a/package.json b/package.json index d26927c..eac30f4 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next build && next dev --turbopack -p 3000", - "dev-quick": "next dev --turbopack -p 3000", + "devbuild": "next build && next dev --turbopack -p 3000", + "dev": "next dev --turbopack -p 3000", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/src/app/gallery/page.tsx b/src/app/gallery/page.tsx index f7db872..42383b1 100644 --- a/src/app/gallery/page.tsx +++ b/src/app/gallery/page.tsx @@ -33,6 +33,8 @@ export default async function GalleryPage({ getUserLastPatchNoteDismissed(userId), getAppSettings(), ]); + + console.log("isAdmin", isAdmin); const resolvedSearchParams = searchParams ? await searchParams : undefined; const initialTab = resolvedSearchParams?.tab === "albums" ? "albums" : "files"; const pageTitle = initialTab === "albums" ? "ur albums" : "ur gallery"; @@ -75,6 +77,7 @@ export default async function GalleryPage({ albums={albums.map((album) => ({ id: album.id, name: album.name }))} media={media} resumableThresholdBytes={settings.resumableThresholdBytes} + isAdmin={isAdmin} /> ); diff --git a/src/app/media/[kind]/[mediaId]/[fileName]/route.ts b/src/app/media/[kind]/[mediaId]/[fileName]/route.ts index 2cf496c..5672fb3 100644 --- a/src/app/media/[kind]/[mediaId]/[fileName]/route.ts +++ b/src/app/media/[kind]/[mediaId]/[fileName]/route.ts @@ -8,7 +8,6 @@ import { getMediaSignedUrl, getMediaRangeStream, getMediaStream, - pendingVideoPreviewPng, usesS3StorageBackend, } from "@/lib/media-storage"; @@ -89,16 +88,7 @@ export async function GET( ? "original" : parsed.size; if (parsedKind === "video" && parsed.size !== "original" && media.previewStatus !== "ready") { - const fallback = await pendingVideoPreviewPng(parsed.size); - return new Response(new Uint8Array(fallback), { - headers: { - "Content-Type": "image/png", - "Cache-Control": "private, no-store, max-age=0, must-revalidate", - Pragma: "no-cache", - Expires: "0", - Vary: "Cookie, Authorization", - }, - }); + return new Response("Not found", { status: 404 }); } const isRangeStreamableOriginal = requestedSize === "original" && diff --git a/src/app/share/[fileName]/route.ts b/src/app/share/[fileName]/route.ts index 744073b..91797f2 100644 --- a/src/app/share/[fileName]/route.ts +++ b/src/app/share/[fileName]/route.ts @@ -5,7 +5,6 @@ import { getMediaSignedUrl, getMediaRangeStream, getMediaStream, - pendingVideoPreviewPng, usesS3StorageBackend, } from "@/lib/media-storage"; import { getSharedMediaByCode, getSharedMediaByCodeAndExt } from "@/lib/media-store"; @@ -232,10 +231,7 @@ export async function GET( return withPublicImageCors(await unavailableImageResponse(parsed.ext)); } if (media.kind === "video" && media.previewStatus !== "ready" && parsed.size !== "original") { - const fallback = await pendingVideoPreviewPng(parsed.size === "sm" ? "sm" : "lg"); - return withPublicImageCors( - new Response(new Uint8Array(fallback), { headers: publicCacheHeaders("png") }), - ); + return withPublicImageCors(new Response("Not found", { status: 404 })); } const requestedSize = media.kind === "image" && media.ext.toLowerCase() === "svg" && parsed.size !== "original" diff --git a/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts b/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts index 993ec11..92dccc1 100644 --- a/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts +++ b/src/app/share/album/[shareId]/media/[kind]/[mediaId]/[fileName]/route.ts @@ -7,7 +7,6 @@ import { getMediaSignedUrl, getMediaRangeStream, getMediaStream, - pendingVideoPreviewPng, usesS3StorageBackend, } from "@/lib/media-storage"; import { unavailableImageResponse } from "@/lib/unavailable-image"; @@ -104,8 +103,7 @@ export async function GET( } if (media.kind === "video" && media.previewStatus !== "ready" && parsed.size !== "original") { - const fallback = await pendingVideoPreviewPng(parsed.size); - return withPublicCors(new Response(new Uint8Array(fallback), { headers: publicCacheHeaders("png") })); + return withPublicCors(new Response("Not found", { status: 404 })); } const requestedSize = diff --git a/src/components/gallery-client.tsx b/src/components/gallery-client.tsx index e9268a8..3d3dac3 100644 --- a/src/components/gallery-client.tsx +++ b/src/components/gallery-client.tsx @@ -78,6 +78,7 @@ export default function GalleryClient({ hideImagesInAlbums = false, kindFilter = "all", resumableThresholdBytes = DEFAULT_RESUMABLE_THRESHOLD, + isAdmin = false, }: { media: GalleryImage[]; onImagesChange?: (next: GalleryImage[]) => void; @@ -86,6 +87,7 @@ export default function GalleryClient({ hideImagesInAlbums?: boolean; kindFilter?: "all" | "image" | "video" | "document" | "other"; resumableThresholdBytes?: number; + isAdmin?: boolean; }) { const [items, setItems] = useState(media); const [active, setActive] = useState(null); @@ -1578,6 +1580,7 @@ export default function GalleryClient({ /> ) : ( )}
diff --git a/src/components/viewers/file-viewer-content.tsx b/src/components/viewers/file-viewer-content.tsx index 676b430..61570a3 100644 --- a/src/components/viewers/file-viewer-content.tsx +++ b/src/components/viewers/file-viewer-content.tsx @@ -1,9 +1,11 @@ "use client"; import { LightClock } from "@energiz3r/icon-library/Icons/Light/LightClock"; -import { getFileIconForExtension, isAudioExtension } from "@/lib/FileIconHelper"; +import { isAudioExtension, renderFileIconForExtension } from "@/lib/FileIconHelper"; +import { useSession } from "next-auth/react"; export function FileViewerContent({ + isAdmin, kind, previewStatus, fullUrl, @@ -21,10 +23,11 @@ export function FileViewerContent({ mimeType?: string; onRegenerateThumbnail?: () => void; isRegeneratingThumbnail?: boolean; + isAdmin?: boolean; }) { const iconClass = "h-12 w-12 text-neutral-500"; - + if (kind === "video") { return (
@@ -38,9 +41,12 @@ export function FileViewerContent({
- {previewStatus === "failed" ? "preview failed" : "preview pending"} + preview pending
- {onRegenerateThumbnail ? ( + +
+ ) : null} + {isAdmin && onRegenerateThumbnail ? (
- ) : null} ); } if (kind === "document") { if (previewStatus !== "ready") { - const Icon = getFileIconForExtension(ext); return (
- + {renderFileIconForExtension(ext, { className: iconClass, fill: "currentColor" })}
); } @@ -80,19 +83,15 @@ export function FileViewerContent({ return (
- {(() => { - const Icon = getFileIconForExtension(ext); - return ; - })()} + {renderFileIconForExtension(ext, { className: iconClass, fill: "currentColor" })}
); } - const Icon = getFileIconForExtension(ext); return (
- + {renderFileIconForExtension(ext, { className: iconClass, fill: "currentColor" })}
); } diff --git a/src/lib/FileIconHelper.tsx b/src/lib/FileIconHelper.tsx index 8caf430..8890b5e 100644 --- a/src/lib/FileIconHelper.tsx +++ b/src/lib/FileIconHelper.tsx @@ -48,3 +48,22 @@ export function getFileIconForExtension(ext?: string): IconComponent { if (DOCUMENT_TEXT_EXTENSIONS.has(normalized)) return LightFileAlt; return LightFile; } + +export function renderFileIconForExtension( + ext?: string, + props?: { className?: string; fill?: string }, +): React.ReactNode { + const normalized = normalizeExt(ext); + + if (IMAGE_EXTENSIONS.has(normalized)) return ; + if (VIDEO_EXTENSIONS.has(normalized)) return ; + if (AUDIO_EXTENSIONS.has(normalized)) return ; + if (ARCHIVE_EXTENSIONS.has(normalized)) return ; + if (PDF_EXTENSIONS.has(normalized)) return ; + if (CSV_EXTENSIONS.has(normalized)) return ; + if (SPREADSHEET_EXTENSIONS.has(normalized)) return ; + if (PRESENTATION_EXTENSIONS.has(normalized)) return ; + if (CODE_EXTENSIONS.has(normalized)) return ; + if (DOCUMENT_TEXT_EXTENSIONS.has(normalized)) return ; + return ; +} diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index affe3e0..6e57ded 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -1,7 +1,7 @@ import path from "path"; import os from "os"; import { createReadStream, promises as fs } from "fs"; -import { execFile, spawn } from "child_process"; +import { execFile } from "child_process"; import { Readable } from "stream"; import { promisify } from "util"; import sharp from "sharp"; @@ -51,6 +51,13 @@ const s3Client = }) : null; const execFileAsync = promisify(execFile); +function formatProcessError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return "Unknown process error."; +} + const MEDIA_DIRECT_URL_TTL_SECONDS = Number.parseInt( process.env.MEDIA_DIRECT_URL_TTL_SECONDS ?? "120", 10, @@ -97,40 +104,6 @@ function toWebReadableStream(body: unknown): ReadableStream { throw new Error("Unsupported storage response stream type."); } -function isAsyncIterableUint8Array(value: unknown): value is AsyncIterable { - return ( - typeof value === "object" && - value !== null && - typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function" - ); -} - -function webReaderToAsyncIterable(reader: { - read: () => Promise<{ done: boolean; value?: Uint8Array }>; - releaseLock?: () => void; -}): AsyncIterable { - return { - [Symbol.asyncIterator]() { - return { - async next() { - const result = await reader.read(); - if (result.done) { - return { done: true, value: undefined as Uint8Array | undefined }; - } - return { done: false, value: result.value ?? new Uint8Array() }; - }, - // eslint-disable-next-line @typescript-eslint/require-await - async return() { - if (typeof reader.releaseLock === "function") { - reader.releaseLock(); - } - return { done: true, value: undefined as Uint8Array | undefined }; - }, - }; - }, - }; -} - async function readWebStreamToBuffer(stream: ReadableStream): Promise { const reader = stream.getReader(); const chunks: Buffer[] = []; @@ -438,7 +411,8 @@ async function tryGeneratePdfPreview(buffer: Buffer): Promise { timeout: 20_000, }); return await fs.readFile(outputPath); - } catch { + } catch (error) { + console.warn(`[document-preview] PDF preview generation failed: ${formatProcessError(error)}`); return null; } finally { await fs.rm(tmpDir, { recursive: true, force: true }); @@ -460,7 +434,10 @@ async function tryGenerateOfficePreview(buffer: Buffer, ext: string): Promise { - return new Promise((resolve) => { - const ffmpeg = spawn( +async function tryGenerateVideoPreviewFromSource(source: string): Promise { + const tmpDir = await createWorkingDir("tanglepic-video-preview-"); + const outputPath = path.join(tmpDir, "preview.png"); + try { + await execFileAsync( "ffmpeg", [ "-hide_banner", "-loglevel", "error", - "-i", - "pipe:0", + "-nostdin", + "-threads", + "1", "-ss", "00:00:01", + "-i", + source, "-frames:v", "1", "-vf", - "scale='min(1024,iw)':-2", - "-f", - "image2pipe", - "-vcodec", - "png", - "pipe:1", + "scale='min(1024,iw)':-2:flags=lanczos", + "-an", + "-sn", + "-dn", + "-y", + outputPath, ], - { stdio: ["pipe", "pipe", "pipe"] }, + { timeout: 30_000 }, ); - const chunks: Buffer[] = []; - let stderr = ""; - const timeout = setTimeout(() => { - ffmpeg.kill("SIGKILL"); - }, 30_000); - - ffmpeg.stdout.on("data", (chunk: Buffer) => { - chunks.push(Buffer.from(chunk)); - }); - ffmpeg.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - ffmpeg.once("error", () => { - clearTimeout(timeout); - resolve(null); - }); - ffmpeg.once("close", (code) => { - clearTimeout(timeout); - if (code === 0 && chunks.length > 0) { - resolve(Buffer.concat(chunks)); - return; - } - if (stderr.length > 0) { - console.warn(`ffmpeg thumbnail generation failed: ${stderr}`); - } - resolve(null); - }); - input.once("error", () => { - ffmpeg.kill("SIGKILL"); - clearTimeout(timeout); - resolve(null); - }); - ffmpeg.stdin.on("error", () => { - // ignore broken pipe; close handler resolves outcome. - }); - input.pipe(ffmpeg.stdin); - }); -} - -async function openKeyNodeStream(key: string): Promise { - if (STORAGE_BACKEND === "s3") { - if (!s3Client || !S3_BUCKET) { - throw new Error("S3 is not configured."); - } - const response = await s3Client.send( - new GetObjectCommand({ - Bucket: S3_BUCKET, - Key: key, - }), - ); - const body = response.Body; - if (!body) { - throw new Error("Storage response body is empty."); - } - if (body instanceof Readable) { - return body; - } - if (typeof (body as { getReader?: unknown }).getReader === "function") { - const reader = ( - body as { - getReader: () => { - read: () => Promise<{ done: boolean; value?: Uint8Array }>; - releaseLock?: () => void; - }; - } - ).getReader(); - return Readable.from(webReaderToAsyncIterable(reader)); - } - if (isAsyncIterableUint8Array(body)) { - return Readable.from(body); - } - throw new Error("Unsupported storage response stream type."); - } - return createReadStream(absolutePathForKey(key)); -} - -export async function pendingVideoPreviewPng(size: Exclude): Promise { - const lg = await asPreviewPng("Preview Pending"); - if (size === "lg") { - return lg; + return await fs.readFile(outputPath); + } catch (error) { + const details = error instanceof Error ? error.message : "Unknown ffmpeg error"; + console.warn(`ffmpeg thumbnail generation failed: ${details}`); + return null; + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); } - return sharp(lg).resize({ width: 320, withoutEnlargement: true }).png().toBuffer(); } export async function storeGenericMediaFromBuffer(input: { @@ -941,10 +849,22 @@ export async function generateVideoPreviewFromStoredMedia(input: { uploadedAt: Date; }): Promise<{ sizeSm: number; sizeLg: number; width?: number; height?: number }> { const originalKey = buildStorageKey("video", input.baseName, input.ext, "original", input.uploadedAt); - const originalStream = await openKeyNodeStream(originalKey); - const videoFrame = await tryGenerateVideoPreviewFromStream(originalStream); - const lgBufferSource = videoFrame ?? (await asPreviewPng("Video Preview")); - const lgBuffer = await sharp(lgBufferSource) + const source = + STORAGE_BACKEND === "s3" + ? await getMediaSignedUrl({ + kind: "video", + baseName: input.baseName, + ext: input.ext, + size: "original", + uploadedAt: input.uploadedAt, + responseContentType: contentTypeForExt(input.ext), + }) + : absolutePathForKey(originalKey); + const videoFrame = await tryGenerateVideoPreviewFromSource(source); + if (!videoFrame) { + throw new Error("Unable to extract a preview frame from this video."); + } + const lgBuffer = await sharp(videoFrame) .resize({ width: 1024, withoutEnlargement: true }) .png() .toBuffer(); From 577dd6bab819fabd4ce0ff7058edf4282e21014e Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 17:10:22 +1100 Subject: [PATCH 11/13] hopefully fix office --- package.json | 4 +-- src/lib/media-storage.ts | 55 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index eac30f4..383b524 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "cdk:bootstrap:prod": "./scripts/infra/bootstrap-cdk.sh prod", "infra:image:push:dev": "./scripts/infra/build-and-push-image.sh dev", "infra:image:push:prod": "./scripts/infra/build-and-push-image.sh prod", - "infra:deploy:dev:app": "bash ./scripts/infra/deploy-app.sh dev", - "infra:deploy:prod:app": "bash ./scripts/infra/deploy-app.sh prod" + "infra:deploy:dev": "bash ./scripts/infra/deploy-app.sh dev", + "infra:deploy:prod": "bash ./scripts/infra/deploy-app.sh prod" }, "dependencies": { "@aws-sdk/client-cost-explorer": "^3.996.0", diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index 6e57ded..a28f984 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -52,6 +52,30 @@ const s3Client = : null; const execFileAsync = promisify(execFile); function formatProcessError(error: unknown): string { + if (!error || typeof error !== "object") { + return "Unknown process error."; + } + const maybeError = error as { + message?: unknown; + code?: unknown; + stdout?: unknown; + stderr?: unknown; + }; + const parts: string[] = []; + if (typeof maybeError.message === "string" && maybeError.message.trim().length > 0) { + parts.push(maybeError.message.trim()); + } + if (typeof maybeError.code === "string" && maybeError.code.trim().length > 0) { + parts.push(`code=${maybeError.code}`); + } else if (typeof maybeError.code === "number") { + parts.push(`code=${String(maybeError.code)}`); + } + if (typeof maybeError.stderr === "string" && maybeError.stderr.trim().length > 0) { + parts.push(`stderr=${maybeError.stderr.trim()}`); + } + if (parts.length > 0) { + return parts.join(" | "); + } if (error instanceof Error) { return error.message; } @@ -425,11 +449,36 @@ async function tryGenerateOfficePreview(buffer: Buffer, ext: string): Promise Date: Thu, 5 Mar 2026 17:59:40 +1100 Subject: [PATCH 12/13] logging for doc generation --- src/lib/media-storage.ts | 137 +++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 50 deletions(-) diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index a28f984..15d744d 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -1,6 +1,6 @@ import path from "path"; import os from "os"; -import { createReadStream, promises as fs } from "fs"; +import { constants as fsConstants, createReadStream, promises as fs } from "fs"; import { execFile } from "child_process"; import { Readable } from "stream"; import { promisify } from "util"; @@ -73,6 +73,9 @@ function formatProcessError(error: unknown): string { if (typeof maybeError.stderr === "string" && maybeError.stderr.trim().length > 0) { parts.push(`stderr=${maybeError.stderr.trim()}`); } + if (typeof maybeError.stdout === "string" && maybeError.stdout.trim().length > 0) { + parts.push(`stdout=${maybeError.stdout.trim()}`); + } if (parts.length > 0) { return parts.join(" | "); } @@ -82,16 +85,32 @@ function formatProcessError(error: unknown): string { return "Unknown process error."; } +async function writableStatusForDir(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + await fs.access(dirPath, fsConstants.W_OK); + const probePath = path.join( + dirPath, + `.writable-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + await fs.writeFile(probePath, "ok"); + await fs.rm(probePath, { force: true }); + return "yes"; + } catch (error) { + return `no (${formatProcessError(error)})`; + } +} + const MEDIA_DIRECT_URL_TTL_SECONDS = Number.parseInt( process.env.MEDIA_DIRECT_URL_TTL_SECONDS ?? "120", 10, ); const TMP_CANDIDATES = [ + path.join(DATA_DIR, "tmp"), process.env.TANGLEPIC_TMP_DIR, - "/dev/shm", "/var/tmp", os.tmpdir(), - path.join(process.cwd(), "data", "tmp"), + "/dev/shm", ].filter((value): value is string => Boolean(value && value.trim().length > 0)); function toWebReadableStream(body: unknown): ReadableStream { @@ -166,9 +185,9 @@ function buildStorageKey( return path.posix.join("uploads", year, month, day, kind, size, `${baseName}.${ext}`); } -async function createWorkingDir(prefix: string): Promise { +async function createWorkingDir(prefix: string, candidates: string[] = TMP_CANDIDATES): Promise { let lastError: Error | null = null; - for (const candidate of TMP_CANDIDATES) { + for (const candidate of candidates) { try { await fs.mkdir(candidate, { recursive: true }); return await fs.mkdtemp(path.join(candidate, prefix)); @@ -444,53 +463,71 @@ async function tryGeneratePdfPreview(buffer: Buffer): Promise { } async function tryGenerateOfficePreview(buffer: Buffer, ext: string): Promise { - const tmpDir = await createWorkingDir("tanglepic-office-"); - const inputPath = path.join(tmpDir, `input.${ext}`); - const pdfPath = path.join(tmpDir, "input.pdf"); - const outputPrefix = path.join(tmpDir, "preview"); - const outputPath = `${outputPrefix}.png`; - const officeProfilePath = path.join(tmpDir, "lo-profile"); - const officeProfileUri = `file://${officeProfilePath}`; - try { - await fs.mkdir(officeProfilePath, { recursive: true }); - await fs.writeFile(inputPath, buffer); - await execFileAsync( - "soffice", - [ - "--headless", - "--invisible", - "--nologo", - "--nodefault", - "--nolockcheck", - "--norestore", - `-env:UserInstallation=${officeProfileUri}`, - "--convert-to", - "pdf:writer_pdf_Export", - "--outdir", - tmpDir, - inputPath, - ], - { - timeout: 20_000, - env: { - ...process.env, - HOME: tmpDir, - TMPDIR: tmpDir, + const officeTmpCandidates = Array.from(new Set([path.join(DATA_DIR, "tmp"), ...TMP_CANDIDATES])); + const errorsByCandidate: string[] = []; + for (const candidate of officeTmpCandidates) { + let tmpDir = ""; + try { + const candidateWritable = await writableStatusForDir(candidate); + console.info( + `[document-preview] Office preview candidate check for .${ext}: path=${candidate} writable=${candidateWritable}`, + ); + tmpDir = await createWorkingDir("tanglepic-office-", [candidate]); + const inputPath = path.join(tmpDir, `input.${ext}`); + const pdfPath = path.join(tmpDir, "input.pdf"); + const outputPrefix = path.join(tmpDir, "preview"); + const outputPath = `${outputPrefix}.png`; + const officeProfilePath = path.join(tmpDir, "lo-profile"); + const officeProfileUri = `file://${officeProfilePath}`; + + await fs.mkdir(officeProfilePath, { recursive: true }); + const tmpDirWritable = await writableStatusForDir(tmpDir); + const profileDirWritable = await writableStatusForDir(officeProfilePath); + console.info( + `[document-preview] Office preview working dirs for .${ext}: tmpDir=${tmpDir} writable=${tmpDirWritable}; profileDir=${officeProfilePath} writable=${profileDirWritable}`, + ); + await fs.writeFile(inputPath, buffer); + await execFileAsync( + "soffice", + [ + "--headless", + "--invisible", + "--nologo", + "--nodefault", + "--nolockcheck", + "--norestore", + `-env:UserInstallation=${officeProfileUri}`, + "--convert-to", + "pdf:writer_pdf_Export", + "--outdir", + tmpDir, + inputPath, + ], + { + timeout: 20_000, + env: { + ...process.env, + HOME: tmpDir, + TMPDIR: tmpDir, + }, }, - }, - ); - await execFileAsync("pdftoppm", ["-f", "1", "-singlefile", "-png", pdfPath, outputPrefix], { - timeout: 20_000, - }); - return await fs.readFile(outputPath); - } catch (error) { - console.warn( - `[document-preview] Office preview generation failed for .${ext}: ${formatProcessError(error)}`, - ); - return null; - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); + ); + await execFileAsync("pdftoppm", ["-f", "1", "-singlefile", "-png", pdfPath, outputPrefix], { + timeout: 20_000, + }); + return await fs.readFile(outputPath); + } catch (error) { + errorsByCandidate.push(`${candidate}: ${formatProcessError(error)}`); + } finally { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } } + console.warn( + `[document-preview] Office preview generation failed for .${ext}: ${errorsByCandidate.join(" || ")}`, + ); + return null; } async function tryGenerateDocumentPreview( From 3375249b2a23204df14443e628bed7e0627c218c Mon Sep 17 00:00:00 2001 From: Tangles Date: Thu, 5 Mar 2026 18:29:17 +1100 Subject: [PATCH 13/13] cmon docs previews --- package.json | 1 + pnpm-lock.yaml | 169 +++++++++++++++++++++++++++++++++++++++ src/lib/media-storage.ts | 25 ++++++ 3 files changed, 195 insertions(+) diff --git a/package.json b/package.json index 383b524..376283d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.1", "lucide-react": "^0.544.0", + "mammoth": "^1.11.0", "next": "^15.5.10", "next-auth": "^4.24.11", "postgres": "^3.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc139ba..504822b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.2.0) + mammoth: + specifier: ^1.11.0 + version: 1.11.0 next: specifier: ^15.5.10 version: 15.5.12(@babel/core@7.29.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2636,6 +2639,10 @@ packages: cpu: [x64] os: [win32] + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2666,6 +2673,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2738,6 +2748,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -2751,6 +2764,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2873,6 +2889,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2962,6 +2981,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -3085,6 +3107,9 @@ packages: sqlite3: optional: true + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3528,6 +3553,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3536,6 +3564,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -3672,6 +3703,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3731,6 +3765,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3745,6 +3782,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -3842,6 +3882,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3857,6 +3900,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} engines: {node: '>= 18'} @@ -4109,6 +4157,9 @@ packages: openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4125,6 +4176,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4136,6 +4190,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4271,6 +4329,9 @@ packages: pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4368,6 +4429,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -4428,6 +4492,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -4473,6 +4540,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4522,6 +4592,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4558,6 +4631,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -4726,6 +4802,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4829,6 +4908,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -8190,6 +8273,8 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@xmldom/xmldom@0.8.11': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -8223,6 +8308,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -8316,12 +8405,16 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} bcryptjs@3.0.3: {} binary-extensions@2.3.0: {} + bluebird@3.4.7: {} + bowser@2.14.1: {} brace-expansion@1.1.12: @@ -8441,6 +8534,8 @@ snapshots: cookie@1.1.1: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -8517,6 +8612,8 @@ snapshots: didyoumean@1.2.2: {} + dingbat-to-unicode@1.0.1: {} + dlv@1.1.3: {} doctrine@2.1.0: @@ -8562,6 +8659,10 @@ snapshots: '@types/pg': 8.16.0 postgres: 3.4.8 + duck@0.1.12: + dependencies: + underscore: 1.13.8 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -9228,6 +9329,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -9235,6 +9338,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} internal-slot@1.1.0: @@ -9376,6 +9481,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isbot@5.1.34: {} @@ -9424,6 +9531,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9439,6 +9553,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -9508,6 +9626,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.8 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9524,6 +9648,19 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.8 + xmlbuilder: 10.1.1 + marked@14.0.0: {} math-intrinsics@1.1.0: {} @@ -9908,6 +10045,8 @@ snapshots: object-hash: 2.2.0 oidc-token-hash: 5.2.0 + option@0.2.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9931,6 +10070,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9947,6 +10088,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -10045,6 +10188,8 @@ snapshots: pretty-format@3.8.0: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -10146,6 +10291,16 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -10237,6 +10392,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -10286,6 +10443,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + sharp@0.33.5: dependencies: color: 4.2.3 @@ -10393,6 +10552,8 @@ snapshots: space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + stable-hash@0.0.5: {} standardwebhooks@1.0.0: @@ -10457,6 +10618,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -10657,6 +10822,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + underscore@1.13.8: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -10810,6 +10977,8 @@ snapshots: word-wrap@1.2.5: {} + xmlbuilder@10.1.1: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/src/lib/media-storage.ts b/src/lib/media-storage.ts index 15d744d..d23b9e6 100644 --- a/src/lib/media-storage.ts +++ b/src/lib/media-storage.ts @@ -5,6 +5,7 @@ import { execFile } from "child_process"; import { Readable } from "stream"; import { promisify } from "util"; import sharp from "sharp"; +import mammoth from "mammoth"; import { CopyObjectCommand, DeleteObjectCommand, @@ -530,6 +531,20 @@ async function tryGenerateOfficePreview(buffer: Buffer, ext: string): Promise { + try { + const result = await mammoth.extractRawText({ buffer }); + const text = result.value?.trim(); + if (!text) { + return null; + } + return asTextPreviewPng("DOCX preview", text); + } catch (error) { + console.warn(`[document-preview] DOCX text preview failed: ${formatProcessError(error)}`); + return null; + } +} + async function tryGenerateDocumentPreview( buffer: Buffer, ext: string, @@ -557,6 +572,16 @@ async function tryGenerateDocumentPreview( ...SPREADSHEET_EXTENSIONS, ...PRESENTATION_EXTENSIONS, ]); + if ( + normalizedExt === "docx" || + normalizedMime.includes("wordprocessingml.document") + ) { + const docxPreview = await tryGenerateDocxTextPreview(buffer); + if (docxPreview) { + return docxPreview; + } + } + if ( officeConvertibleExtensions.has(normalizedExt) || normalizedMime.includes("officedocument") ||