feat(auth+terms): add iron-session auth, per-session Nextcloud client; protect file/folder APIs; add Terms modal + gating; wire OnboardingGate; update envs and routes. Step 1 & 2 complete
This commit is contained in:
parent
eba3fe7bf2
commit
34e8b6389f
315
package-lock.json
generated
315
package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -32,7 +33,6 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-loading-skeleton": "^3.5.0",
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"recharts": "^3.2.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"umap-js": "^1.4.0",
|
"umap-js": "^1.4.0",
|
||||||
@ -4640,32 +4640,6 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^10.0.3",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/plugin-commonjs": {
|
"node_modules/@rollup/plugin-commonjs": {
|
||||||
"version": "28.0.1",
|
"version": "28.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz",
|
||||||
@ -5559,18 +5533,6 @@
|
|||||||
"integrity": "sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==",
|
"integrity": "sha512-ARhyoYDnY1LES3vYI0fiG6e9esWfTNcXcO6+MPJJXcnyMV3bim4lnFt45VXouV7y82F4x3YH8nOQ6VztuvUiWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -6279,27 +6241,6 @@
|
|||||||
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
|
"integrity": "sha512-5sNP3DmtSnSozxcjqmzQKsDOuVJXZkceo1KJScDc1982kk/TS9mTPc6lpli1gTu1MIBF1YWutpHpjucNWcIj5g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-ease": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-interpolate": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-color": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-path": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale": {
|
"node_modules/@types/d3-scale": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-3.3.5.tgz",
|
||||||
@ -6309,27 +6250,12 @@
|
|||||||
"@types/d3-time": "^2"
|
"@types/d3-time": "^2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
|
||||||
"version": "3.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
|
||||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-path": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time": {
|
"node_modules/@types/d3-time": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-2.1.4.tgz",
|
||||||
"integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==",
|
"integrity": "sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@ -6482,12 +6408,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.43.0",
|
"version": "8.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz",
|
||||||
@ -8629,6 +8549,15 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-assert": {
|
"node_modules/core-assert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
|
||||||
@ -8726,15 +8655,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||||
@ -8762,15 +8682,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
"node_modules/d3-scale": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
@ -8787,18 +8698,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
"node_modules/d3-time": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
@ -8823,15 +8722,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@ -8994,12 +8884,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/deck.gl": {
|
"node_modules/deck.gl": {
|
||||||
"version": "9.1.14",
|
"version": "9.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/deck.gl/-/deck.gl-9.1.14.tgz",
|
||||||
@ -9402,16 +9286,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.39.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
|
||||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz",
|
||||||
@ -9939,12 +9813,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
@ -10803,16 +10671,6 @@
|
|||||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
|
||||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@ -10892,6 +10750,30 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iron-session": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/vvo",
|
||||||
|
"https://github.com/sponsors/brc-dd"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^0.7.2",
|
||||||
|
"iron-webcrypto": "^1.2.1",
|
||||||
|
"uncrypto": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iron-webcrypto": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/brc-dd"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-any-array": {
|
"node_modules/is-any-array": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-0.1.1.tgz",
|
||||||
@ -13367,29 +13249,6 @@
|
|||||||
"react": ">=16.8.0"
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||||
@ -13498,48 +13357,6 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-fX0xCgNXo6mag9wz3oLuANR+dUQM4uIlTYBGTGq9CBRgW/8TZPzqPGYs5NTt8aENCf+i1CI8vqxT1py8L/5J2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"decimal.js-light": "^2.5.1",
|
|
||||||
"es-toolkit": "^1.39.3",
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"immer": "^10.1.1",
|
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
|
||||||
"reselect": "5.1.1",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -13614,12 +13431,6 @@
|
|||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@ -14851,12 +14662,6 @@
|
|||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tiny-invariant": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@ -15220,6 +15025,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uncrypto": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.21.3",
|
"version": "6.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
||||||
@ -15384,15 +15195,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@ -15412,43 +15214,6 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/victory-vendor": {
|
|
||||||
"version": "37.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
|
||||||
"license": "MIT AND ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "^3.0.3",
|
|
||||||
"@types/d3-ease": "^3.0.0",
|
|
||||||
"@types/d3-interpolate": "^3.0.1",
|
|
||||||
"@types/d3-scale": "^4.0.2",
|
|
||||||
"@types/d3-shape": "^3.1.0",
|
|
||||||
"@types/d3-time": "^3.0.0",
|
|
||||||
"@types/d3-timer": "^3.0.0",
|
|
||||||
"d3-array": "^3.1.6",
|
|
||||||
"d3-ease": "^3.0.1",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-shape": "^3.1.0",
|
|
||||||
"d3-time": "^3.0.0",
|
|
||||||
"d3-timer": "^3.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/victory-vendor/node_modules/@types/d3-scale": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-time": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/victory-vendor/node_modules/@types/d3-time": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.20",
|
"version": "5.4.20",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"deck.gl": "^9.1.14",
|
"deck.gl": "^9.1.14",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
"iron-session": "^8.0.4",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "15.5.3",
|
"next": "15.5.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -38,7 +39,6 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-loading-skeleton": "^3.5.0",
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"recharts": "^3.2.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"umap-js": "^1.4.0",
|
"umap-js": "^1.4.0",
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getActivity } from "@/lib/analytics";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
|
||||||
return NextResponse.json(data, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const from = searchParams.get("from") || undefined;
|
|
||||||
const to = searchParams.get("to") || undefined;
|
|
||||||
const interval = (searchParams.get("interval") as "day" | "week" | "month") || "day";
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "analytics.activity" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("interval", interval);
|
|
||||||
if (from) span.setAttribute("from", from);
|
|
||||||
if (to) span.setAttribute("to", to);
|
|
||||||
const data = await getActivity({ from, to, interval });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to fetch activity", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getFileTypes } from "@/lib/analytics";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
|
||||||
return NextResponse.json(data, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const limit = Number(searchParams.get("limit") || "20") || 20;
|
|
||||||
const withSize = searchParams.get("withSize") === "true";
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "analytics.file-types" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("limit", limit);
|
|
||||||
span.setAttribute("withSize", withSize);
|
|
||||||
const data = await getFileTypes({ limit, withSize });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to fetch file types", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getSummary } from "@/lib/analytics";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
|
||||||
return NextResponse.json(data, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const ownersLimit = Number(searchParams.get("ownersLimit") || "10") || 10;
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "analytics.summary" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("ownersLimit", ownersLimit);
|
|
||||||
const summary = await getSummary({ ownersLimit });
|
|
||||||
return summary;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to fetch analytics summary", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getTopTags } from "@/lib/analytics";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
|
||||||
return NextResponse.json(data, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const limit = Number(searchParams.get("limit") || "20") || 20;
|
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
|
||||||
{ op: "db.query", name: "analytics.tags" },
|
|
||||||
async (span) => {
|
|
||||||
span.setAttribute("limit", limit);
|
|
||||||
const data = await getTopTags({ limit });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
Sentry.captureException(error);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return json({ error: "Failed to fetch top tags", message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
src/app/api/auth/session/route.ts
Normal file
42
src/app/api/auth/session/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readAuth } from "@/lib/session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.auth.session" },
|
||||||
|
async (span) => {
|
||||||
|
const auth = await readAuth(req);
|
||||||
|
if (!auth) {
|
||||||
|
span.setAttribute("authenticated", false);
|
||||||
|
return { authenticated: false as const };
|
||||||
|
}
|
||||||
|
span.setAttribute("authenticated", true);
|
||||||
|
span.setAttribute("username", auth.username);
|
||||||
|
return {
|
||||||
|
authenticated: true as const,
|
||||||
|
username: auth.username,
|
||||||
|
createdAt: auth.createdAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return json(result);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "SESSION_CHECK_FAILED",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/app/api/auth/signin/route.ts
Normal file
121
src/app/api/auth/signin/route.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { NextcloudClient } from "@/lib/webdav";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { joinPath, parentPath } from "@/lib/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type SignInBody = {
|
||||||
|
baseUrl?: string;
|
||||||
|
username?: string;
|
||||||
|
appPassword?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(input: string): string {
|
||||||
|
// Throws on invalid URL
|
||||||
|
const u = new URL(input);
|
||||||
|
let s = u.toString();
|
||||||
|
if (s.endsWith("/")) s = s.slice(0, -1);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveRootPath(username: string): string {
|
||||||
|
// Use the pattern of the configured root path to substitute the username.
|
||||||
|
// Example: /remote.php/dav/files/admin -> /remote.php/dav/files/{username}
|
||||||
|
const baseParent = parentPath(env.NEXTCLOUD_ROOT_PATH) || "/remote.php/dav/files";
|
||||||
|
return joinPath(baseParent, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as SignInBody;
|
||||||
|
const baseUrlRaw = body.baseUrl?.trim();
|
||||||
|
const username = body.username?.trim();
|
||||||
|
const appPassword = body.appPassword?.trim();
|
||||||
|
|
||||||
|
if (!baseUrlRaw || !username || !appPassword) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "INVALID_REQUEST",
|
||||||
|
message: "baseUrl, username, and appPassword are required",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.auth.signin" },
|
||||||
|
async (span) => {
|
||||||
|
let baseUrl: string;
|
||||||
|
try {
|
||||||
|
baseUrl = normalizeBaseUrl(baseUrlRaw);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 400 as const,
|
||||||
|
payload: { error: "INVALID_BASE_URL", message: "baseUrl must be a valid URL" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
span.setAttribute("baseUrl", baseUrl);
|
||||||
|
span.setAttribute("username", username);
|
||||||
|
|
||||||
|
// Build a per-session client rooted to the user's files path
|
||||||
|
const rootPath = deriveRootPath(username);
|
||||||
|
const client = new NextcloudClient(rootPath, {
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
appPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate credentials with a lightweight directory listing
|
||||||
|
try {
|
||||||
|
await client.listDirectory();
|
||||||
|
} catch (err) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401 as const,
|
||||||
|
payload: {
|
||||||
|
error: "INVALID_CREDENTIALS",
|
||||||
|
message: "Failed to authenticate with Nextcloud WebDAV",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist session
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
const session = await getSession(req, res);
|
||||||
|
session.auth = {
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
appPassword,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
await session.save();
|
||||||
|
return { ok: true, status: 200 as const, response: res };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return json(result.payload, { status: result.status });
|
||||||
|
}
|
||||||
|
return result.response!;
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "SIGNIN_FAILED",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/auth/signout/route.ts
Normal file
33
src/app/api/auth/signout/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSession, destroySession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.auth.signout" },
|
||||||
|
async () => {
|
||||||
|
const session = await getSession(req, res);
|
||||||
|
await destroySession(session);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "SIGNOUT_FAILED",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -20,11 +20,19 @@ export async function GET(req: Request) {
|
|||||||
}
|
}
|
||||||
const path = normalizePath(rawPath);
|
const path = normalizePath(rawPath);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.content.get" },
|
{ op: "function", name: "api.files.content.get" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
const res = await nextcloud.readText(path);
|
const res = await client.readText(path);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -52,13 +60,21 @@ export async function PUT(req: Request) {
|
|||||||
const path = normalizePath(body.path);
|
const path = normalizePath(body.path);
|
||||||
const mime = body.mimeType || "text/markdown";
|
const mime = body.mimeType || "text/markdown";
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.content.put" },
|
{ op: "function", name: "api.files.content.put" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
span.setAttribute("content.length", body.content!.length);
|
span.setAttribute("content.length", body.content!.length);
|
||||||
span.setAttribute("mimeType", mime);
|
span.setAttribute("mimeType", mime);
|
||||||
const res = await nextcloud.writeText(path, body.content!, mime);
|
const res = await client.writeText(path, body.content!, mime);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -24,12 +24,20 @@ export async function POST(req: Request) {
|
|||||||
const from = normalizePath(body.from);
|
const from = normalizePath(body.from);
|
||||||
const to = normalizePath(body.to);
|
const to = normalizePath(body.to);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.copy" },
|
{ op: "function", name: "api.files.copy" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path_from", from);
|
span.setAttribute("path_from", from);
|
||||||
span.setAttribute("path_to", to);
|
span.setAttribute("path_to", to);
|
||||||
const res = await nextcloud.copyFile(from, to);
|
const res = await client.copyFile(from, to);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -22,11 +22,19 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const path = normalizePath(body.path);
|
const path = normalizePath(body.path);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.delete" },
|
{ op: "function", name: "api.files.delete" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
const res = await nextcloud.deletePath(path);
|
const res = await client.deletePath(path);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
@ -18,15 +18,23 @@ export async function GET(req: Request) {
|
|||||||
if (!rawPath) return badRequest("Missing path");
|
if (!rawPath) return badRequest("Missing path");
|
||||||
const path = normalizePath(rawPath);
|
const path = normalizePath(rawPath);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.download" },
|
{ op: "function", name: "api.files.download" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
|
|
||||||
// Optionally stat to get metadata (mime/name). Non-fatal if it fails.
|
// Optionally stat to get metadata (mime/name). Non-fatal if it fails.
|
||||||
const stat = await nextcloud.stat(path).catch(() => null);
|
const stat = await client.stat(path).catch(() => null);
|
||||||
|
|
||||||
const nodeStream = await nextcloud.downloadStream(path);
|
const nodeStream = await client.downloadStream(path);
|
||||||
// Convert Node.js readable to Web ReadableStream
|
// Convert Node.js readable to Web ReadableStream
|
||||||
const asReadable = Readable as unknown as {
|
const asReadable = Readable as unknown as {
|
||||||
toWeb?: (s: NodeJS.ReadableStream) => ReadableStream;
|
toWeb?: (s: NodeJS.ReadableStream) => ReadableStream;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath, parentPath } from "@/lib/paths";
|
import { normalizePath, parentPath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -20,12 +20,20 @@ export async function GET(req: Request) {
|
|||||||
const perPage = Math.min(100, Math.max(1, Number(searchParams.get("perPage") || "25") || 25));
|
const perPage = Math.min(100, Math.max(1, Number(searchParams.get("perPage") || "25") || 25));
|
||||||
const from = (page - 1) * perPage;
|
const from = (page - 1) * perPage;
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.list" },
|
{ op: "function", name: "api.files.list" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
if (path) span.setAttribute("path", path);
|
if (path) span.setAttribute("path", path);
|
||||||
|
|
||||||
const entries = await nextcloud.listDirectory(path);
|
const entries = await client.listDirectory(path);
|
||||||
const files = entries
|
const files = entries
|
||||||
.filter((e) => !e.isDirectory)
|
.filter((e) => !e.isDirectory)
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -24,12 +24,20 @@ export async function POST(req: Request) {
|
|||||||
const from = normalizePath(body.from);
|
const from = normalizePath(body.from);
|
||||||
const to = normalizePath(body.to);
|
const to = normalizePath(body.to);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.rename" },
|
{ op: "function", name: "api.files.rename" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path_from", from);
|
span.setAttribute("path_from", from);
|
||||||
span.setAttribute("path_to", to);
|
span.setAttribute("path_to", to);
|
||||||
const res = await nextcloud.moveFile(from, to);
|
const res = await client.moveFile(from, to);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getEsClient } from "@/lib/elasticsearch";
|
import { getEsClient } from "@/lib/elasticsearch";
|
||||||
|
import { readAuth } from "@/lib/session";
|
||||||
|
import { unauthorizedJson } from "@/lib/nextcloud-session";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -21,6 +23,11 @@ export async function GET(req: Request) {
|
|||||||
return json({ error: "path is required" }, { status: 400 });
|
return json({ error: "path is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = await readAuth(req);
|
||||||
|
if (!auth) {
|
||||||
|
return unauthorizedJson();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "db.query", name: "ES list tag history" },
|
{ op: "db.query", name: "ES list tag history" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { getEsClient } from "@/lib/elasticsearch";
|
|||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { pathToId } from "@/lib/paths";
|
import { pathToId } from "@/lib/paths";
|
||||||
import type { TagHistoryEvent } from "@/types/files";
|
import type { TagHistoryEvent } from "@/types/files";
|
||||||
|
import { readAuth } from "@/lib/session";
|
||||||
|
import { unauthorizedJson } from "@/lib/nextcloud-session";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -25,6 +27,12 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const path = body.path;
|
const path = body.path;
|
||||||
const tags = body.tags;
|
const tags = body.tags;
|
||||||
|
|
||||||
|
const auth = await readAuth(req);
|
||||||
|
if (!auth) {
|
||||||
|
return unauthorizedJson();
|
||||||
|
}
|
||||||
|
|
||||||
const id = pathToId(path);
|
const id = pathToId(path);
|
||||||
const index = env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
|
const index = env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
|
||||||
const eventsIndex = "files_events";
|
const eventsIndex = "files_events";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath, parentPath } from "@/lib/paths";
|
import { normalizePath, parentPath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -29,6 +29,14 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const destPath = normalizePath(rawDest);
|
const destPath = normalizePath(rawDest);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.files.upload" },
|
{ op: "function", name: "api.files.upload" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
@ -37,7 +45,7 @@ export async function POST(req: Request) {
|
|||||||
span.setAttribute("filename", file.name);
|
span.setAttribute("filename", file.name);
|
||||||
if (file.type) span.setAttribute("mimeType", file.type);
|
if (file.type) span.setAttribute("mimeType", file.type);
|
||||||
|
|
||||||
const upload = await nextcloud.uploadFile(destPath, file, file.type || undefined);
|
const upload = await client.uploadFile(destPath, file, file.type || undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: upload.ok,
|
ok: upload.ok,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath } from "@/lib/paths";
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -19,11 +19,19 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const path = normalizePath(rawPath);
|
const path = normalizePath(rawPath);
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await Sentry.startSpan(
|
const result = await Sentry.startSpan(
|
||||||
{ op: "function", name: "api.folders.create" },
|
{ op: "function", name: "api.folders.create" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
const res = await nextcloud.createFolder(path);
|
const res = await client.createFolder(path);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { nextcloud } from "@/lib/webdav";
|
import { requireClient, unauthorizedJson, UnauthorizedError } from "@/lib/nextcloud-session";
|
||||||
import { normalizePath, parentPath } from "@/lib/paths";
|
import { normalizePath, parentPath } from "@/lib/paths";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@ -16,13 +16,21 @@ export async function GET(req: Request) {
|
|||||||
const rawPath = searchParams.get("path") || undefined;
|
const rawPath = searchParams.get("path") || undefined;
|
||||||
const path = rawPath ? normalizePath(rawPath) : undefined;
|
const path = rawPath ? normalizePath(rawPath) : undefined;
|
||||||
|
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await requireClient(req);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedError) return unauthorizedJson();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const span = Sentry.startSpan(
|
const span = Sentry.startSpan(
|
||||||
{ op: "function", name: "api.folders.list" },
|
{ op: "function", name: "api.folders.list" },
|
||||||
async (span) => {
|
async (span) => {
|
||||||
if (path) {
|
if (path) {
|
||||||
span.setAttribute("path", path);
|
span.setAttribute("path", path);
|
||||||
}
|
}
|
||||||
const entries = await nextcloud.listDirectory(path);
|
const entries = await client.listDirectory(path);
|
||||||
// Provide folder meta and a basic partition for folders vs files
|
// Provide folder meta and a basic partition for folders vs files
|
||||||
const folders = entries
|
const folders = entries
|
||||||
.filter((e) => e.isDirectory)
|
.filter((e) => e.isDirectory)
|
||||||
|
|||||||
34
src/app/api/terms/accept/route.ts
Normal file
34
src/app/api/terms/accept/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { acceptTerms } from "@/lib/terms";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(_req: Request) {
|
||||||
|
try {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.terms.accept" },
|
||||||
|
async (span) => {
|
||||||
|
// Accept current terms by setting versioned cookie
|
||||||
|
acceptTerms(res);
|
||||||
|
span.setAttribute("accepted", true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "TERMS_ACCEPT_FAILED",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/api/terms/status/route.ts
Normal file
35
src/app/api/terms/status/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getTermsAccepted, getTermsVersion } from "@/lib/terms";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.terms.status" },
|
||||||
|
async (span) => {
|
||||||
|
const accepted = getTermsAccepted(req);
|
||||||
|
const version = getTermsVersion();
|
||||||
|
span.setAttribute("accepted", accepted);
|
||||||
|
span.setAttribute("version", version);
|
||||||
|
return { accepted, version };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return json(result);
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: "TERMS_STATUS_FAILED",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,51 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { KPICards } from "@/components/dashboard/kpis";
|
|
||||||
import { FileTypesChart } from "@/components/dashboard/charts/file-types-chart";
|
|
||||||
import { ActivityChart } from "@/components/dashboard/charts/activity-chart";
|
|
||||||
import { TopTags } from "@/components/dashboard/charts/top-tags";
|
|
||||||
import { VectorPanel } from "@/components/dashboard/vector-panel";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function handleDrillSearch(tag: string) {
|
|
||||||
// For now, surface a hint; in a future PR we can wire a tag filter to the search API directly from the dashboard
|
|
||||||
// Here we just navigate to the home page; the user can use the global search input
|
|
||||||
alert(`Search for tag: ${tag}. Use the global search box on the home page.`);
|
|
||||||
router.push("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col">
|
|
||||||
<header className="border-b p-3 flex items-center gap-3">
|
|
||||||
<div className="text-lg font-semibold">Dashboard</div>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<Button variant="outline" onClick={() => router.push("/")}>Open Explorer</Button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="p-3 flex-1 overflow-auto">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* KPIs */}
|
|
||||||
<KPICards />
|
|
||||||
|
|
||||||
{/* Charts row */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
<FileTypesChart />
|
|
||||||
<ActivityChart />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags + Vector Panel */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
||||||
<TopTags onDrillSearch={handleDrillSearch} />
|
|
||||||
<VectorPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryProvider } from "@/components/providers/query-provider";
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
|
import { OnboardingGate } from "@/components/onboarding/onboarding-gate";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -28,7 +29,9 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryProvider>{children}</QueryProvider>
|
<QueryProvider>
|
||||||
|
<OnboardingGate>{children}</OnboardingGate>
|
||||||
|
</QueryProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
133
src/components/auth/signin-dialog.tsx
Normal file
133
src/components/auth/signin-dialog.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignInBody = {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
appPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignInDialog({ open, onOpenChange }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [baseUrl, setBaseUrl] = React.useState("");
|
||||||
|
const [username, setUsername] = React.useState("");
|
||||||
|
const [appPassword, setAppPassword] = React.useState("");
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const disabled = !baseUrl || !username || !appPassword;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (payload: SignInBody) => {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "ui.click", name: "auth.signin" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("baseUrl", payload.baseUrl);
|
||||||
|
span.setAttribute("username", payload.username);
|
||||||
|
const res = await fetch("/api/auth/signin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const msg =
|
||||||
|
typeof data?.message === "string"
|
||||||
|
? data.message
|
||||||
|
: `Sign-in failed (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
setError(null);
|
||||||
|
// Invalidate session status so OnboardingGate refreshes
|
||||||
|
await qc.invalidateQueries({ queryKey: ["auth", "session"] });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(msg);
|
||||||
|
Sentry.captureException(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (disabled || mutation.isPending) return;
|
||||||
|
mutation.mutate({ baseUrl, username, appPassword });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Sign in to Nextcloud</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your Nextcloud Base URL, username, and app password. Your credentials are stored securely in a server-side session.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="grid gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Base URL</span>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://your-nextcloud.example.com"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Username</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">App Password</span>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="app password"
|
||||||
|
value={appPassword}
|
||||||
|
onChange={(e) => setAppPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled || mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Signing in..." : "Sign In"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { ActivitySeries } from "@/types/analytics";
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
CartesianGrid,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
async function fetchActivity(
|
|
||||||
from?: string,
|
|
||||||
to?: string,
|
|
||||||
interval: "day" | "week" | "month" = "day",
|
|
||||||
): Promise<ActivitySeries> {
|
|
||||||
const url = new URL("/api/analytics/activity", window.location.origin);
|
|
||||||
if (from) url.searchParams.set("from", from);
|
|
||||||
if (to) url.searchParams.set("to", to);
|
|
||||||
url.searchParams.set("interval", interval);
|
|
||||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load activity (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as ActivitySeries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivityChart() {
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: ["analytics", "activity", "day"],
|
|
||||||
queryFn: () => fetchActivity(undefined, undefined, "day"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data =
|
|
||||||
q.data?.points.map((p) => ({
|
|
||||||
date: p.date.slice(0, 10),
|
|
||||||
uploaded: p.uploaded ?? 0,
|
|
||||||
modified: p.modified ?? 0,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-3">
|
|
||||||
<div className="text-sm font-medium mb-2">Activity</div>
|
|
||||||
<div className="h-64">
|
|
||||||
{q.isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground">No data</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={data}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "hsl(var(--popover))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
color: "hsl(var(--popover-foreground))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="uploaded"
|
|
||||||
name="Uploaded"
|
|
||||||
stroke="hsl(var(--chart-2))"
|
|
||||||
fill="hsl(var(--chart-2))"
|
|
||||||
fillOpacity={0.25}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="modified"
|
|
||||||
name="Modified"
|
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
fillOpacity={0.25}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { FileTypesResponse } from "@/types/analytics";
|
|
||||||
import {
|
|
||||||
ResponsiveContainer,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
CartesianGrid,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
async function fetchFileTypes(limit = 15, withSize = false): Promise<FileTypesResponse> {
|
|
||||||
const url = new URL("/api/analytics/file-types", window.location.origin);
|
|
||||||
url.searchParams.set("limit", String(limit));
|
|
||||||
url.searchParams.set("withSize", String(withSize));
|
|
||||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load file types (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as FileTypesResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileTypesChart() {
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: ["analytics", "file-types"],
|
|
||||||
queryFn: () => fetchFileTypes(15, false),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data =
|
|
||||||
q.data?.buckets.map((b) => ({
|
|
||||||
name: b.mimeType || "unknown",
|
|
||||||
count: b.count,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-3">
|
|
||||||
<div className="text-sm font-medium mb-2">File Types</div>
|
|
||||||
<div className="h-64">
|
|
||||||
{q.isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<div className="text-xs text-muted-foreground">No data</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={data}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
|
||||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
|
||||||
<YAxis tick={{ fontSize: 11 }} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "hsl(var(--popover))",
|
|
||||||
border: "1px solid hsl(var(--border))",
|
|
||||||
color: "hsl(var(--popover-foreground))",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="count" fill="hsl(var(--primary))" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { TagsResponse } from "@/types/analytics";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
async function fetchTopTags(limit = 20): Promise<TagsResponse> {
|
|
||||||
const url = new URL("/api/analytics/tags", window.location.origin);
|
|
||||||
url.searchParams.set("limit", String(limit));
|
|
||||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load tags (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as TagsResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopTags({
|
|
||||||
onDrillSearch,
|
|
||||||
}: {
|
|
||||||
onDrillSearch?: (tag: string) => void;
|
|
||||||
}) {
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: ["analytics", "top-tags"],
|
|
||||||
queryFn: () => fetchTopTags(20),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = q.data?.buckets ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-3 flex flex-col">
|
|
||||||
<div className="text-sm font-medium mb-2">Top Tags</div>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{q.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
|
||||||
{!q.isLoading && data.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">No tags</div>
|
|
||||||
)}
|
|
||||||
{data.map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.tag}
|
|
||||||
className="flex items-center justify-between rounded border px-2 py-1"
|
|
||||||
title={t.tag}
|
|
||||||
>
|
|
||||||
<div className="truncate">{t.tag || "(empty)"}</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-xs text-muted-foreground">{t.count.toLocaleString()}</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onDrillSearch?.(t.tag)}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { AnalyticsSummary } from "@/types/analytics";
|
|
||||||
|
|
||||||
async function fetchSummary(ownersLimit = 8): Promise<AnalyticsSummary> {
|
|
||||||
const url = new URL("/api/analytics/summary", window.location.origin);
|
|
||||||
url.searchParams.set("ownersLimit", String(ownersLimit));
|
|
||||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load summary (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as AnalyticsSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes?: number) {
|
|
||||||
const b = Number(bytes || 0);
|
|
||||||
if (!b) return "0 B";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
||||||
const i = Math.floor(Math.log(b) / Math.log(k));
|
|
||||||
return `${parseFloat((b / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KPICards() {
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: ["analytics", "summary"],
|
|
||||||
queryFn: () => fetchSummary(8),
|
|
||||||
});
|
|
||||||
|
|
||||||
const s = q.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
||||||
<KPI title="Total Files" value={q.isLoading ? "…" : (s?.totalFiles ?? 0).toLocaleString()} />
|
|
||||||
<KPI title="Total Size" value={q.isLoading ? "…" : formatBytes(s?.totalSizeBytes)} />
|
|
||||||
<KPI title="Unique Tags" value={q.isLoading ? "…" : (s?.uniqueTags ?? 0).toLocaleString()} />
|
|
||||||
<KPI
|
|
||||||
title="Last Modified"
|
|
||||||
value={q.isLoading ? "…" : (s?.lastModifiedAt ? new Date(s.lastModifiedAt).toLocaleString() : "—")}
|
|
||||||
/>
|
|
||||||
{/* Owners list */}
|
|
||||||
<div className="rounded-md border p-3 col-span-1 md:col-span-2">
|
|
||||||
<div className="text-sm font-medium mb-1">Top Owners</div>
|
|
||||||
{q.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
|
||||||
{!q.isLoading && (s?.owners?.length ?? 0) === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground">No owner data</div>
|
|
||||||
)}
|
|
||||||
<ul className="text-sm space-y-1">
|
|
||||||
{(s?.owners ?? []).map((o) => (
|
|
||||||
<li key={o.owner} className="flex items-center justify-between">
|
|
||||||
<span className="truncate">{o.owner || "unknown"}</span>
|
|
||||||
<span className="text-muted-foreground">{o.count.toLocaleString()}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-3">
|
|
||||||
<div className="text-sm font-medium mb-1">Last Uploaded</div>
|
|
||||||
<div className="text-lg">
|
|
||||||
{q.isLoading ? "…" : (s?.lastUploadedAt ? new Date(s.lastUploadedAt).toLocaleString() : "—")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KPI({ title, value }: { title: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-3">
|
|
||||||
<div className="text-sm text-muted-foreground">{title}</div>
|
|
||||||
<div className="text-2xl font-semibold mt-1">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { EmbeddingScatter, type EmbeddingPoint } from "@/components/qdrant/embedding-scatter";
|
|
||||||
|
|
||||||
type CollectionsResp = { collections: Array<{ name: string; points_count?: number; vectors_count?: number }> };
|
|
||||||
type PointsResp = { points: Array<{ id: string | number; payload?: Record<string, unknown>; vector?: number[] | Record<string, number> }>; next_page_offset?: string | number | null };
|
|
||||||
|
|
||||||
async function fetchCollections(): Promise<CollectionsResp> {
|
|
||||||
const res = await fetch("/api/qdrant/collections", { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load collections (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as CollectionsResp;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPoints(collection: string, limit = 100, offset?: string | number | null, withVector = true): Promise<PointsResp> {
|
|
||||||
const url = new URL("/api/qdrant/points", window.location.origin);
|
|
||||||
url.searchParams.set("collection", collection);
|
|
||||||
url.searchParams.set("limit", String(limit));
|
|
||||||
if (offset != null) url.searchParams.set("offset", String(offset));
|
|
||||||
url.searchParams.set("withVector", String(withVector));
|
|
||||||
const res = await fetch(url.toString(), { cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const payload = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(payload?.message || `Failed to load points (${res.status})`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as PointsResp;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VectorPanel() {
|
|
||||||
const [selectedCollection, setSelectedCollection] = React.useState<string>("");
|
|
||||||
const [limit, setLimit] = React.useState(200);
|
|
||||||
const [withVector, setWithVector] = React.useState(true);
|
|
||||||
const [offset, setOffset] = React.useState<string | number | null>(null);
|
|
||||||
const [selection, setSelection] = React.useState<EmbeddingPoint[]>([]);
|
|
||||||
|
|
||||||
const collectionsQuery = useQuery({
|
|
||||||
queryKey: ["qdrant", "collections"],
|
|
||||||
queryFn: fetchCollections,
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const names = collectionsQuery.data?.collections?.map((c) => c.name) ?? [];
|
|
||||||
if (!selectedCollection && names.length > 0) {
|
|
||||||
const preferred =
|
|
||||||
names.find((n) => n === "fortura-db") ||
|
|
||||||
names.find((n) => n === "miguel_responses") ||
|
|
||||||
names[0];
|
|
||||||
setSelectedCollection(preferred);
|
|
||||||
}
|
|
||||||
}, [collectionsQuery.data, selectedCollection]);
|
|
||||||
|
|
||||||
const pointsQuery = useQuery({
|
|
||||||
queryKey: ["qdrant", "points", selectedCollection, limit, offset, withVector],
|
|
||||||
queryFn: () => fetchPoints(selectedCollection!, limit, offset, withVector),
|
|
||||||
enabled: !!selectedCollection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const points = (pointsQuery.data?.points ?? []) as unknown as EmbeddingPoint[];
|
|
||||||
|
|
||||||
function doDrillOpenExplorer() {
|
|
||||||
// Prefer path from payload, otherwise noop
|
|
||||||
const paths =
|
|
||||||
selection
|
|
||||||
.map((p) => (p.payload?.path as string | undefined))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 1) as string[];
|
|
||||||
|
|
||||||
if (paths.length > 0) {
|
|
||||||
// Navigate to app root; this app does not yet parse query params for path navigation.
|
|
||||||
// We open a download for now as a practical drill-through.
|
|
||||||
const path = paths[0]!;
|
|
||||||
const url = new URL("/api/files/download", window.location.origin);
|
|
||||||
url.searchParams.set("path", path);
|
|
||||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
|
||||||
} else {
|
|
||||||
alert("No path available in selected points. Please select points with payload.path.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function doDrillSearchES() {
|
|
||||||
// Make a simple search from first payload.name or id
|
|
||||||
const first = selection[0];
|
|
||||||
const name = (first?.payload?.name as string | undefined) || String(first?.id ?? "");
|
|
||||||
const q = name ?? "";
|
|
||||||
if (!q) return;
|
|
||||||
// Navigate to root with a hint; the app currently doesn't read URL q param, so we just show info.
|
|
||||||
alert(`Search hint (manual): ${q}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-3 flex flex-col">
|
|
||||||
<div className="text-sm font-medium mb-2">Vector Exploration</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">Collection</span>
|
|
||||||
<Input
|
|
||||||
value={selectedCollection}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedCollection(e.target.value);
|
|
||||||
setOffset(null);
|
|
||||||
}}
|
|
||||||
className="w-56"
|
|
||||||
placeholder="collection name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm">Limit</span>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="w-24"
|
|
||||||
value={limit}
|
|
||||||
onChange={(e) => setLimit(Math.max(1, Math.min(1000, Number(e.target.value) || 100)))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant={withVector ? "default" : "outline"} onClick={() => setWithVector((v) => !v)}>
|
|
||||||
{withVector ? "Vectors: on" : "Vectors: off"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setOffset(null);
|
|
||||||
pointsQuery.refetch();
|
|
||||||
}}
|
|
||||||
disabled={!selectedCollection || pointsQuery.isFetching}
|
|
||||||
>
|
|
||||||
{pointsQuery.isFetching ? "Loading…" : "Refresh"}
|
|
||||||
</Button>
|
|
||||||
<div className="ml-auto text-xs text-muted-foreground">
|
|
||||||
Selected: {selection.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-[280px_1fr] gap-3 min-h-[380px]">
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<div className="border-b px-3 py-2 text-sm font-medium">Collections</div>
|
|
||||||
<ScrollArea className="h-[340px]">
|
|
||||||
<div className="p-2 space-y-1">
|
|
||||||
{collectionsQuery.isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
|
||||||
{collectionsQuery.error && (
|
|
||||||
<div className="text-xs text-destructive">{(collectionsQuery.error as Error).message}</div>
|
|
||||||
)}
|
|
||||||
{(collectionsQuery.data?.collections ?? []).map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.name}
|
|
||||||
className={`w-full text-left px-2 py-1 rounded hover:bg-accent ${
|
|
||||||
selectedCollection === c.name ? "bg-accent" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedCollection(c.name);
|
|
||||||
setOffset(null);
|
|
||||||
}}
|
|
||||||
title={`${c.points_count ?? 0} pts`}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{c.name}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
pts: {c.points_count ?? "—"} vec: {c.vectors_count ?? "—"}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{(collectionsQuery.data?.collections ?? []).length === 0 && !collectionsQuery.isLoading && (
|
|
||||||
<div className="text-xs text-muted-foreground px-2">No collections</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border p-3 flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="text-sm font-medium">Embedding Scatter</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setOffset(null)} disabled={pointsQuery.isFetching}>
|
|
||||||
Reset Offset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setOffset(pointsQuery.data?.next_page_offset ?? null)}
|
|
||||||
disabled={pointsQuery.isFetching || !pointsQuery.data?.next_page_offset}
|
|
||||||
>
|
|
||||||
Next Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-[360px]">
|
|
||||||
{pointsQuery.isLoading ? (
|
|
||||||
<div className="text-xs text-muted-foreground">Loading…</div>
|
|
||||||
) : (
|
|
||||||
<EmbeddingScatter
|
|
||||||
points={points}
|
|
||||||
onSelect={(sel) => setSelection(sel)}
|
|
||||||
onOpenPoint={(p) => {
|
|
||||||
const path = (p.payload?.path as string | undefined) ?? "";
|
|
||||||
if (path) {
|
|
||||||
const url = new URL("/api/files/download", window.location.origin);
|
|
||||||
url.searchParams.set("path", path);
|
|
||||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
|
||||||
<Button size="sm" onClick={doDrillOpenExplorer} disabled={selection.length === 0}>
|
|
||||||
Drill: Open Explorer
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={doDrillSearchES} disabled={selection.length === 0}>
|
|
||||||
Drill: Search in ES
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
83
src/components/onboarding/onboarding-gate.tsx
Normal file
83
src/components/onboarding/onboarding-gate.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { SignInDialog } from "@/components/auth/signin-dialog";
|
||||||
|
import { TermsModal } from "@/components/onboarding/terms-modal";
|
||||||
|
|
||||||
|
type SessionResp =
|
||||||
|
| { authenticated: false }
|
||||||
|
| { authenticated: true; username: string; createdAt: number };
|
||||||
|
|
||||||
|
type TermsResp = { accepted: boolean; version: string };
|
||||||
|
|
||||||
|
async function fetchJSON<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url, { credentials: "include" });
|
||||||
|
if (!res.ok) {
|
||||||
|
// For session/terms status endpoints, treat errors as unauthenticated/unaccepted to keep UX flowing
|
||||||
|
try {
|
||||||
|
const data = (await res.json()) as unknown as T;
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${url} failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnboardingGate:
|
||||||
|
* - Shows SignInDialog when unauthenticated
|
||||||
|
* - After authenticated, shows TermsModal until current TERMS_VERSION is accepted
|
||||||
|
* The Radix Dialog overlay blocks interaction with the underlying UI, enforcing the gate.
|
||||||
|
*/
|
||||||
|
export function OnboardingGate({ children }: { children: React.ReactNode }) {
|
||||||
|
// Poll session and terms with low-frequency revalidation (user-driven events will invalidate)
|
||||||
|
const { data: session } = useQuery<SessionResp>({
|
||||||
|
queryKey: ["auth", "session"],
|
||||||
|
queryFn: () => fetchJSON<SessionResp>("/api/auth/session"),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: terms } = useQuery<TermsResp>({
|
||||||
|
queryKey: ["terms", "status"],
|
||||||
|
queryFn: () => fetchJSON<TermsResp>("/api/terms/status"),
|
||||||
|
// Don't fetch terms until we know auth status; if unauthenticated, terms aren't relevant
|
||||||
|
enabled: session?.authenticated === true,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dialog open states are derived from API responses, but we allow them to be controlled to prevent flicker
|
||||||
|
const [signinOpen, setSigninOpen] = React.useState(false);
|
||||||
|
const [termsOpen, setTermsOpen] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// If explicitly unauthenticated, force sign-in open
|
||||||
|
if (session && session.authenticated === false) {
|
||||||
|
setSigninOpen(true);
|
||||||
|
} else {
|
||||||
|
setSigninOpen(false);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Open terms modal only when authenticated and not accepted
|
||||||
|
if (session?.authenticated === true && terms && !terms.accepted) {
|
||||||
|
setTermsOpen(true);
|
||||||
|
} else {
|
||||||
|
setTermsOpen(false);
|
||||||
|
}
|
||||||
|
}, [session, terms]);
|
||||||
|
|
||||||
|
const version = terms?.version;
|
||||||
|
|
||||||
|
const canRenderApp = session?.authenticated === true && !!terms && terms.accepted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{canRenderApp ? children : null}
|
||||||
|
<SignInDialog open={signinOpen} onOpenChange={setSigninOpen} />
|
||||||
|
<TermsModal open={termsOpen} onOpenChange={setTermsOpen} version={version} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/onboarding/terms-modal.tsx
Normal file
81
src/components/onboarding/terms-modal.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TermsModal({ open, onOpenChange, version }: Props) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "ui.click", name: "terms.accept" },
|
||||||
|
async () => {
|
||||||
|
const res = await fetch("/api/terms/accept", { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const msg =
|
||||||
|
typeof data?.message === "string"
|
||||||
|
? data.message
|
||||||
|
: `Failed to accept terms (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: ["terms", "status"] });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !mutation.isPending && onOpenChange(o)}>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Terms & Conditions {version ? `(${version})` : ""}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
To continue, you must accept the Terms & Conditions for this application.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-h-[40vh] overflow-auto border rounded-md p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
By using this application, you agree to the processing of your files and metadata through
|
||||||
|
your configured Nextcloud instance and the connected services (Elasticsearch, Qdrant, etc.)
|
||||||
|
in accordance with your organization's policies. Do not upload sensitive information
|
||||||
|
unless you have the proper authorization. Your actions may be logged for diagnostic and
|
||||||
|
security purposes.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
You also acknowledge that some features (e.g., semantic search, "Why this result")
|
||||||
|
may call external APIs when enabled by configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => mutation.mutate()}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Accepting..." : "I Accept"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,290 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import type { estypes } from "@elastic/elasticsearch";
|
|
||||||
import { getEsClient } from "@/lib/elasticsearch";
|
|
||||||
import { env } from "@/lib/env";
|
|
||||||
import type {
|
|
||||||
AnalyticsSummary,
|
|
||||||
FileTypesResponse,
|
|
||||||
ActivitySeries,
|
|
||||||
TagsResponse,
|
|
||||||
} from "@/types/analytics";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elasticsearch aggregations for dashboard analytics.
|
|
||||||
* Uses the alias if configured, otherwise the base index name.
|
|
||||||
*/
|
|
||||||
function indexName() {
|
|
||||||
return env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary:
|
|
||||||
* - totalFiles: value_count over id or doc_count if strict index
|
|
||||||
* - totalSizeBytes: sum(sizeBytes)
|
|
||||||
* - uniqueTags: cardinality(tags)
|
|
||||||
* - lastModifiedAt: max(modifiedAt)
|
|
||||||
* - lastUploadedAt: max(uploadedAt)
|
|
||||||
* - owners: top owners (terms)
|
|
||||||
*/
|
|
||||||
export async function getSummary(opts?: { ownersLimit?: number }): Promise<AnalyticsSummary> {
|
|
||||||
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getSummary" }, async (span) => {
|
|
||||||
const client = getEsClient();
|
|
||||||
const ownersLimit = Math.min(50, Math.max(1, opts?.ownersLimit ?? 10));
|
|
||||||
|
|
||||||
const res = await client.search({
|
|
||||||
index: indexName(),
|
|
||||||
size: 0,
|
|
||||||
aggs: {
|
|
||||||
totalFiles: { value_count: { field: "id" } },
|
|
||||||
totalSizeBytes: { sum: { field: "sizeBytes" } },
|
|
||||||
uniqueTags: { cardinality: { field: "tags" } },
|
|
||||||
lastModifiedAt: { max: { field: "modifiedAt" } },
|
|
||||||
lastUploadedAt: { max: { field: "uploadedAt" } },
|
|
||||||
owners: {
|
|
||||||
terms: {
|
|
||||||
field: "owner",
|
|
||||||
size: ownersLimit,
|
|
||||||
order: { _count: "desc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type OwnersBucket = { key: string; doc_count: number };
|
|
||||||
type SummaryAggs = {
|
|
||||||
totalFiles?: { value?: number };
|
|
||||||
totalSizeBytes?: { value?: number };
|
|
||||||
uniqueTags?: { value?: number };
|
|
||||||
lastModifiedAt?: { value_as_string?: string };
|
|
||||||
lastUploadedAt?: { value_as_string?: string };
|
|
||||||
owners?: { buckets?: OwnersBucket[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
const aggs = (res.aggregations as unknown) as SummaryAggs;
|
|
||||||
|
|
||||||
const ownersBuckets: OwnersBucket[] = aggs?.owners?.buckets ?? [];
|
|
||||||
|
|
||||||
const summary: AnalyticsSummary = {
|
|
||||||
totalFiles:
|
|
||||||
typeof aggs?.totalFiles?.value === "number"
|
|
||||||
? aggs.totalFiles.value
|
|
||||||
: (res.hits.total as any)?.value ?? 0,
|
|
||||||
totalSizeBytes: Math.round(Number(aggs?.totalSizeBytes?.value || 0)),
|
|
||||||
uniqueTags: Number(aggs?.uniqueTags?.value || 0),
|
|
||||||
lastModifiedAt: aggs?.lastModifiedAt?.value_as_string,
|
|
||||||
lastUploadedAt: aggs?.lastUploadedAt?.value_as_string,
|
|
||||||
owners: ownersBuckets.map((b) => ({ owner: String(b.key), count: b.doc_count })),
|
|
||||||
};
|
|
||||||
|
|
||||||
span.setAttribute("owners.count", summary.owners?.length ?? 0);
|
|
||||||
return summary;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* File Types:
|
|
||||||
* - terms aggregation on mimeType
|
|
||||||
* - optional sum of sizeBytes can be included if needed later
|
|
||||||
*/
|
|
||||||
export async function getFileTypes(opts?: {
|
|
||||||
limit?: number;
|
|
||||||
withSize?: boolean;
|
|
||||||
}): Promise<FileTypesResponse> {
|
|
||||||
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getFileTypes" }, async (span) => {
|
|
||||||
const client = getEsClient();
|
|
||||||
const limit = Math.min(100, Math.max(1, opts?.limit ?? 20));
|
|
||||||
const withSize = !!opts?.withSize;
|
|
||||||
|
|
||||||
const aggs: Record<string, estypes.AggregationsAggregationContainer> = {
|
|
||||||
fileTypes: {
|
|
||||||
terms: {
|
|
||||||
field: "mimeType",
|
|
||||||
size: limit,
|
|
||||||
order: { _count: "desc" },
|
|
||||||
},
|
|
||||||
aggs: withSize
|
|
||||||
? {
|
|
||||||
totalSize: { sum: { field: "sizeBytes" } },
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await client.search({
|
|
||||||
index: indexName(),
|
|
||||||
size: 0,
|
|
||||||
aggs,
|
|
||||||
});
|
|
||||||
|
|
||||||
type FileTypesAggs = {
|
|
||||||
fileTypes?: {
|
|
||||||
buckets?: Array<{ key: string; doc_count: number; totalSize?: { value?: number } }>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const agg = (res.aggregations as unknown) as FileTypesAggs;
|
|
||||||
const buckets =
|
|
||||||
agg.fileTypes?.buckets ?? [];
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
buckets: buckets.map((b) => ({
|
|
||||||
mimeType: b.key,
|
|
||||||
count: b.doc_count,
|
|
||||||
sizeBytes: withSize ? Math.round(Number(b.totalSize?.value || 0)) : undefined,
|
|
||||||
})),
|
|
||||||
total:
|
|
||||||
typeof res.hits.total === "number"
|
|
||||||
? (res.hits.total as number)
|
|
||||||
: (res.hits.total as any)?.value ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
span.setAttribute("filetypes.count", data.buckets.length);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity:
|
|
||||||
* - date histogram on uploadedAt and modifiedAt
|
|
||||||
* - returns daily series
|
|
||||||
*/
|
|
||||||
export async function getActivity(opts?: {
|
|
||||||
from?: string; // ISO
|
|
||||||
to?: string; // ISO
|
|
||||||
interval?: "day" | "week" | "month";
|
|
||||||
}): Promise<ActivitySeries> {
|
|
||||||
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getActivity" }, async (span) => {
|
|
||||||
const client = getEsClient();
|
|
||||||
const interval = opts?.interval ?? "day";
|
|
||||||
|
|
||||||
const makeDateHist = (field: string) => ({
|
|
||||||
date_histogram: {
|
|
||||||
field,
|
|
||||||
fixed_interval:
|
|
||||||
interval === "month" ? "30d" : interval === "week" ? "7d" : "1d",
|
|
||||||
min_doc_count: 0,
|
|
||||||
...(opts?.from || opts?.to
|
|
||||||
? {
|
|
||||||
extended_bounds: {
|
|
||||||
min: opts.from,
|
|
||||||
max: opts.to,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await client.search({
|
|
||||||
index: indexName(),
|
|
||||||
size: 0,
|
|
||||||
query:
|
|
||||||
opts?.from || opts?.to
|
|
||||||
? {
|
|
||||||
bool: {
|
|
||||||
should: [
|
|
||||||
{
|
|
||||||
range: {
|
|
||||||
uploadedAt: {
|
|
||||||
gte: opts.from,
|
|
||||||
lte: opts.to,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
range: {
|
|
||||||
modifiedAt: {
|
|
||||||
gte: opts.from,
|
|
||||||
lte: opts.to,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
minimum_should_match: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { match_all: {} },
|
|
||||||
aggs: {
|
|
||||||
uploaded: makeDateHist("uploadedAt"),
|
|
||||||
modified: makeDateHist("modifiedAt"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type HistBucket = { key_as_string: string; doc_count: number };
|
|
||||||
type ActivityAggs = {
|
|
||||||
uploaded?: { buckets?: HistBucket[] };
|
|
||||||
modified?: { buckets?: HistBucket[] };
|
|
||||||
};
|
|
||||||
const a = (res.aggregations as unknown) as ActivityAggs;
|
|
||||||
|
|
||||||
const upBuckets: HistBucket[] = a.uploaded?.buckets ?? [];
|
|
||||||
const modBuckets: HistBucket[] = a.modified?.buckets ?? [];
|
|
||||||
|
|
||||||
// Merge series by date string
|
|
||||||
const map = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
date: string;
|
|
||||||
uploaded?: number;
|
|
||||||
modified?: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const b of upBuckets) {
|
|
||||||
map.set(b.key_as_string, { date: b.key_as_string, uploaded: b.doc_count });
|
|
||||||
}
|
|
||||||
for (const b of modBuckets) {
|
|
||||||
const prev = map.get(b.key_as_string) || { date: b.key_as_string };
|
|
||||||
prev.modified = b.doc_count;
|
|
||||||
map.set(b.key_as_string, prev);
|
|
||||||
}
|
|
||||||
|
|
||||||
const points = Array.from(map.values()).sort((a, b) =>
|
|
||||||
a.date.localeCompare(b.date),
|
|
||||||
);
|
|
||||||
|
|
||||||
const series: ActivitySeries = { points };
|
|
||||||
span.setAttribute("activity.points", points.length);
|
|
||||||
return series;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top Tags:
|
|
||||||
* - terms aggregation on tags
|
|
||||||
*/
|
|
||||||
export async function getTopTags(opts?: { limit?: number }): Promise<TagsResponse> {
|
|
||||||
return Sentry.startSpan({ op: "db.query", name: "ES analytics.getTopTags" }, async (span) => {
|
|
||||||
const client = getEsClient();
|
|
||||||
const limit = Math.min(200, Math.max(1, opts?.limit ?? 20));
|
|
||||||
|
|
||||||
const res = await client.search({
|
|
||||||
index: indexName(),
|
|
||||||
size: 0,
|
|
||||||
aggs: {
|
|
||||||
tags: {
|
|
||||||
terms: {
|
|
||||||
field: "tags",
|
|
||||||
size: limit,
|
|
||||||
order: { _count: "desc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type TagsAggs = { tags?: { buckets?: Array<{ key: string; doc_count: number }> } };
|
|
||||||
const t = (res.aggregations as unknown) as TagsAggs;
|
|
||||||
|
|
||||||
const buckets = t.tags?.buckets ?? [];
|
|
||||||
|
|
||||||
const data: TagsResponse = {
|
|
||||||
buckets: buckets.map((b) => ({ tag: b.key, count: b.doc_count })),
|
|
||||||
total:
|
|
||||||
typeof res.hits.total === "number"
|
|
||||||
? (res.hits.total as number)
|
|
||||||
: (res.hits.total as any)?.value ?? 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
span.setAttribute("tags.count", data.buckets.length);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
53
src/lib/nextcloud-session.ts
Normal file
53
src/lib/nextcloud-session.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readAuth } from "@/lib/session";
|
||||||
|
import { createNextcloudClient, NextcloudClient } from "@/lib/webdav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error type used to signal unauthenticated access from helpers.
|
||||||
|
* Route handlers can catch this and return 401.
|
||||||
|
*/
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
status = 401 as const;
|
||||||
|
code = "UNAUTHENTICATED" as const;
|
||||||
|
constructor(message = "Authentication required") {
|
||||||
|
super(message);
|
||||||
|
this.name = "UnauthorizedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a NextcloudClient from the current user's session.
|
||||||
|
* Throws UnauthorizedError (401) if no session is present.
|
||||||
|
*/
|
||||||
|
export async function requireClient(req: Request): Promise<NextcloudClient> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "function", name: "requireClient" },
|
||||||
|
async (span) => {
|
||||||
|
const auth = await readAuth(req);
|
||||||
|
if (!auth) {
|
||||||
|
span.setAttribute("authenticated", false);
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
span.setAttribute("authenticated", true);
|
||||||
|
span.setAttribute("username", auth.username);
|
||||||
|
// Never attach appPassword to the span
|
||||||
|
const client = createNextcloudClient({
|
||||||
|
baseUrl: auth.baseUrl,
|
||||||
|
username: auth.username,
|
||||||
|
appPassword: auth.appPassword,
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helper to return a standardized 401 response.
|
||||||
|
*/
|
||||||
|
export function unauthorizedJson() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "UNAUTHENTICATED", message: "Sign-in required" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/lib/session.ts
Normal file
85
src/lib/session.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getIronSession, type IronSession, type SessionOptions } from "iron-session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth session payload stored server-side in iron-session.
|
||||||
|
* Never expose appPassword to the client.
|
||||||
|
*/
|
||||||
|
export type AuthSession = {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
appPassword: string;
|
||||||
|
createdAt: number; // epoch ms
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build iron-session options from env. We intentionally read process.env
|
||||||
|
* directly here to avoid making SESSION_* required in the global env parser
|
||||||
|
* until onboarding is wired. We validate at the point of use.
|
||||||
|
*/
|
||||||
|
function getSessionOptions(): SessionOptions {
|
||||||
|
const cookieName = process.env.SESSION_COOKIE_NAME || "ncx_session";
|
||||||
|
const password = process.env.SESSION_PASSWORD;
|
||||||
|
if (!password || password.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
"SESSION_PASSWORD is missing or too short (min 32 chars). Set it in .env.local",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cookieName,
|
||||||
|
password,
|
||||||
|
ttl: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
cookieOptions: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the iron session bound to this request/response. Use this in route handlers:
|
||||||
|
*
|
||||||
|
* const res = NextResponse.json({}, { status: 200 });
|
||||||
|
* const session = await getSession(req, res);
|
||||||
|
* session.auth = { ... };
|
||||||
|
* await session.save();
|
||||||
|
* return res;
|
||||||
|
*/
|
||||||
|
export async function getSession(
|
||||||
|
req: Request,
|
||||||
|
res: NextResponse,
|
||||||
|
): Promise<IronSession<{ auth?: AuthSession }>> {
|
||||||
|
return getIronSession(req, res, getSessionOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only convenience helper. Creates a throwaway response container in case
|
||||||
|
* iron-session needs to touch cookie headers. Do not use this if you intend
|
||||||
|
* to modify the session; use getSession(req, res) instead.
|
||||||
|
*/
|
||||||
|
export async function readAuth(req: Request): Promise<AuthSession | null> {
|
||||||
|
const res = new NextResponse();
|
||||||
|
try {
|
||||||
|
const session = await getSession(req, res);
|
||||||
|
return session.auth ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the session and clear the cookie on the provided response.
|
||||||
|
* Usage:
|
||||||
|
* const res = NextResponse.json({ ok: true });
|
||||||
|
* const session = await getSession(req, res);
|
||||||
|
* await destroySession(session);
|
||||||
|
* return res;
|
||||||
|
*/
|
||||||
|
export async function destroySession(
|
||||||
|
session: IronSession<{ auth?: AuthSession }>,
|
||||||
|
) {
|
||||||
|
await session.destroy();
|
||||||
|
}
|
||||||
50
src/lib/terms.ts
Normal file
50
src/lib/terms.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versioned Terms & Conditions helpers using an HTTP-only cookie.
|
||||||
|
* - Cookie stores only the accepted version string.
|
||||||
|
* - Server-only usage for setting/reading the cookie in route handlers/layout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TERMS_COOKIE = "tc_version";
|
||||||
|
|
||||||
|
export function getTermsVersion(): string {
|
||||||
|
return process.env.TERMS_VERSION || "v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the incoming Request has accepted the current Terms version.
|
||||||
|
* Safe for use in Route Handlers where you only have a Request object.
|
||||||
|
*/
|
||||||
|
export function getTermsAccepted(req: Request): boolean {
|
||||||
|
const cookieHeader = req.headers.get("cookie") || "";
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader
|
||||||
|
.split(/;\s*/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((pair) => {
|
||||||
|
const idx = pair.indexOf("=");
|
||||||
|
if (idx === -1) return [pair, ""];
|
||||||
|
const k = decodeURIComponent(pair.slice(0, idx).trim());
|
||||||
|
const v = decodeURIComponent(pair.slice(idx + 1).trim());
|
||||||
|
return [k, v];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const val = cookies[TERMS_COOKIE];
|
||||||
|
return typeof val === "string" && val === getTermsVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Terms cookie on the provided NextResponse to the current version.
|
||||||
|
* - httpOnly, sameSite=lax, path=/, secure in production, ~365 days.
|
||||||
|
*/
|
||||||
|
export function acceptTerms(res: NextResponse): void {
|
||||||
|
const version = getTermsVersion();
|
||||||
|
res.cookies.set(TERMS_COOKIE, version, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -7,11 +7,19 @@ import type { WebDavEntry } from "@/types/files";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Nextcloud WebDAV wrapper with Sentry spans.
|
* Nextcloud WebDAV wrapper with Sentry spans.
|
||||||
* Uses credentials from env and talks directly to the Nextcloud WebDAV endpoint.
|
* - Default singleton uses credentials from env (scripts, legacy).
|
||||||
|
* - Supports per-session clients via constructor auth override.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type Credentials = {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
appPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
let _client: WebDAVClient | null = null;
|
let _client: WebDAVClient | null = null;
|
||||||
|
|
||||||
function getClient(): WebDAVClient {
|
function getEnvClient(): WebDAVClient {
|
||||||
if (_client) return _client;
|
if (_client) return _client;
|
||||||
_client = createClient(env.NEXTCLOUD_BASE_URL, {
|
_client = createClient(env.NEXTCLOUD_BASE_URL, {
|
||||||
username: env.NEXTCLOUD_USERNAME,
|
username: env.NEXTCLOUD_USERNAME,
|
||||||
@ -86,9 +94,24 @@ function mapStatToEntry(stat: FileStat): WebDavEntry {
|
|||||||
|
|
||||||
export class NextcloudClient {
|
export class NextcloudClient {
|
||||||
private rootPath: string;
|
private rootPath: string;
|
||||||
|
private client: WebDAVClient;
|
||||||
|
|
||||||
constructor(rootPath: string = env.NEXTCLOUD_ROOT_PATH) {
|
/**
|
||||||
|
* Create a NextcloudClient.
|
||||||
|
* - If auth is provided, use per-session credentials.
|
||||||
|
* - Else fallback to env-based singleton client.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
rootPath: string = env.NEXTCLOUD_ROOT_PATH,
|
||||||
|
auth?: Credentials,
|
||||||
|
) {
|
||||||
this.rootPath = normalizePath(rootPath);
|
this.rootPath = normalizePath(rootPath);
|
||||||
|
this.client = auth
|
||||||
|
? createClient(auth.baseUrl, {
|
||||||
|
username: auth.username,
|
||||||
|
password: auth.appPassword,
|
||||||
|
})
|
||||||
|
: getEnvClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -107,8 +130,7 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
const stats = (await this.client.getDirectoryContents(target)) as FileStat[];
|
||||||
const stats = (await client.getDirectoryContents(target)) as FileStat[];
|
|
||||||
return stats.map(mapStatToEntry);
|
return stats.map(mapStatToEntry);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -120,8 +142,7 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
await this.client.createDirectory(target);
|
||||||
await client.createDirectory(target);
|
|
||||||
// ETag not typically returned by createDirectory in webdav client
|
// ETag not typically returned by createDirectory in webdav client
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
@ -139,9 +160,8 @@ export class NextcloudClient {
|
|||||||
const target = this.resolve(destPath);
|
const target = this.resolve(destPath);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
if (contentType) span.setAttribute("contentType", contentType);
|
if (contentType) span.setAttribute("contentType", contentType);
|
||||||
const client = getClient();
|
|
||||||
const data = await toWebdavData(file);
|
const data = await toWebdavData(file);
|
||||||
await client.putFileContents(target, data, { overwrite: true });
|
await this.client.putFileContents(target, data, { overwrite: true });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -153,8 +173,7 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
const stream = this.client.createReadStream(target) as unknown as NodeJS.ReadableStream;
|
||||||
const stream = client.createReadStream(target) as unknown as NodeJS.ReadableStream;
|
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -166,9 +185,8 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
|
||||||
try {
|
try {
|
||||||
const s = (await client.stat(target)) as FileStat;
|
const s = (await this.client.stat(target)) as FileStat;
|
||||||
return mapStatToEntry(s);
|
return mapStatToEntry(s);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 404 -> not found
|
// 404 -> not found
|
||||||
@ -187,8 +205,7 @@ export class NextcloudClient {
|
|||||||
const dst = this.resolve(to);
|
const dst = this.resolve(to);
|
||||||
span.setAttribute("path_from", src);
|
span.setAttribute("path_from", src);
|
||||||
span.setAttribute("path_to", dst);
|
span.setAttribute("path_to", dst);
|
||||||
const client = getClient();
|
await this.client.moveFile(src, dst, { overwrite: true });
|
||||||
await client.moveFile(src, dst, { overwrite: true });
|
|
||||||
return { ok: true, from: src, to: dst };
|
return { ok: true, from: src, to: dst };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -202,8 +219,7 @@ export class NextcloudClient {
|
|||||||
const dst = this.resolve(to);
|
const dst = this.resolve(to);
|
||||||
span.setAttribute("path_from", src);
|
span.setAttribute("path_from", src);
|
||||||
span.setAttribute("path_to", dst);
|
span.setAttribute("path_to", dst);
|
||||||
const client = getClient();
|
await this.client.copyFile(src, dst, { overwrite: true });
|
||||||
await client.copyFile(src, dst, { overwrite: true });
|
|
||||||
return { ok: true, from: src, to: dst };
|
return { ok: true, from: src, to: dst };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -215,14 +231,13 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
|
||||||
try {
|
try {
|
||||||
// Determine if directory or file for the correct delete call
|
// Determine if directory or file for the correct delete call
|
||||||
const s = (await client.stat(target)) as FileStat;
|
const s = (await this.client.stat(target)) as FileStat;
|
||||||
if (s.type === "directory") {
|
if (s.type === "directory") {
|
||||||
await client.deleteFile(target); // webdav client uses deleteFile for both in many versions
|
await this.client.deleteFile(target); // webdav client uses deleteFile for both in many versions
|
||||||
} else {
|
} else {
|
||||||
await client.deleteFile(target);
|
await this.client.deleteFile(target);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -239,8 +254,7 @@ export class NextcloudClient {
|
|||||||
async (span) => {
|
async (span) => {
|
||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
const client = getClient();
|
const text = (await this.client.getFileContents(target, { format: "text" })) as unknown as string;
|
||||||
const text = (await client.getFileContents(target, { format: "text" })) as unknown as string;
|
|
||||||
const s = await this.stat(target);
|
const s = await this.stat(target);
|
||||||
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
|
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
|
||||||
},
|
},
|
||||||
@ -254,12 +268,25 @@ export class NextcloudClient {
|
|||||||
const target = this.resolve(path);
|
const target = this.resolve(path);
|
||||||
span.setAttribute("path", target);
|
span.setAttribute("path", target);
|
||||||
if (mimeType) span.setAttribute("contentType", mimeType);
|
if (mimeType) span.setAttribute("contentType", mimeType);
|
||||||
const client = getClient();
|
await this.client.putFileContents(target, content, { overwrite: true });
|
||||||
await client.putFileContents(target, content, { overwrite: true });
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory to build a per-session Nextcloud client.
|
||||||
|
*/
|
||||||
|
export function createNextcloudClient(
|
||||||
|
auth: Credentials,
|
||||||
|
rootPath?: string,
|
||||||
|
) {
|
||||||
|
const rp = rootPath ? normalizePath(rootPath) : env.NEXTCLOUD_ROOT_PATH;
|
||||||
|
return new NextcloudClient(rp, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy/env-based singleton instance (for scripts, etc.)
|
||||||
|
*/
|
||||||
export const nextcloud = new NextcloudClient();
|
export const nextcloud = new NextcloudClient();
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
export type AnalyticsSummary = {
|
|
||||||
totalFiles: number;
|
|
||||||
totalSizeBytes: number;
|
|
||||||
uniqueTags: number;
|
|
||||||
lastModifiedAt?: string;
|
|
||||||
lastUploadedAt?: string;
|
|
||||||
owners?: Array<{ owner: string; count: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FileTypeBucket = {
|
|
||||||
mimeType: string;
|
|
||||||
count: number;
|
|
||||||
sizeBytes?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FileTypesResponse = {
|
|
||||||
buckets: FileTypeBucket[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActivityPoint = {
|
|
||||||
date: string; // ISO date (day resolution)
|
|
||||||
uploaded?: number;
|
|
||||||
modified?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActivitySeries = {
|
|
||||||
points: ActivityPoint[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TagBucket = {
|
|
||||||
tag: string;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TagsResponse = {
|
|
||||||
buckets: TagBucket[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VectorSelection = {
|
|
||||||
ids: Array<string | number>;
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DrillTarget = {
|
|
||||||
path: string;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user