mirror of
https://github.com/diced/zipline.git
synced 2026-06-26 07:53:54 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bd477d7aa | |||
| 26a16137b2 | |||
| d07c1fa99b | |||
| ae80d228b5 | |||
| b5c39bed47 | |||
| ca9bd41244 | |||
| 93f0210605 | |||
| 4329dc7cdf | |||
| ae6a6536f9 | |||
| 0fc7e7a06f | |||
| 18bc86c261 | |||
| 2e210da549 | |||
| 3639ec0dc2 |
Generated
+65
-561
@@ -19,69 +19,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"lastModified": 1777487137,
|
||||
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_2": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix"
|
||||
],
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "latest",
|
||||
"repo": "cachix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"cachix_3": {
|
||||
"inputs": {
|
||||
"devenv": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"git-hooks": "git-hooks_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767714506,
|
||||
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
|
||||
"owner": "cachix",
|
||||
"repo": "cachix",
|
||||
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
|
||||
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -92,60 +34,19 @@
|
||||
}
|
||||
},
|
||||
"crate2nix": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_2",
|
||||
"crate2nix_stable": "crate2nix_stable",
|
||||
"devshell": "devshell_2",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nix-test-runner": "nix-test-runner_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit-hooks": "pre-commit-hooks_2"
|
||||
},
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1773440526,
|
||||
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
|
||||
"owner": "nix-community",
|
||||
"lastModified": 1772186516,
|
||||
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crate2nix_stable": {
|
||||
"inputs": {
|
||||
"cachix": "cachix_3",
|
||||
"crate2nix_stable": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable"
|
||||
],
|
||||
"devshell": "devshell",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"nix-test-runner": "nix-test-runner",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"pre-commit-hooks": "pre-commit-hooks"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769627083,
|
||||
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
|
||||
"owner": "nix-community",
|
||||
"repo": "crate2nix",
|
||||
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "0.15.0",
|
||||
"owner": "rossng",
|
||||
"repo": "crate2nix",
|
||||
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@@ -153,9 +54,10 @@
|
||||
"inputs": {
|
||||
"cachix": "cachix",
|
||||
"crate2nix": "crate2nix",
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-parts": "flake-parts_3",
|
||||
"git-hooks": "git-hooks_3",
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-parts": "flake-parts",
|
||||
"ghostty": "ghostty",
|
||||
"git-hooks": "git-hooks",
|
||||
"nix": "nix",
|
||||
"nixd": "nixd",
|
||||
"nixpkgs": [
|
||||
@@ -164,11 +66,11 @@
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774134162,
|
||||
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
|
||||
"lastModified": 1782331842,
|
||||
"narHash": "sha256-7CJ2EqNVPMq0ly39aaP6dGgdO627MqUtM/+Dm+QwNdU=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
|
||||
"rev": "885e1c9d62cfa12232802de77b36aaded1ca609b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -189,80 +91,7 @@
|
||||
"url": "file:///dev/null"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768818222,
|
||||
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_2": {
|
||||
"locked": {
|
||||
"lastModified": 1733328505,
|
||||
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
|
||||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"flake-compat_3": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
@@ -282,17 +111,15 @@
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -302,58 +129,15 @@
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768135262,
|
||||
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_3": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_4": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772408722,
|
||||
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -362,86 +146,40 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"ghostty": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1779069789,
|
||||
"narHash": "sha256-ojo+gso45/6CVSuqfSVnlWpQ4d0QeLgwok+v/g3yu0E=",
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"rev": "4b7bf0b20e3baf9c1ba10c63f2ad1fd853faea8f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ghostty-org",
|
||||
"repo": "ghostty",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"lastModified": 1778507602,
|
||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_2",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765404074,
|
||||
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks_3": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_5",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772893680,
|
||||
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -451,102 +189,6 @@
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"cachix",
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"pre-commit-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore_5": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
@@ -594,52 +236,20 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1774103430,
|
||||
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
|
||||
"lastModified": 1779748925,
|
||||
"narHash": "sha256-meIhqGC04O5VXbKSFXSQoOKp+XCq5RMnwAk1Guo0VQo=",
|
||||
"owner": "cachix",
|
||||
"repo": "nix",
|
||||
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
|
||||
"rev": "0bc443c8ff235c3547d09327b48aaa2ab98b15f2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "devenv-2.32",
|
||||
"ref": "devenv-2.34",
|
||||
"repo": "nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-test-runner_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1588761593,
|
||||
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "stoeffel",
|
||||
"repo": "nix-test-runner",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixd": {
|
||||
"inputs": {
|
||||
"flake-parts": [
|
||||
@@ -653,11 +263,11 @@
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773634079,
|
||||
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
|
||||
"lastModified": 1778381404,
|
||||
"narHash": "sha256-FqhdOTA8vyoIpkHhbs2cCT7h6EWM7nsLeOYJc1ifQLE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixd",
|
||||
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
|
||||
"rev": "e3e45eb76663f522e196b7f0cf34cab201db7779",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -667,69 +277,6 @@
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1772328832,
|
||||
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1765186076,
|
||||
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1769433173,
|
||||
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1773964973,
|
||||
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
|
||||
@@ -745,61 +292,18 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_3",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"crate2nix_stable",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"lastModified": 1777168982,
|
||||
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"pre-commit-hooks_2": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore_4",
|
||||
"nixpkgs": [
|
||||
"devenv",
|
||||
"crate2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769069492,
|
||||
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@@ -807,8 +311,8 @@
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"devenv-root": "devenv-root",
|
||||
"flake-parts": "flake-parts_4",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@@ -819,11 +323,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1773630837,
|
||||
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
|
||||
"lastModified": 1779074409,
|
||||
"narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
|
||||
"rev": "2a77b5b1dc952f214e8102acdef1622b68515560",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -841,11 +345,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772660329,
|
||||
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
|
||||
"lastModified": 1775636079,
|
||||
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
|
||||
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.6.2",
|
||||
"version": "4.6.3",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "File_folderId_createdAt_idx" ON "public"."File"("folderId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "File_name_idx" ON "public"."File"("name");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Zipline" ADD COLUMN "filesExtensionlessUrls" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -49,6 +49,7 @@ model Zipline {
|
||||
filesRandomWordsSeparator String @default("-")
|
||||
filesDefaultCompressionFormat String? @default("jpg")
|
||||
filesMaxFilesPerUpload Int @default(1000)
|
||||
filesExtensionlessUrls Boolean @default(false)
|
||||
|
||||
urlsRoute String @default("/go")
|
||||
urlsLength Int @default(6)
|
||||
@@ -297,6 +298,9 @@ model File {
|
||||
folderId String?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
|
||||
@@index([name])
|
||||
@@index([folderId, createdAt])
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import DashboardAdminHome from '@/components/pages/admin';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
|
||||
export function Component() {
|
||||
useTitle('Administrator');
|
||||
|
||||
return <DashboardAdminHome />;
|
||||
}
|
||||
|
||||
Component.displayName = 'Dashboard/Admin';
|
||||
@@ -77,6 +77,7 @@ export const router = createBrowserRouter([
|
||||
if (!isAdministrator(user.role)) return redirect('/dashboard');
|
||||
},
|
||||
children: [
|
||||
{ path: 'admin', lazy: () => import('./pages/dashboard/admin/index') },
|
||||
{ path: 'admin/invites', lazy: () => import('./pages/dashboard/admin/invites') },
|
||||
{ path: 'admin/settings/*', lazy: () => import('./pages/dashboard/admin/settings') },
|
||||
{ path: 'admin/actions', lazy: () => import('./pages/dashboard/admin/actions') },
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isCode } from '@/lib/code';
|
||||
import { config as zConfig } from '@/lib/config';
|
||||
import type { Config } from '@/lib/config/validate';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { findFileByName, File, fileSelect } from '@/lib/db/models/file';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { parseString } from '@/lib/parser';
|
||||
import { parserMetrics } from '@/lib/parser/metrics';
|
||||
@@ -24,17 +24,20 @@ import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'r
|
||||
import { createRoutes } from './routes';
|
||||
|
||||
export const getFile = async (id: string) =>
|
||||
prisma.file.findFirst({
|
||||
where: { name: decodeURIComponent(id) },
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
userId: true,
|
||||
thumbnail: { select: { path: true } },
|
||||
tags: { select: { id: true, name: true, color: true } },
|
||||
Folder: { select: { id: true, public: true, name: true } },
|
||||
},
|
||||
});
|
||||
findFileByName(id, (where, orderBy) =>
|
||||
prisma.file.findFirst({
|
||||
where,
|
||||
...(orderBy && { orderBy }),
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
userId: true,
|
||||
thumbnail: { select: { path: true } },
|
||||
tags: { select: { id: true, name: true, color: true } },
|
||||
Folder: { select: { id: true, public: true, name: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export async function render(
|
||||
{
|
||||
|
||||
@@ -121,17 +121,23 @@ const navLinks: NavLinks[] = [
|
||||
if: (user) => isAdministrator(user?.role),
|
||||
active: (path: string) => path.startsWith('/dashboard/admin'),
|
||||
links: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: <IconHome size='1rem' />,
|
||||
active: (path: string) => path === '/dashboard/admin',
|
||||
href: '/dashboard/admin',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: <IconAdjustments size='1rem' />,
|
||||
active: (path: string) => path.startsWith('/dashboard/admin/settings'),
|
||||
if: (user) => user?.role === 'SUPERADMIN',
|
||||
href: '/dashboard/admin/settings',
|
||||
links: SETTINGS_EXTERNAL_LINKS.map(({ name, url, icon }) => ({
|
||||
label: name,
|
||||
icon,
|
||||
active: (path: string) => path === url,
|
||||
href: url,
|
||||
links: SETTINGS_EXTERNAL_LINKS.map(({ label, href, icon: Icon }) => ({
|
||||
label,
|
||||
icon: <Icon size='1rem' />,
|
||||
active: (path: string) => path === href,
|
||||
href,
|
||||
})),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActionIcon, Anchor, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function LinksList({
|
||||
links,
|
||||
}: {
|
||||
links: {
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: any;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
}) {
|
||||
const visibleLinks = links.filter((link) => !link.hidden);
|
||||
|
||||
return (
|
||||
<Stack gap='md'>
|
||||
{visibleLinks.map(({ label, description, href, icon: Icon }) => (
|
||||
<Anchor key={href} component={Link} to={href} style={{ textDecoration: 'none' }}>
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md'>
|
||||
<ActionIcon variant='filled' radius='md' size='xl'>
|
||||
<Icon size='1.75rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<div>
|
||||
<Title order={4}>{label}</Title>
|
||||
<Text c='dimmed'>{description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,96 @@ function VersionButton({ text, children, href }: { href: string; text: string; c
|
||||
);
|
||||
}
|
||||
|
||||
type VersionData = NonNullable<ReturnType<typeof useVersion>['version']>;
|
||||
|
||||
export function VersionInfo({ version }: { version: VersionData }) {
|
||||
return (
|
||||
<>
|
||||
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
|
||||
{version.isUpstream && (
|
||||
<Text>
|
||||
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
|
||||
may contain bugs.
|
||||
</Text>
|
||||
)}
|
||||
{!version.isLatest && !version.isUpstream && version.isRelease && (
|
||||
<Text>
|
||||
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
|
||||
<Anchor href={version.latest.url}>latest version</Anchor>.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Indicator processing position='middle-end' inline offset={-15} color='red' disabled={version.isLatest}>
|
||||
<Title order={3} my='sm'>
|
||||
Current Version
|
||||
</Title>
|
||||
</Indicator>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Version',
|
||||
value: version.version.tag!,
|
||||
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{!version.isLatest && version.isUpstream && version.latest.commit && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
Latest Commit Available
|
||||
</Title>
|
||||
<Text c='dimmed' size='sm' mb='sm'>
|
||||
This is only visible when running an upstream version.
|
||||
</Text>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.latest.commit.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!version.isLatest && version.isRelease && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
{version.latest.tag} is available
|
||||
</Title>
|
||||
|
||||
<VersionButton text='Changelogs' href={version.latest.url}>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
|
||||
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VersionBadge() {
|
||||
const { version, isLoading } = useVersion();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
@@ -74,93 +164,7 @@ export default function VersionBadge() {
|
||||
return (
|
||||
<>
|
||||
<Modal title='Zipline Version' opened={opened} onClose={close} size='lg'>
|
||||
{version.isLatest && <Text>Running the latest version of Zipline.</Text>}
|
||||
{version.isUpstream && (
|
||||
<Text>
|
||||
You are running an <b>unstable</b> version of Zipline. Upstream versions are not fully tested and
|
||||
may contain bugs.
|
||||
</Text>
|
||||
)}
|
||||
{!version.isLatest && !version.isUpstream && version.isRelease && (
|
||||
<Text>
|
||||
You are running an <b>outdated</b> version of Zipline. It is recommended to update to the{' '}
|
||||
<Anchor href={version.latest.url}>latest version</Anchor>.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Indicator
|
||||
processing
|
||||
position='middle-end'
|
||||
inline
|
||||
offset={-15}
|
||||
color='red'
|
||||
disabled={version.isLatest}
|
||||
>
|
||||
<Title order={3} my='sm'>
|
||||
Current Version
|
||||
</Title>
|
||||
</Indicator>
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Version',
|
||||
value: version.version.tag!,
|
||||
href: `https://github.com/diced/zipline/releases/${version.version.tag}`,
|
||||
},
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.version.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.version.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Upstream?',
|
||||
value: version.isUpstream ? 'Yes' : 'No',
|
||||
color: version.isUpstream ? 'orange' : 'green',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{!version.isLatest && version.isUpstream && version.latest.commit && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
Latest Commit Available
|
||||
</Title>
|
||||
<Text c='dimmed' size='sm' mb='sm'>
|
||||
This is only visible when running an upstream version.
|
||||
</Text>
|
||||
|
||||
<DataDisplay
|
||||
items={[
|
||||
{
|
||||
label: 'Commit',
|
||||
value: version.latest.commit.sha!.slice(0, 7)!,
|
||||
href: `https://github.com/diced/zipline/commit/${version.latest.commit.sha}`,
|
||||
},
|
||||
{
|
||||
label: 'Available to update',
|
||||
value: version.latest.commit.pull ? 'Yes' : 'No',
|
||||
color: version.latest.commit.pull ? 'green' : 'red',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!version.isLatest && version.isRelease && (
|
||||
<>
|
||||
<Title order={3} mt='sm'>
|
||||
{version.latest.tag} is available
|
||||
</Title>
|
||||
|
||||
<VersionButton text='Changelogs' href={version.latest.url}>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
|
||||
<VersionButton text='Update' href='https://zipline.diced.sh/docs/get-started/docker#updating'>
|
||||
{version.latest.tag}
|
||||
</VersionButton>
|
||||
</>
|
||||
)}
|
||||
<VersionInfo version={version} />
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='Click to view more version information'>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { File } from '@/lib/db/models/file';
|
||||
import { Card } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import FileContextMenu from '../FileContextMenu';
|
||||
import DashboardFileModal from './DashboardFileModal';
|
||||
|
||||
import styles from './index.module.css';
|
||||
@@ -19,19 +20,17 @@ export default function DashboardFile({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleView = () => (onOpen ? onOpen(file.id) : setOpen(true));
|
||||
|
||||
return (
|
||||
<>
|
||||
{!onOpen && <DashboardFileModal open={open} setOpen={setOpen} file={file} reduce={reduce} user={id} />}
|
||||
|
||||
<Card
|
||||
shadow='md'
|
||||
radius='md'
|
||||
p={0}
|
||||
onClick={() => (onOpen ? onOpen(file.id) : setOpen(true))}
|
||||
className={styles.file}
|
||||
>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
<FileContextMenu file={file} reduce={reduce} user={id} onView={handleView}>
|
||||
<Card shadow='md' radius='md' p={0} onClick={handleView} className={styles.file}>
|
||||
<DashboardFileType key={file.id} file={file} />
|
||||
</Card>
|
||||
</FileContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { Box, Combobox, InputBase, Menu, ScrollArea, Text, useCombobox } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconClipboardTypography,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconEye,
|
||||
IconFolderMinus,
|
||||
IconFolderSymlink,
|
||||
IconPencil,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
addToFolder,
|
||||
copyFile,
|
||||
createFolderAndAdd,
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
favoriteFile,
|
||||
removeFromFolder,
|
||||
viewFile,
|
||||
} from './actions';
|
||||
import EditFileDetailsModal from './DashboardFile/EditFileDetailsModal';
|
||||
|
||||
const stop = (fn: () => void) => (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
|
||||
function openCreateFolderModal(file: File) {
|
||||
modals.openConfirmModal({
|
||||
modalId: 'file-context-create-folder',
|
||||
title: 'Create folder',
|
||||
centered: true,
|
||||
children: (
|
||||
<InputBase
|
||||
id='file-context-new-folder'
|
||||
label='Folder name'
|
||||
placeholder='My folder'
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const name = event.currentTarget.value.trim();
|
||||
if (!name) return;
|
||||
createFolderAndAdd(file, name);
|
||||
modals.closeAll();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
labels: { confirm: 'Create', cancel: 'Cancel' },
|
||||
onConfirm: () => {
|
||||
const input = document.getElementById('file-context-new-folder') as HTMLInputElement | null;
|
||||
const name = input?.value?.trim();
|
||||
if (name) createFolderAndAdd(file, name);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function FileContextMenu({
|
||||
file,
|
||||
reduce,
|
||||
user,
|
||||
onView,
|
||||
children,
|
||||
}: {
|
||||
file: File;
|
||||
reduce?: boolean;
|
||||
user?: string;
|
||||
onView?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||
const { data: folders } = useFolders(user);
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
if (!folders) return [];
|
||||
return buildFolderHierarchy(folders);
|
||||
}, [folders]);
|
||||
|
||||
const folderCombobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
folderCombobox.resetSelectedOption();
|
||||
setFolderSearch('');
|
||||
},
|
||||
});
|
||||
const [folderSearch, setFolderSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
const close = () => setOpened(false);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
};
|
||||
}, [opened]);
|
||||
|
||||
const closeMenu = () => setOpened(false);
|
||||
|
||||
const run = (fn: () => void) => () => {
|
||||
closeMenu();
|
||||
fn();
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setPosition({ x: event.clientX, y: event.clientY });
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const handleAddToFolder = async (value: string) => {
|
||||
closeMenu();
|
||||
folderCombobox.closeDropdown();
|
||||
|
||||
if (value === '$create') {
|
||||
await createFolderAndAdd(file, folderSearch.trim());
|
||||
} else {
|
||||
await addToFolder(file, value);
|
||||
}
|
||||
|
||||
setFolderSearch('');
|
||||
};
|
||||
|
||||
const filteredFolders = folderOptions.filter((folder) =>
|
||||
folder.path.toLowerCase().includes(folderSearch.toLowerCase().trim()),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditFileDetailsModal open={editOpen} onClose={() => setEditOpen(false)} file={file} />
|
||||
|
||||
<Box onContextMenu={handleContextMenu} style={{ display: 'contents' }}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
withinPortal
|
||||
shadow='md'
|
||||
radius='md'
|
||||
width={240}
|
||||
position='bottom-start'
|
||||
offset={4}
|
||||
closeOnItemClick
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown onClick={(event) => event.stopPropagation()}>
|
||||
<Menu.Label>
|
||||
<Text size='xs' fw={600} lineClamp={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size='xs' c='dimmed' lineClamp={1}>
|
||||
{file.type}
|
||||
</Text>
|
||||
</Menu.Label>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
{onView && (
|
||||
<Menu.Item leftSection={<IconEye size='1rem' />} onClick={stop(run(onView))}>
|
||||
Open
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item leftSection={<IconExternalLink size='1rem' />} onClick={stop(run(() => viewFile(file)))}>
|
||||
Open in new tab
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
onClick={stop(run(() => copyFile(file, clipboard)))}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconClipboardTypography size='1rem' />}
|
||||
onClick={stop(run(() => copyFile(file, clipboard, true)))}
|
||||
>
|
||||
Copy raw link
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconDownload size='1rem' />} onClick={stop(run(() => downloadFile(file)))}>
|
||||
Download
|
||||
</Menu.Item>
|
||||
|
||||
{!reduce && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
file.favorite ? (
|
||||
<IconStarFilled size='1rem' color='var(--mantine-color-yellow-5)' />
|
||||
) : (
|
||||
<IconStar size='1rem' />
|
||||
)
|
||||
}
|
||||
onClick={stop(run(() => favoriteFile(file)))}
|
||||
>
|
||||
{file.favorite ? 'Unfavorite' : 'Favorite'}
|
||||
</Menu.Item>
|
||||
|
||||
{file.folderId ? (
|
||||
<Menu.Item
|
||||
leftSection={<IconFolderMinus size='1rem' color='var(--mantine-color-red-5)' />}
|
||||
onClick={stop(run(() => removeFromFolder(file)))}
|
||||
>
|
||||
Remove from folder
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Sub openDelay={100} closeDelay={200}>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<IconFolderSymlink size='1rem' />}>
|
||||
Move to folder
|
||||
</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<Box p='xs' w={220} onClick={(event) => event.stopPropagation()}>
|
||||
<Combobox
|
||||
store={folderCombobox}
|
||||
onOptionSubmit={handleAddToFolder}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
size='xs'
|
||||
placeholder='Search folders...'
|
||||
value={folderSearch}
|
||||
onChange={(event) => {
|
||||
folderCombobox.openDropdown();
|
||||
setFolderSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => folderCombobox.openDropdown()}
|
||||
onFocus={() => folderCombobox.openDropdown()}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents='none'
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<ScrollArea.Autosize mah={200} type='scroll'>
|
||||
<FolderComboboxOptions
|
||||
folderOptions={filteredFolders}
|
||||
searchValue={folderSearch}
|
||||
additionalOptions={
|
||||
!folders?.some((f) => f.name === folderSearch.trim()) &&
|
||||
folderSearch.trim().length > 0 ? (
|
||||
<Combobox.Option value='$create'>
|
||||
+ Create "{folderSearch.trim()}"
|
||||
</Combobox.Option>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{!filteredFolders.length && !folderSearch.trim() && (
|
||||
<Combobox.Empty px='xs' py='sm'>
|
||||
<Text size='xs' c='dimmed'>
|
||||
No folders yet
|
||||
</Text>
|
||||
</Combobox.Empty>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
<Menu.Item mt={4} onClick={stop(() => openCreateFolderModal(file))}>
|
||||
+ Create new folder
|
||||
</Menu.Item>
|
||||
</Box>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size='1rem' />}
|
||||
onClick={stop(run(() => setEditOpen(true)))}
|
||||
>
|
||||
Edit details
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
color='red'
|
||||
leftSection={<IconTrashFilled size='1rem' />}
|
||||
onClick={stop(run(() => deleteFile(warnDeletion, file, () => {})))}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>,
|
||||
const url = raw
|
||||
? getDomain(`/raw/${file.name}`)
|
||||
: file.url
|
||||
? getDomain(`${file.url}`)
|
||||
? getDomain(file.url)
|
||||
: getDomain(`/view/${file.name}`);
|
||||
|
||||
clipboard.copy(url);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { LinksList } from '@/components/LinksList';
|
||||
import useLogin from '@/lib/client/hooks/useLogin';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { SimpleGrid, Title } from '@mantine/core';
|
||||
import { IconAdjustments, IconGraph, IconStopwatch, IconTags, IconUsersGroup } from '@tabler/icons-react';
|
||||
import { Version } from './parts/Version';
|
||||
import { Storage } from './parts/Storage';
|
||||
|
||||
export default function DashboardAdminHome() {
|
||||
const { user } = useLogin();
|
||||
const config = useConfig();
|
||||
|
||||
const adminLinks = [
|
||||
{
|
||||
label: 'Metrics',
|
||||
description: 'Instance-wide usage graphs and statistics',
|
||||
href: '/dashboard/metrics',
|
||||
icon: IconGraph,
|
||||
show:
|
||||
config.features.metrics.enabled &&
|
||||
(!config.features.metrics.adminOnly || isAdministrator(user?.role)),
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
description: 'Maintenance tools and import/export',
|
||||
href: '/dashboard/admin/actions',
|
||||
icon: IconStopwatch,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
description: 'Manage users and quotas',
|
||||
href: '/dashboard/admin/users',
|
||||
icon: IconUsersGroup,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
description: 'Server configuration',
|
||||
href: '/dashboard/admin/settings',
|
||||
icon: IconAdjustments,
|
||||
show: user?.role === 'SUPERADMIN',
|
||||
},
|
||||
{
|
||||
label: 'Invites',
|
||||
description: 'Create and manage invite codes',
|
||||
href: '/dashboard/admin/invites',
|
||||
icon: IconTags,
|
||||
show: config.invites.enabled,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={1}>Administrator</Title>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing='md' my='md'>
|
||||
<Storage />
|
||||
<Version />
|
||||
</SimpleGrid>
|
||||
|
||||
<LinksList links={adminLinks} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Button, Group, Paper, Progress, Skeleton, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconDatabase, IconRefresh } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export function Storage() {
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<Response['/api/server/status']>('/api/server/status');
|
||||
|
||||
return (
|
||||
<Paper withBorder p='md' radius='md'>
|
||||
<Group justify='space-between' mb='sm'>
|
||||
<Group gap='xs'>
|
||||
<IconDatabase size='1.2rem' />
|
||||
<Title order={3}>Storage</Title>
|
||||
</Group>
|
||||
|
||||
<Tooltip label='Refresh storage stats'>
|
||||
<Button variant='subtle' size='compact-sm' onClick={() => mutate()} loading={isLoading}>
|
||||
<IconRefresh size='1rem' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap='sm'>
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={28} animate />
|
||||
<Skeleton height={12} animate />
|
||||
</Stack>
|
||||
) : error ? (
|
||||
<Text size='sm' c='red'>
|
||||
Failed to load storage
|
||||
</Text>
|
||||
) : status ? (
|
||||
<Stack gap='sm'>
|
||||
<Text size='sm' c='dimmed'>
|
||||
{status.datasource === 's3' ? 'S3: ' : ''}
|
||||
{status.storage.path}
|
||||
</Text>
|
||||
|
||||
{status.storage.total != null ? (
|
||||
<>
|
||||
<Progress.Root size='xl'>
|
||||
<Progress.Section
|
||||
value={Math.min(100, (status.storage.used / status.storage.total) * 100)}
|
||||
color={
|
||||
status.storage.used / status.storage.total > 0.9
|
||||
? 'red'
|
||||
: status.storage.used / status.storage.total > 0.75
|
||||
? 'orange'
|
||||
: 'blue'
|
||||
}
|
||||
>
|
||||
<Progress.Label>
|
||||
{Math.round((status.storage.used / status.storage.total) * 100)}%
|
||||
</Progress.Label>
|
||||
</Progress.Section>
|
||||
</Progress.Root>
|
||||
|
||||
<Text size='xs' c='dimmed' ta='right'>
|
||||
{bytes(status.storage.used)} / {bytes(status.storage.total)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size='xs' c='dimmed'>
|
||||
{bytes(status.storage.used)} used
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { VersionInfo } from '@/components/VersionBadge';
|
||||
import useVersion from '@/lib/client/hooks/useVersion';
|
||||
import { Group, Paper, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconVersions } from '@tabler/icons-react';
|
||||
|
||||
export function Version() {
|
||||
const { version, isLoading } = useVersion();
|
||||
|
||||
return (
|
||||
<Paper withBorder p='md' radius='md'>
|
||||
<Group gap='xs' mb='sm'>
|
||||
<IconVersions size='1.2rem' />
|
||||
<Title order={3}>Version</Title>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack gap='sm'>
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={18} animate />
|
||||
<Skeleton height={60} animate />
|
||||
</Stack>
|
||||
) : version ? (
|
||||
<VersionInfo version={version} />
|
||||
) : (
|
||||
<Text size='xs' c='dimmed'>
|
||||
Version information could not be loaded.
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { mutateFiles } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { getDomain } from '@/lib/client/webDomain';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconFilesOff, IconStarsFilled, IconStarsOff, IconTrashFilled } from '@tabler/icons-react';
|
||||
import {
|
||||
IconClipboardListFilled,
|
||||
IconFilesOff,
|
||||
IconStarsFilled,
|
||||
IconStarsOff,
|
||||
IconTrashFilled,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export async function bulkDelete(ids: string[], setSelectedFiles: (files: File[]) => void) {
|
||||
modals.openConfirmModal({
|
||||
@@ -130,3 +137,17 @@ export async function bulkFavorite(ids: string[], favorite: boolean) {
|
||||
onCancel: modals.closeAll,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bulkCopyLinks(urls: string[]) {
|
||||
const links = urls.map((url) => getDomain(url)).join('\n');
|
||||
|
||||
await navigator.clipboard.writeText(links);
|
||||
|
||||
notifications.show({
|
||||
title: 'Copied links to clipboard',
|
||||
message: `Copied ${urls.length} link${urls.length === 1 ? '' : 's'} to clipboard`,
|
||||
color: 'green',
|
||||
icon: <IconClipboardListFilled size='1rem' />,
|
||||
autoClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ import useSWR from 'swr';
|
||||
import { useShallow } from 'zustand/shallow';
|
||||
import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..';
|
||||
import TableEditModal from '../TableEditModal';
|
||||
import { bulkDelete, bulkFavorite } from '../bulk';
|
||||
import { bulkCopyLinks, bulkDelete, bulkFavorite } from '../bulk';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
@@ -399,7 +399,8 @@ export default function FileTable({
|
||||
<Collapse expanded={selectedFiles.length > 0}>
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Text size='sm' c='dimmed' mb='xs'>
|
||||
Selections are saved across page changes
|
||||
Selections are saved across page changes. Currently selected <b>{selectedFiles.length}</b> file
|
||||
{selectedFiles.length > 1 ? 's' : ''}.
|
||||
</Text>
|
||||
|
||||
<Group>
|
||||
@@ -415,7 +416,7 @@ export default function FileTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete {selectedFiles.length} file{selectedFiles.length > 1 ? 's' : ''}
|
||||
Delete files
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -429,8 +430,15 @@ export default function FileTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} {selectedFiles.length} file
|
||||
{selectedFiles.length > 1 ? 's' : ''}
|
||||
{unfavoriteAll ? 'Unfavorite' : 'Favorite'} files
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
leftSection={<IconCopy size='1rem' />}
|
||||
onClick={() => bulkCopyLinks(selectedFiles.map((x) => x.url!))}
|
||||
>
|
||||
Copy file links
|
||||
</Button>
|
||||
|
||||
{!id && (
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function DashboardFolders() {
|
||||
data: currentFolder,
|
||||
error: currentFolderError,
|
||||
isLoading,
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}` : null);
|
||||
} = useSWR<Folder>(currentFolderId ? `/api/user/folders/${currentFolderId}?noincl=true` : null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { buildFolderHierarchy } from '@/lib/folderHierarchy';
|
||||
import { openWarningModal } from '@/lib/client/warningModal';
|
||||
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||
import { Button, Combobox, InputBase, Modal, Radio, Stack, Text, useCombobox } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
@@ -10,7 +11,7 @@ import { IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { mutateFolder } from '../actions';
|
||||
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade';
|
||||
type ChildrenAction = 'root' | 'folder' | 'cascade' | 'cascade-files';
|
||||
|
||||
export default function DeleteFolderModal({
|
||||
folder,
|
||||
@@ -47,29 +48,9 @@ export default function DeleteFolderModal({
|
||||
return selected?.path || '';
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const performDelete = async (body: any) => {
|
||||
setLoading(true);
|
||||
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await fetchApi<Response['/api/user/folders/[id]']>(
|
||||
`/api/user/folders/${folder.id}`,
|
||||
'DELETE',
|
||||
@@ -95,6 +76,46 @@ export default function DeleteFolderModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const body: any = {
|
||||
delete: 'folder',
|
||||
};
|
||||
|
||||
if (hasContent) {
|
||||
body.childrenAction = childrenAction;
|
||||
if (childrenAction === 'folder') {
|
||||
if (!targetFolderId) {
|
||||
notifications.show({
|
||||
title: 'No folder selected',
|
||||
message: 'Please select a folder to move contents to',
|
||||
color: 'red',
|
||||
});
|
||||
return;
|
||||
}
|
||||
body.targetFolderId = targetFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContent && (childrenAction === 'cascade' || childrenAction === 'cascade-files')) {
|
||||
openWarningModal({
|
||||
confirmLabel: `Delete '${folder.name}' and ${childrenAction === 'cascade-files' ? 'all subfolders and files' : 'all subfolders'}?`,
|
||||
message: (
|
||||
<Stack gap='sm'>
|
||||
<Text c='red' fw={500}>
|
||||
{childrenAction === 'cascade-files'
|
||||
? 'All subfolders and every file within them will be permanently deleted from storage. This action cannot be undone.'
|
||||
: 'All subfolders will be permanently deleted (files will be moved to the root). This action cannot be undone.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
onConfirm: () => performDelete(body),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performDelete(body);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal centered opened={opened} onClose={onClose} title={`Delete "${folder.name}"?`}>
|
||||
<Stack gap='sm'>
|
||||
@@ -118,7 +139,15 @@ export default function DeleteFolderModal({
|
||||
value='cascade'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete everything (cascade delete)
|
||||
Delete subfolders (files moved to root)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Radio
|
||||
value='cascade-files'
|
||||
label={
|
||||
<Text size='sm' c='red'>
|
||||
Delete subfolders and their files (cascade delete)
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
@@ -171,8 +200,15 @@ export default function DeleteFolderModal({
|
||||
|
||||
{childrenAction === 'cascade' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all contents within this folder (subfolders will be
|
||||
deleted, and files will be unlinked from their folders).
|
||||
Warning: This will permanently delete all subfolders within this folder. Files will be
|
||||
unlinked from their folders and moved to the root.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{childrenAction === 'cascade-files' && (
|
||||
<Text size='sm' c='red' fw={500}>
|
||||
Warning: This will permanently delete all subfolders within this folder, along with every file
|
||||
contained in them. The files will be removed from storage and cannot be recovered.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function FolderGridView({
|
||||
currentFolderId: string | null;
|
||||
onNavigate: (folderId: string | null) => void;
|
||||
}) {
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}&noincl=true` : '?root=true&noincl=true';
|
||||
const { data: folders, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function FolderTableView({
|
||||
}) {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}` : '?root=true';
|
||||
const queryParam = currentFolderId ? `?parentId=${currentFolderId}&noincl=true` : '?root=true&noincl=true';
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
|
||||
`/api/user/folders${queryParam}`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Modal, Center, PinInput, Text, Group, Button } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { IconX, IconShieldQuestion } from '@tabler/icons-react';
|
||||
|
||||
export default function TotpModal({
|
||||
@@ -12,6 +13,8 @@ export default function TotpModal({
|
||||
onVerify: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const mobile = useMediaQuery('(max-width: 600px)');
|
||||
|
||||
return (
|
||||
<Modal onClose={onCancel} title='Enter code' opened={state.open} withCloseButton={false}>
|
||||
<form onSubmit={onVerify}>
|
||||
@@ -23,7 +26,7 @@ export default function TotpModal({
|
||||
onChange={onPinChange}
|
||||
error={!!state.error}
|
||||
disabled={state.disabled}
|
||||
size='xl'
|
||||
size={mobile ? 'md' : 'xl'}
|
||||
autoFocus
|
||||
/>
|
||||
</Center>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import ClearTempButton from './actions/ClearTempButton';
|
||||
import ClearZerosButton from './actions/ClearZerosButton';
|
||||
import GenThumbsButton from './actions/GenThumbsButton';
|
||||
@@ -10,6 +11,7 @@ const ACTIONS = [
|
||||
name: 'Import/Export Data',
|
||||
desc: 'Allows you to import or export server data and configurations.',
|
||||
Component: ImportExport,
|
||||
superAdminOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'Clear Temporary Files',
|
||||
@@ -34,6 +36,9 @@ const ACTIONS = [
|
||||
];
|
||||
|
||||
export default function DashboardServerActions() {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const actions = ACTIONS.filter((action) => !action.superAdminOnly || user?.role === 'SUPERADMIN');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap='sm'>
|
||||
@@ -43,7 +48,7 @@ export default function DashboardServerActions() {
|
||||
Useful tools and scripts for server management.
|
||||
</Text>
|
||||
<Stack gap='xs' my='sm'>
|
||||
{ACTIONS.map(({ name, desc, Component }) => (
|
||||
{actions.map(({ name, desc, Component }) => (
|
||||
<Paper withBorder p='sm' key={name}>
|
||||
<Group gap='md'>
|
||||
<Component />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LinksList } from '@/components/LinksList';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useTitle } from '@/lib/client/hooks/useTitle';
|
||||
import {
|
||||
@@ -9,8 +10,6 @@ import {
|
||||
Collapse,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
@@ -176,9 +175,10 @@ const SETTINGS_COMPONENTS = {
|
||||
export const SETTINGS_EXTERNAL_LINKS = Object.values(SETTINGS_COMPONENTS)
|
||||
.filter((setting) => setting.component !== null)
|
||||
.map((setting) => ({
|
||||
name: setting.name,
|
||||
url: `/dashboard/admin/settings/${setting.key}`,
|
||||
icon: setting.Icon ? <setting.Icon size='1rem' /> : <IconAdjustmentsHorizontalFilled size='1rem' />,
|
||||
label: setting.name,
|
||||
description: setting.desc,
|
||||
href: `/dashboard/admin/settings/${setting.key}`,
|
||||
icon: setting.Icon ? setting.Icon : IconAdjustmentsHorizontalFilled,
|
||||
}));
|
||||
|
||||
const SETTINGS_PART_KEYS = Object.keys(SETTINGS_COMPONENTS)
|
||||
@@ -332,31 +332,9 @@ export default function DashboardServerSettings() {
|
||||
</Suspense>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack mt='md' gap='md'>
|
||||
{Object.entries(SETTINGS_COMPONENTS)
|
||||
.filter(([key]) => key !== 'settings')
|
||||
.map(([k, { key, Icon, name, desc }]) => (
|
||||
<Anchor
|
||||
key={k}
|
||||
component={Link}
|
||||
to={`/dashboard/admin/settings/${key}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Paper withBorder p='sm'>
|
||||
<Group gap='md'>
|
||||
<ActionIcon variant='filled' radius='md' size='xl'>
|
||||
{Icon ? <Icon size='1.75rem' /> : <IconAdjustmentsHorizontalFilled size='1.75rem' />}
|
||||
</ActionIcon>
|
||||
|
||||
<div>
|
||||
<Title order={4}>{name}</Title>
|
||||
<Text c='dimmed'>{desc}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Anchor>
|
||||
))}
|
||||
</Stack>
|
||||
<Box my='sm'>
|
||||
<LinksList links={SETTINGS_EXTERNAL_LINKS} />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
|
||||
filesRandomWordsSeparator: data.settings.filesRandomWordsSeparator,
|
||||
filesDefaultCompressionFormat: data.settings.filesDefaultCompressionFormat,
|
||||
filesMaxFilesPerUpload: data.settings.filesMaxFilesPerUpload,
|
||||
filesExtensionlessUrls: data.settings.filesExtensionlessUrls,
|
||||
},
|
||||
enhanceGetInputProps: (payload) => ({
|
||||
disabled: data.tampered.includes(payload.field) || false,
|
||||
@@ -100,6 +101,12 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
|
||||
{...form.getInputProps('filesRemoveGpsMetadata', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Extensionless URLs'
|
||||
description='Allow file links without the extension (e.g. /u/uuid instead of /u/uuid.png). Upload responses still include the extension.'
|
||||
{...form.getInputProps('filesExtensionlessUrls', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label='Route'
|
||||
description='The route to use for file uploads. Requires a server restart.'
|
||||
|
||||
@@ -58,6 +58,7 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
enabled: user.view.enabled || false,
|
||||
disableTextFiles: user.view.disableTextFiles || false,
|
||||
content: user.view.content || '',
|
||||
embed: user.view.embed || false,
|
||||
embedMediaOnly: user.view.embedMediaOnly || false,
|
||||
@@ -73,8 +74,9 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
const valuesTrimmed = {
|
||||
const view = {
|
||||
enabled: values.enabled,
|
||||
disableTextFiles: values.disableTextFiles,
|
||||
embed: values.embed,
|
||||
embedMediaOnly: values.embed ? false : values.embedMediaOnly,
|
||||
content: values.content.trim() || null,
|
||||
@@ -89,7 +91,7 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
};
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/user']>('/api/user', 'PATCH', {
|
||||
view: valuesTrimmed,
|
||||
view,
|
||||
});
|
||||
|
||||
if (!data && error) {
|
||||
@@ -124,6 +126,12 @@ function Form({ user, setUser }: { user: User; setUser: (u: User) => void }) {
|
||||
<Stack gap='sm' mt='xs'>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing='sm' mb='xs'>
|
||||
<Switch
|
||||
label='Disable text files'
|
||||
description='Disable viewing text files through view-routes. This has no effect on other file types and will work even if view-routes are disabled.'
|
||||
{...form.getInputProps('disableTextFiles', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label='Enable View Routes'
|
||||
description='Enable viewing files through customizable view-routes'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { readToDataURL } from '@/lib/base64';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
@@ -31,7 +31,7 @@ export default function EditUserModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
user?: User | null;
|
||||
user?: LimitedUser | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { ActionIcon, Avatar, Card, Group, Menu, Stack, Text } from '@mantine/core';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { IconDots, IconFiles, IconTrashFilled, IconUserEdit } from '@tabler/icons-react';
|
||||
@@ -9,7 +9,7 @@ import RelativeDate from '@/components/RelativeDate';
|
||||
import { canInteract, isAdministrator, roleName } from '@/lib/role';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function UserCard({ user }: { user: User }) {
|
||||
export default function UserCard({ user }: { user: LimitedUser }) {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
|
||||
const [opened, setOpen] = useState(false);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconUserCancel, IconUserMinus } from '@tabler/icons-react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export async function deleteUser(user: User) {
|
||||
export async function deleteUser(user: LimitedUser) {
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: `Delete ${user.username}?`,
|
||||
@@ -33,7 +33,7 @@ export async function deleteUser(user: User) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteUser(user: User, deleteFiles: boolean = false) {
|
||||
async function handleDeleteUser(user: LimitedUser, deleteFiles: boolean = false) {
|
||||
const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'DELETE', {
|
||||
delete: deleteFiles,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { readToDataURL } from '@/lib/base64';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
@@ -68,7 +67,7 @@ export default function DashboardUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/users'], User>>('/api/users', 'POST', {
|
||||
const { data, error } = await fetchApi<LimitedUser>('/api/users', 'POST', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
role: values.role ?? 'USER',
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { Center, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconFilesOff } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
import UserCard from '../UserCard';
|
||||
|
||||
export default function UserGridView() {
|
||||
const { data: users, isLoading } =
|
||||
useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
|
||||
const { data: users, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { useUserStore } from '@/lib/client/store/user';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { LimitedUser } from '@/lib/db/models/user';
|
||||
import { canInteract, roleName } from '@/lib/role';
|
||||
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconFiles, IconTrashFilled } from '@tabler/icons-react';
|
||||
@@ -15,20 +14,20 @@ import EditUserModal from '../EditUserModal';
|
||||
export default function UserTableView() {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>('/api/users?noincl=true');
|
||||
const { data, isLoading } = useSWR<LimitedUser[]>('/api/users?noincl=true');
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<LimitedUser | null>(null);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const sorted = useMemo<User[]>(() => {
|
||||
const sorted = useMemo<LimitedUser[]>(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const { columnAccessor, direction } = sortStatus;
|
||||
const key = columnAccessor as keyof User;
|
||||
const key = columnAccessor as keyof LimitedUser;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[key]!;
|
||||
|
||||
@@ -92,6 +92,7 @@ export const API_ERRORS = {
|
||||
3016: 'OAuth registration is disabled',
|
||||
3017: 'OAuth login is not allowed for this account',
|
||||
3018: 'Invalid access token provided.',
|
||||
3019: 'You cannot modify this user',
|
||||
|
||||
// 4xxx, not founds
|
||||
4000: 'File not found',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ApiServerImportV3 } from '@/server/routes/api/server/import/v3';
|
||||
import { ApiServerImportV4 } from '@/server/routes/api/server/import/v4';
|
||||
import { ApiServerPublicResponse } from '@/server/routes/api/server/public';
|
||||
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
|
||||
import { ApiServerStatusResponse } from '@/server/routes/api/server/status';
|
||||
import { ApiServerSettingsResponse, ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
|
||||
import { ApiServerThemesResponse } from '@/server/routes/api/server/themes';
|
||||
import { ApiServerThumbnailsResponse } from '@/server/routes/api/server/thumbnails';
|
||||
@@ -80,6 +81,7 @@ export type Response = {
|
||||
'/api/server/clear_temp': ApiServerClearTempResponse;
|
||||
'/api/server/clear_zeros': ApiServerClearZerosResponse;
|
||||
'/api/server/requery_size': ApiServerRequerySizeResponse;
|
||||
'/api/server/status': ApiServerStatusResponse;
|
||||
'/api/server/settings': ApiServerSettingsResponse;
|
||||
'/api/server/settings/web': ApiServerSettingsWebResponse;
|
||||
'/api/server/public': ApiServerPublicResponse;
|
||||
|
||||
@@ -19,5 +19,5 @@ export default function useVersion() {
|
||||
revalidateOnReconnect: false,
|
||||
});
|
||||
|
||||
return { version: data?.data, isLoading };
|
||||
return { version: data?.data, details: data?.details, cached: data?.cached, isLoading };
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function openWarningModal(options: WarningModalOptions) {
|
||||
onCancel: () => modals.closeAll(),
|
||||
onConfirm: options.onConfirm,
|
||||
zIndex: 10320948239487,
|
||||
size: 'auto',
|
||||
size: 'md',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export const DATABASE_TO_PROP = {
|
||||
filesRandomWordsSeparator: 'files.randomWordsSeparator',
|
||||
filesDefaultCompressionFormat: 'files.defaultCompressionFormat',
|
||||
filesMaxFilesPerUpload: 'files.maxFilesPerUpload',
|
||||
filesExtensionlessUrls: 'files.extensionlessUrls',
|
||||
|
||||
urlsRoute: 'urls.route',
|
||||
urlsLength: 'urls.length',
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ENVS = [
|
||||
env('files.randomWordsSeparator', 'FILES_RANDOM_WORDS_SEPARATOR', 'string', true),
|
||||
env('files.defaultCompressionFormat', 'FILES_DEFAULT_COMPRESSION_FORMAT', 'string', true),
|
||||
env('files.maxFilesPerUpload', 'FILES_MAX_FILES_PER_UPLOAD', 'number', true),
|
||||
env('files.extensionlessUrls', 'FILES_EXTENSIONLESS_URLS', 'boolean', true),
|
||||
|
||||
env('urls.route', 'URLS_ROUTE', 'string', true),
|
||||
env('urls.length', 'URLS_LENGTH', 'number', true),
|
||||
|
||||
@@ -49,6 +49,7 @@ export const rawConfig: any = {
|
||||
randomWordsSeparator: undefined,
|
||||
defaultCompressionFormat: undefined,
|
||||
maxFilesPerUpload: undefined,
|
||||
extensionlessUrls: undefined,
|
||||
},
|
||||
urls: {
|
||||
route: undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import enabled from '../oauth/enabled';
|
||||
import { version } from '../../../package.json';
|
||||
import { Config } from './validate';
|
||||
|
||||
export type SafeConfig = Omit<
|
||||
@@ -21,6 +22,7 @@ export function safeConfig(config: Config): SafeConfig {
|
||||
bypassLocalLogin: oauth.bypassLocalLogin,
|
||||
loginOnly: oauth.loginOnly,
|
||||
};
|
||||
(rest as SafeConfig).version = version;
|
||||
|
||||
return rest as SafeConfig;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ export const schema = z.object({
|
||||
.default('jpg')
|
||||
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
|
||||
maxFilesPerUpload: z.number().max(2147483647).min(1).default(1000),
|
||||
extensionlessUrls: z.boolean().default(false),
|
||||
}),
|
||||
urls: z.object({
|
||||
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
|
||||
|
||||
@@ -14,6 +14,10 @@ async function existsAndCanRW(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
function isCrossDeviceMove(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EXDEV';
|
||||
}
|
||||
|
||||
export class LocalDatasource extends Datasource {
|
||||
name = 'local';
|
||||
logger = log('datasource').c('local');
|
||||
@@ -44,7 +48,7 @@ export class LocalDatasource extends Datasource {
|
||||
const path = this.resolvePath(file);
|
||||
if (!path) throw new Error('Invalid path provided');
|
||||
|
||||
// handles if given a path to a file, it will just move it instead of doing unecessary writes
|
||||
// handles path-based writes without duplicating bytes when the source can be consumed
|
||||
if (typeof data === 'string' && data.startsWith('/')) {
|
||||
const exists = await existsAndCanRW(data);
|
||||
if (!exists)
|
||||
@@ -52,6 +56,15 @@ export class LocalDatasource extends Datasource {
|
||||
"Something went very wrong! the temporary directory wasn't readable or the file doesn't exist.",
|
||||
);
|
||||
|
||||
if (!noDelete) {
|
||||
try {
|
||||
await rename(data, path);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!isCrossDeviceMove(e)) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
await copyFile(data, path);
|
||||
|
||||
if (!noDelete) await rm(data);
|
||||
|
||||
+21
-12
@@ -7,6 +7,7 @@ import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
UploadPartCopyCommand,
|
||||
@@ -256,23 +257,31 @@ export class S3Datasource extends Datasource {
|
||||
}
|
||||
|
||||
public async totalSize(): Promise<number> {
|
||||
const command = new ListObjectsCommand({
|
||||
Bucket: this.options.bucket,
|
||||
Prefix: this.options.subdirectory ?? undefined,
|
||||
Delimiter: this.options.subdirectory ? undefined : '/',
|
||||
});
|
||||
let total = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
try {
|
||||
const res = await this.client.send(command);
|
||||
do {
|
||||
const res = await this.client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.options.bucket,
|
||||
Prefix: this.options.subdirectory ?? undefined,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while listing objects');
|
||||
this.logger.error('error metadata', res.$metadata as Record<string, unknown>);
|
||||
if (!isOk(res.$metadata.httpStatusCode || 0)) {
|
||||
this.logger.error('there was an error while listing objects');
|
||||
this.logger.error('error metadata', res.$metadata as Record<string, unknown>);
|
||||
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return res.Contents?.reduce((acc, obj) => acc + Number(obj.Size), 0) ?? 0;
|
||||
total += res.Contents?.reduce((acc, obj) => acc + Number(obj.Size ?? 0), 0) ?? 0;
|
||||
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
return total;
|
||||
} catch (e) {
|
||||
this.logger.error('there was an error while listing objects');
|
||||
this.logger.error('error metadata', e as Record<string, unknown>);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { formatRootUrl } from '@/lib/url';
|
||||
import type { Prisma } from '@/prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { tagSchema, tagSelectNoFiles } from './tag';
|
||||
|
||||
@@ -27,6 +29,20 @@ export const fileSelect = {
|
||||
},
|
||||
};
|
||||
|
||||
export async function findFileByName<TResult>(
|
||||
id: string,
|
||||
query: (
|
||||
where: Prisma.FileWhereInput,
|
||||
orderBy?: Prisma.FileOrderByWithRelationInput,
|
||||
) => Promise<TResult | null>,
|
||||
) {
|
||||
const name = sanitizeFilename(id);
|
||||
if (!name) return null;
|
||||
const file = await query({ name });
|
||||
if (file || !config.files.extensionlessUrls || name.includes('.')) return file;
|
||||
return query({ name: { startsWith: `${name}.` } }, { createdAt: 'desc' });
|
||||
}
|
||||
|
||||
export function cleanFile(file: File) {
|
||||
file.password = !!file.password;
|
||||
|
||||
|
||||
@@ -38,30 +38,39 @@ export async function buildPublicParentChain(parentId: string | null): Promise<F
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanFolder<T extends Partial<Folder>>(folder: T, stringifyDates = false): T {
|
||||
type CleanableFolder = {
|
||||
createdAt?: string | Date;
|
||||
updatedAt?: string | Date;
|
||||
files?: unknown;
|
||||
children?: unknown;
|
||||
parent?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function cleanFolder<T extends CleanableFolder>(folder: T, stringifyDates = false): T {
|
||||
if (folder.files && Array.isArray(folder.files)) cleanFiles(folder.files as any, stringifyDates);
|
||||
|
||||
if (stringifyDates) {
|
||||
if (folder.createdAt && folder.createdAt instanceof Date)
|
||||
folder.createdAt = folder.createdAt.toISOString();
|
||||
(folder as CleanableFolder).createdAt = folder.createdAt.toISOString();
|
||||
if (folder.updatedAt && folder.updatedAt instanceof Date)
|
||||
folder.updatedAt = folder.updatedAt.toISOString();
|
||||
(folder as CleanableFolder).updatedAt = folder.updatedAt.toISOString();
|
||||
}
|
||||
|
||||
if (folder.children && Array.isArray(folder.children)) {
|
||||
for (const child of folder.children) {
|
||||
cleanFolder(child, stringifyDates);
|
||||
if (child && typeof child === 'object') cleanFolder(child as CleanableFolder, stringifyDates);
|
||||
}
|
||||
}
|
||||
|
||||
if (folder.parent && typeof folder.parent === 'object') {
|
||||
cleanFolder(folder.parent, stringifyDates);
|
||||
cleanFolder(folder.parent as CleanableFolder, stringifyDates);
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
export function cleanFolders<T extends Partial<Folder>>(folders: T[], stringifyDates = false): T[] {
|
||||
export function cleanFolders<T extends CleanableFolder>(folders: T[], stringifyDates = false): T[] {
|
||||
for (let i = 0; i !== folders.length; ++i) {
|
||||
cleanFolder(folders[i], stringifyDates);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,20 @@ export const userSelect = {
|
||||
sessions: true,
|
||||
};
|
||||
|
||||
export const limitedUserSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true,
|
||||
view: true,
|
||||
quota: true,
|
||||
};
|
||||
|
||||
export const userViewSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().nullish(),
|
||||
disableTextFiles: z.boolean().nullish(),
|
||||
align: z.enum(['left', 'center', 'right']).nullish(),
|
||||
showMimetype: z.boolean().nullish(),
|
||||
showTags: z.boolean().nullish(),
|
||||
@@ -106,3 +117,14 @@ export const userSchema = z.object({
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
export const limitedUserSchema = userSchema.omit({
|
||||
oauthProviders: true,
|
||||
totpSecret: true,
|
||||
passkeys: true,
|
||||
sessions: true,
|
||||
password: true,
|
||||
token: true,
|
||||
});
|
||||
|
||||
export type LimitedUser = z.infer<typeof limitedUserSchema>;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { statfs } from 'fs/promises';
|
||||
import { config } from './config';
|
||||
import { datasource } from './datasource';
|
||||
import z from 'zod';
|
||||
|
||||
export const diskStatusSchema = z.object({
|
||||
used: z.number(),
|
||||
total: z.number().nullable(),
|
||||
available: z.number().nullable(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export type DiskStatus = z.infer<typeof diskStatusSchema>;
|
||||
|
||||
async function localDiskStatus() {
|
||||
const path = config.datasource.local!.directory;
|
||||
const stats = await statfs(path);
|
||||
|
||||
const total = stats.blocks * stats.bsize;
|
||||
const available = stats.bavail * stats.bsize;
|
||||
const used = total - stats.bfree * stats.bsize;
|
||||
|
||||
return { used, total, available, path };
|
||||
}
|
||||
|
||||
async function s3DiskStatus() {
|
||||
const s3 = config.datasource.s3!;
|
||||
const totalSize = await datasource.totalSize();
|
||||
const path = `${s3.bucket}${s3.subdirectory ? `/${s3.subdirectory.replace(/\/$/, '')}` : ''}`;
|
||||
|
||||
return {
|
||||
used: totalSize,
|
||||
total: null,
|
||||
available: null,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function diskStatus() {
|
||||
return config.datasource.type === 'local' ? localDiskStatus() : s3DiskStatus();
|
||||
}
|
||||
@@ -11,6 +11,12 @@ export function canInteract(current?: Role, target?: Role) {
|
||||
);
|
||||
}
|
||||
|
||||
export function interactableRoles(current?: Role): Role[] {
|
||||
if (current === 'SUPERADMIN') return ['USER', 'ADMIN'];
|
||||
if (current === 'ADMIN') return ['USER'];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function roleName(role?: Role) {
|
||||
switch (role) {
|
||||
case 'USER':
|
||||
|
||||
@@ -70,6 +70,8 @@ export default typedPlugin(
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015);
|
||||
|
||||
if (req.query.counts) {
|
||||
const counts = await getCounts();
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ export default typedPlugin(
|
||||
.enum(COMPRESS_TYPES)
|
||||
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
|
||||
filesMaxFilesPerUpload: z.number().min(1).max(2147483647),
|
||||
filesExtensionlessUrls: z.boolean(),
|
||||
|
||||
urlsRoute: z
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { diskStatus, diskStatusSchema } from '@/lib/disk';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import z from 'zod';
|
||||
|
||||
export const apiServerStatusResponseSchema = z.object({
|
||||
datasource: z.enum(['local', 's3']).describe('Configured datasource type for this Zipline instance.'),
|
||||
storage: diskStatusSchema,
|
||||
});
|
||||
|
||||
export type ApiServerStatusResponse = z.infer<typeof apiServerStatusResponseSchema>;
|
||||
|
||||
export const PATH = '/api/server/status';
|
||||
export default typedPlugin(
|
||||
async (server) => {
|
||||
server.get(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Get disk status for the configured datasource',
|
||||
response: {
|
||||
200: apiServerStatusResponseSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
const status = await diskStatus();
|
||||
|
||||
return res.send({
|
||||
datasource: config.datasource.type,
|
||||
storage: status,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
{ name: PATH },
|
||||
);
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { fileSelect } from '@/lib/db/models/file';
|
||||
import { buildParentChain, Folder, cleanFolder, folderSchema } from '@/lib/db/models/folder';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
import { zQsBoolean, zStringTrimmed } from '@/lib/validation';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
@@ -29,6 +30,11 @@ const paramsSchema = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
const folderMutationInclude = {
|
||||
_count: { select: { children: true, files: true } },
|
||||
parent: { select: { id: true, name: true, parentId: true } },
|
||||
} as const;
|
||||
|
||||
const folderExistsAndEditable = async (req: FastifyRequest) => {
|
||||
const { id } = req.params as z.infer<typeof paramsSchema>;
|
||||
|
||||
@@ -52,8 +58,12 @@ export default typedPlugin(
|
||||
PATH,
|
||||
{
|
||||
schema: {
|
||||
description: 'Fetch a specific folder by ID, including files, children, and its parent chain.',
|
||||
description:
|
||||
'Fetch a specific folder by ID, optionally including files, children, and its parent chain.',
|
||||
params: paramsSchema,
|
||||
querystring: z.object({
|
||||
noincl: zQsBoolean.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: folderSchema.partial(),
|
||||
},
|
||||
@@ -63,18 +73,21 @@ export default typedPlugin(
|
||||
},
|
||||
async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { noincl } = req.query;
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
...(!noincl && {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
User: true,
|
||||
children: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -98,7 +111,7 @@ export default typedPlugin(
|
||||
(folder as any).parent = await buildParentChain(folder.parentId);
|
||||
}
|
||||
|
||||
return res.send(cleanFolder(folder));
|
||||
return res.send(cleanFolder(folder as unknown as Partial<Folder>));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -149,15 +162,7 @@ export default typedPlugin(
|
||||
data: {
|
||||
files: { connect: { id } },
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
User: true,
|
||||
},
|
||||
include: folderMutationInclude,
|
||||
});
|
||||
|
||||
logger.info('file added to folder', { folder: folderId, file: id });
|
||||
@@ -227,20 +232,7 @@ export default typedPlugin(
|
||||
...(allowUploads !== undefined && { allowUploads }),
|
||||
...(parentId !== undefined && { parentId }),
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { children: true, files: true },
|
||||
},
|
||||
parent: {
|
||||
select: { id: true, name: true, parentId: true },
|
||||
},
|
||||
},
|
||||
include: folderMutationInclude,
|
||||
});
|
||||
|
||||
logger.info('folder updated', {
|
||||
@@ -267,7 +259,7 @@ export default typedPlugin(
|
||||
delete: z.enum(['file', 'folder']),
|
||||
id: zStringTrimmed.optional(),
|
||||
|
||||
childrenAction: z.enum(['root', 'folder', 'cascade']).optional(),
|
||||
childrenAction: z.enum(['root', 'folder', 'cascade', 'cascade-files']).optional(),
|
||||
targetFolderId: z.string().optional(),
|
||||
}),
|
||||
params: paramsSchema,
|
||||
@@ -299,6 +291,8 @@ export default typedPlugin(
|
||||
}
|
||||
|
||||
try {
|
||||
const toDeleteFiles: string[] = [];
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
if (!childrenAction) {
|
||||
return { success: true };
|
||||
@@ -320,7 +314,9 @@ export default typedPlugin(
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} else if (childrenAction === 'cascade') {
|
||||
} else if (childrenAction === 'cascade' || childrenAction === 'cascade-files') {
|
||||
const deleteFiles = childrenAction === 'cascade-files';
|
||||
|
||||
const deleteRecursive = async (id: string) => {
|
||||
const children = await tx.folder.findMany({
|
||||
where: { parentId: id },
|
||||
@@ -329,6 +325,16 @@ export default typedPlugin(
|
||||
for (const child of children) {
|
||||
await deleteRecursive(child.id);
|
||||
}
|
||||
|
||||
if (deleteFiles) {
|
||||
const files = await tx.file.findMany({
|
||||
where: { folderId: id },
|
||||
select: { name: true },
|
||||
});
|
||||
toDeleteFiles.push(...files.map((f) => f.name));
|
||||
await tx.file.deleteMany({ where: { folderId: id } });
|
||||
}
|
||||
|
||||
await tx.folder.delete({ where: { id } });
|
||||
};
|
||||
|
||||
@@ -341,7 +347,11 @@ export default typedPlugin(
|
||||
if (!result?.success) throw new ApiError(1019);
|
||||
|
||||
if (result?.isCascade) {
|
||||
logger.info('folder cascade deleted', { folder: folderId });
|
||||
for (const name of toDeleteFiles) {
|
||||
await datasource.delete(name);
|
||||
}
|
||||
|
||||
logger.info('folder cascade deleted', { folder: folderId, files: toDeleteFiles.length });
|
||||
return res.send({ success: true });
|
||||
} else {
|
||||
await prisma.folder.delete({ where: { id: folderId } });
|
||||
@@ -379,14 +389,7 @@ export default typedPlugin(
|
||||
data: {
|
||||
files: { disconnect: { id } },
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
...fileSelect,
|
||||
password: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: folderMutationInclude,
|
||||
});
|
||||
|
||||
logger.info('file removed from folder', { folder: nFolder.id, file: id });
|
||||
|
||||
@@ -37,24 +37,23 @@ export default typedPlugin(
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const { noincl, user, parentId, root } = req.query;
|
||||
const { noincl, user: userId, parentId, root } = req.query;
|
||||
|
||||
if (user) {
|
||||
const user = await prisma.user.findUnique({
|
||||
if (userId) {
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: req.user.id,
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new ApiError(4009);
|
||||
if (req.user.id !== user.id) {
|
||||
if (!canInteract(req.user.role, user.role)) throw new ApiError(4009);
|
||||
}
|
||||
if (!targetUser) throw new ApiError(4009);
|
||||
if (req.user.id !== targetUser.id && !canInteract(req.user.role, targetUser.role))
|
||||
throw new ApiError(4009);
|
||||
}
|
||||
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: user || req.user.id,
|
||||
userId: userId || req.user.id,
|
||||
...(root && { parentId: null }),
|
||||
...(parentId && { parentId }),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ export default typedPlugin(
|
||||
embedColor: z.string().nullish(),
|
||||
embedSiteName: z.string().nullish(),
|
||||
enabled: z.boolean().optional(),
|
||||
disableTextFiles: z.boolean().optional(),
|
||||
align: z.enum(['left', 'center', 'right']).optional(),
|
||||
showMimetype: z.boolean().optional(),
|
||||
showTags: z.boolean().optional(),
|
||||
@@ -100,6 +101,9 @@ export default typedPlugin(
|
||||
view: {
|
||||
...req.user.view,
|
||||
...(req.body.view.enabled !== undefined && { enabled: req.body.view.enabled || false }),
|
||||
...(req.body.view.disableTextFiles !== undefined && {
|
||||
disableTextFiles: req.body.view.disableTextFiles || false,
|
||||
}),
|
||||
...(req.body.view.content !== undefined && { content: req.body.view.content || null }),
|
||||
...(req.body.view.embed !== undefined && { embed: req.body.view.embed || false }),
|
||||
...(req.body.view.embedMediaOnly !== undefined && {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { bytes } from '@/lib/bytes';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { LimitedUser, limitedUserSchema, limitedUserSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { zStringTrimmed } from '@/lib/validation';
|
||||
@@ -13,7 +13,7 @@ import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ApiUsersIdResponse = User;
|
||||
export type ApiUsersIdResponse = LimitedUser;
|
||||
|
||||
const logger = log('api').c('users').c('[id]');
|
||||
|
||||
@@ -31,7 +31,7 @@ export default typedPlugin(
|
||||
description: 'Fetch a specific user by ID, including their profile and role (admin only).',
|
||||
params: paramsSchema,
|
||||
response: {
|
||||
200: userSchema,
|
||||
200: limitedUserSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
@@ -42,10 +42,11 @@ export default typedPlugin(
|
||||
where: {
|
||||
id: req.params.id,
|
||||
},
|
||||
select: userSelect,
|
||||
select: limitedUserSelect,
|
||||
});
|
||||
|
||||
if (!user) throw new ApiError(4009);
|
||||
if (!canInteract(req.user.role, user.role)) throw new ApiError(4009);
|
||||
|
||||
return res.send(user);
|
||||
},
|
||||
@@ -73,7 +74,7 @@ export default typedPlugin(
|
||||
.optional(),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
200: limitedUserSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
@@ -84,9 +85,13 @@ export default typedPlugin(
|
||||
where: {
|
||||
id: req.params.id,
|
||||
},
|
||||
select: userSelect,
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new ApiError(4009);
|
||||
if (!canInteract(req.user.role, user.role)) throw new ApiError(3019);
|
||||
|
||||
const { username, password, avatar, role, quota } = req.body;
|
||||
if (role && !canInteract(req.user.role, role)) throw new ApiError(3007);
|
||||
@@ -149,11 +154,7 @@ export default typedPlugin(
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
totpSecret: false,
|
||||
passkeys: false,
|
||||
},
|
||||
select: limitedUserSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} updated another user`, {
|
||||
@@ -176,7 +177,7 @@ export default typedPlugin(
|
||||
delete: z.boolean().optional(),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
200: limitedUserSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
@@ -187,7 +188,11 @@ export default typedPlugin(
|
||||
where: {
|
||||
id: req.params.id,
|
||||
},
|
||||
select: userSelect,
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new ApiError(4009);
|
||||
@@ -242,10 +247,7 @@ export default typedPlugin(
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
totpSecret: false,
|
||||
},
|
||||
select: limitedUserSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} deleted another user`, {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { createToken, hashPassword } from '@/lib/crypto';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSchema, userSelect } from '@/lib/db/models/user';
|
||||
import { LimitedUser, limitedUserSchema, limitedUserSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { secondlyRatelimit } from '@/lib/ratelimits';
|
||||
import { canInteract } from '@/lib/role';
|
||||
import { canInteract, interactableRoles } from '@/lib/role';
|
||||
import { zQsBoolean, zStringTrimmed } from '@/lib/validation';
|
||||
import { Role } from '@/prisma/client';
|
||||
import { administratorMiddleware } from '@/server/middleware/administrator';
|
||||
@@ -14,7 +14,7 @@ import typedPlugin from '@/server/typedPlugin';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ApiUsersResponse = User[] | User;
|
||||
export type ApiUsersResponse = LimitedUser[] | LimitedUser;
|
||||
|
||||
const logger = log('api').c('users');
|
||||
|
||||
@@ -33,19 +33,22 @@ export default typedPlugin(
|
||||
'List users in the instance, optionally excluding the current admin from the results (admin only).',
|
||||
querystring: querySchema,
|
||||
response: {
|
||||
200: z.array(userSchema),
|
||||
200: z.array(limitedUserSchema),
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
preHandler: [userMiddleware, administratorMiddleware],
|
||||
},
|
||||
async (req, res) => {
|
||||
const roles = interactableRoles(req.user.role);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
...userSelect,
|
||||
...limitedUserSelect,
|
||||
avatar: true,
|
||||
},
|
||||
where: {
|
||||
role: { in: roles },
|
||||
...(req.query.noincl && { id: { not: req.user.id } }),
|
||||
},
|
||||
});
|
||||
@@ -67,7 +70,7 @@ export default typedPlugin(
|
||||
role: z.enum(Role).default('USER').optional(),
|
||||
}),
|
||||
response: {
|
||||
200: userSchema,
|
||||
200: limitedUserSchema,
|
||||
},
|
||||
tags: ['auth', 'admin'],
|
||||
},
|
||||
@@ -106,11 +109,7 @@ export default typedPlugin(
|
||||
avatar: avatar64 ?? null,
|
||||
token: createToken(),
|
||||
},
|
||||
select: {
|
||||
...userSelect,
|
||||
totpSecret: false,
|
||||
passkeys: false,
|
||||
},
|
||||
select: limitedUserSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} created a new user`, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { config } from '@/lib/config';
|
||||
import { log } from '@/lib/logger';
|
||||
import { isAdministrator } from '@/lib/role';
|
||||
import { getVersion } from '@/lib/version';
|
||||
import { userMiddleware } from '@/server/middleware/user';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
@@ -64,8 +65,8 @@ export default typedPlugin(
|
||||
},
|
||||
preHandler: [userMiddleware],
|
||||
},
|
||||
async (_, res) => {
|
||||
if (!config.features.versionChecking) throw new ApiError(9002);
|
||||
async (req, res) => {
|
||||
if (!config.features.versionChecking && !isAdministrator(req.user.role)) throw new ApiError(9002);
|
||||
|
||||
const details = getVersion();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { findFileByName } from '@/lib/db/models/file';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { rawFileHandler } from './raw/[id]';
|
||||
@@ -16,19 +17,35 @@ export async function filesRoute(
|
||||
res: FastifyReply,
|
||||
) {
|
||||
const { id } = req.params;
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: decodeURIComponent(id),
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
const file = await findFileByName(id, (where, orderBy) =>
|
||||
prisma.file.findFirst({
|
||||
where,
|
||||
...(orderBy && { orderBy }),
|
||||
select: {
|
||||
name: true,
|
||||
type: true,
|
||||
password: true,
|
||||
User: {
|
||||
select: {
|
||||
view: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!file) return res.callNotFound();
|
||||
|
||||
if (file.User?.view.enabled) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.type.startsWith('text/')) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
if (file.password) return res.redirect(`/view/${encodeURIComponent(file.name)}`);
|
||||
const viewUrl = `/view/${encodeURIComponent(file.name)}`;
|
||||
|
||||
if (file.password) return res.redirect(viewUrl);
|
||||
|
||||
if (file.type.startsWith('text/')) {
|
||||
if (file.User?.view?.disableTextFiles) return rawFileHandler(req, res);
|
||||
|
||||
return res.redirect(viewUrl);
|
||||
}
|
||||
|
||||
if (file.User?.view?.enabled) return res.redirect(viewUrl);
|
||||
|
||||
return rawFileHandler(req, res);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiError } from '@/lib/api/errors';
|
||||
import { parseRange } from '@/lib/api/range';
|
||||
import { config } from '@/lib/config';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { findFileByName } from '@/lib/db/models/file';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { sanitizeFilename } from '@/lib/fs';
|
||||
import { log } from '@/lib/logger';
|
||||
@@ -65,11 +66,9 @@ export const rawFileHandler = async (
|
||||
.send(buf);
|
||||
}
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: {
|
||||
name: decodeURIComponent(id),
|
||||
},
|
||||
});
|
||||
const file = await findFileByName(idSanitized, (where, orderBy) =>
|
||||
prisma.file.findFirst({ where, ...(orderBy && { orderBy }) }),
|
||||
);
|
||||
if (!file) return res.callNotFound();
|
||||
|
||||
if (file?.deletesAt && file.deletesAt <= new Date()) {
|
||||
|
||||
@@ -19,6 +19,8 @@ export async function unixSocketPath() {
|
||||
logger.warn('removed existing unix socket before listen', { path });
|
||||
return path;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return path;
|
||||
|
||||
logger.warn('error while checking for existing unix socket', { path, error });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user