mirror of
https://github.com/diced/zipline.git
synced 2026-07-07 04:16:59 -07:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e26037f45 | |||
| 7e61bffcd3 | |||
| 157a94b099 | |||
| 6093356c34 | |||
| e0806588f4 | |||
| 976f057584 | |||
| aa4897349f | |||
| b13e2cc9cb | |||
| 373f54fc24 | |||
| 6a9dcf02a7 | |||
| d4151c6801 | |||
| 1c8adf414f | |||
| 388c5827e9 | |||
| a120c0984c | |||
| fa30c6757b | |||
| 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": {
|
||||
|
||||
+45
-53
@@ -2,7 +2,7 @@
|
||||
"name": "zipline",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "4.6.2",
|
||||
"version": "4.6.4",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
|
||||
@@ -22,107 +22,99 @@
|
||||
"docker:compose:dev:logs": "docker compose --file docker-compose.dev.yml logs -f"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1046.0",
|
||||
"@aws-sdk/lib-storage": "3.1046.0",
|
||||
"@aws-sdk/client-s3": "3.1079.0",
|
||||
"@aws-sdk/lib-storage": "3.1079.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@fastify/sensible": "^6.0.4",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@mantine/charts": "^9.2.0",
|
||||
"@mantine/code-highlight": "^9.2.0",
|
||||
"@mantine/core": "^9.2.0",
|
||||
"@mantine/dates": "^9.2.0",
|
||||
"@mantine/dropzone": "^9.2.0",
|
||||
"@mantine/form": "^9.2.0",
|
||||
"@mantine/hooks": "^9.2.0",
|
||||
"@mantine/modals": "^9.2.0",
|
||||
"@mantine/notifications": "^9.2.0",
|
||||
"@mantine/charts": "^9.4.1",
|
||||
"@mantine/core": "^9.4.1",
|
||||
"@mantine/dates": "^9.4.1",
|
||||
"@mantine/dropzone": "^9.4.1",
|
||||
"@mantine/form": "^9.4.1",
|
||||
"@mantine/hooks": "^9.4.1",
|
||||
"@mantine/modals": "^9.4.1",
|
||||
"@mantine/notifications": "^9.4.1",
|
||||
"@prisma/adapter-pg": "6.13.0",
|
||||
"@prisma/client": "6.13.0",
|
||||
"@prisma/engines": "6.13.0",
|
||||
"@prisma/internals": "6.13.0",
|
||||
"@prisma/migrate": "6.13.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@smithy/node-http-handler": "^4.7.2",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@smithy/node-http-handler": "^4.9.3",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "^0.44.0",
|
||||
"asciinema-player": "^3.15.1",
|
||||
"asciinema-player": "^3.17.0",
|
||||
"bytes": "^3.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"colorette": "^2.0.20",
|
||||
"commander": "^14.0.3",
|
||||
"cookie": "^1.1.1",
|
||||
"commander": "^15.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs": "^1.11.21",
|
||||
"detect-browser": "^5.3.0",
|
||||
"devalue": "^5.8.0",
|
||||
"devalue": "^5.8.1",
|
||||
"fast-glob": "^3.3.3",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"fastify-type-provider-zod": "^6.1.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"fastify": "^5.9.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"fastify-type-provider-zod": "^7.0.0",
|
||||
"he": "^1.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"katex": "^0.16.46",
|
||||
"mantine-datatable": "^9.2.0",
|
||||
"isomorphic-dompurify": "^3.18.0",
|
||||
"katex": "^0.17.0",
|
||||
"mantine-datatable": "^9.3.1",
|
||||
"marked-react": "^4.0.0",
|
||||
"ms": "^2.1.3",
|
||||
"multer": "2.1.1",
|
||||
"nuqs": "^2.8.9",
|
||||
"otplib": "^13.4.0",
|
||||
"nuqs": "^2.9.0",
|
||||
"otplib": "^13.4.1",
|
||||
"prisma": "6.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"sharp": "^0.34.5",
|
||||
"swr": "^2.4.1",
|
||||
"vite": "^8.0.12",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-virtuoso": "^4.18.10",
|
||||
"sharp": "^0.35.3",
|
||||
"swr": "^2.4.2",
|
||||
"vite": "^8.1.3",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.16",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.98.0",
|
||||
"prettier": "^3.9.4",
|
||||
"sass": "^1.101.0",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3"
|
||||
"typescript-eslint": "^8.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
Generated
+5942
-3197
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
+11
-11
@@ -1,14 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
<title>Zipline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { DatePicker } from '@mantine/dates';
|
||||
import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { lazy, useState } from 'react';
|
||||
import { StatsCardsSkeleton } from './parts/StatsCards';
|
||||
import { StatsTablesSkeleton } from './parts/StatsTables';
|
||||
import { StatsCardsSkeleton, StatsTablesSkeleton } from './parts/Skeletons';
|
||||
import { useApiStats } from './useStats';
|
||||
|
||||
const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph'));
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
|
||||
|
||||
export function StatsCardsSkeleton() {
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
}}
|
||||
mb='sm'
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} height={100} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonText() {
|
||||
return (
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsTablesSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
<Table.Th>Storage Used</Table.Th>
|
||||
<Table.Th>Views</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder mah={500} radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>URLs</Table.Th>
|
||||
<Table.Th>Views</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p='sm'>
|
||||
<Skeleton height={500} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { MetricsPoint } from '@/lib/metrics';
|
||||
import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core';
|
||||
import { Group, Paper, rgba, SimpleGrid, Text } from '@mantine/core';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
@@ -69,23 +69,6 @@ function StatCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsCardsSkeleton() {
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={{
|
||||
base: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
}}
|
||||
mb='sm'
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} height={100} animate />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsCards({ points }: { points: MetricsPoint[] }) {
|
||||
if (!points.length) return null;
|
||||
|
||||
|
||||
@@ -1,99 +1,8 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { Metric } from '@/lib/db/models/metric';
|
||||
import { Paper, ScrollArea, SimpleGrid, Skeleton, Table, Text } from '@mantine/core';
|
||||
import { Paper, ScrollArea, SimpleGrid, Table } from '@mantine/core';
|
||||
import TypesPieChart from './TypesPieChart';
|
||||
|
||||
function SkeletonText() {
|
||||
return (
|
||||
<Table.Td>
|
||||
<Skeleton animate>
|
||||
<Text>...</Text>
|
||||
</Skeleton>
|
||||
</Table.Td>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsTablesSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Paper radius='md' withBorder>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
<Table.Th>Storage Used</Table.Th>
|
||||
<Table.Th>Views</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder mah={500} radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>URLs</Table.Th>
|
||||
<Table.Th>Views</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder radius='md'>
|
||||
<ScrollArea.Autosize mah={500} type='auto'>
|
||||
<Table highlightOnHover stickyHeader>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Files</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<SkeletonText />
|
||||
<SkeletonText />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p='sm'>
|
||||
<Skeleton height={500} />
|
||||
</Paper>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StatsTables({ latest }: { latest: Metric | null }) {
|
||||
if (!latest) return null;
|
||||
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FastifyReply } from 'fastify';
|
||||
|
||||
export function setContentSecurity(res: FastifyReply) {
|
||||
res.header('X-Content-Type-Options', 'nosniff');
|
||||
res.header('Content-Security-Policy', 'sandbox');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -2,15 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Field =
|
||||
| 'name'
|
||||
| 'originalName'
|
||||
| 'tags'
|
||||
| 'type'
|
||||
| 'size'
|
||||
| 'createdAt'
|
||||
| 'favorite'
|
||||
| 'views'
|
||||
| 'anonymous';
|
||||
'name' | 'originalName' | 'tags' | 'type' | 'size' | 'createdAt' | 'favorite' | 'views' | 'anonymous';
|
||||
|
||||
const FIELDS: {
|
||||
property: Field;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+27
-17
@@ -14,6 +14,15 @@ declare global {
|
||||
ZIPLINE_BUILD?: string;
|
||||
ZIPLINE_DB_LOG?: string;
|
||||
ZIPLINE_OVERRIDE_DISABLED_WORKER_LOG?: string;
|
||||
ZIPLINE_OVERRIDE_LOG_DATE_FORMAT?: string;
|
||||
ZIPLINE_MONITOR_MEMORY?: string;
|
||||
ZIPLINE_NO_COLOR?: string;
|
||||
ZIPLINE_OUTPUT_OPENAPI?: string;
|
||||
ZIPLINE_GIT_SHA?: string;
|
||||
|
||||
FFMPEG_PATH?: string;
|
||||
|
||||
DEBUG?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +158,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'),
|
||||
@@ -304,11 +314,11 @@ export const schema = z.object({
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
clientId: z.undefined(),
|
||||
clientSecret: z.undefined(),
|
||||
redirectUri: z.undefined(),
|
||||
allowedIds: z.undefined().or(z.array(z.string()).default([])),
|
||||
deniedIds: z.undefined().or(z.array(z.string()).default([])),
|
||||
clientId: z.undefined().optional(),
|
||||
clientSecret: z.undefined().optional(),
|
||||
redirectUri: z.undefined().optional(),
|
||||
allowedIds: z.undefined().optional().or(z.array(z.string()).default([])),
|
||||
deniedIds: z.undefined().optional().or(z.array(z.string()).default([])),
|
||||
}),
|
||||
),
|
||||
github: z
|
||||
@@ -319,9 +329,9 @@ export const schema = z.object({
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
clientId: z.undefined(),
|
||||
clientSecret: z.undefined(),
|
||||
redirectUri: z.undefined(),
|
||||
clientId: z.undefined().optional(),
|
||||
clientSecret: z.undefined().optional(),
|
||||
redirectUri: z.undefined().optional(),
|
||||
}),
|
||||
),
|
||||
google: z
|
||||
@@ -332,9 +342,9 @@ export const schema = z.object({
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
clientId: z.undefined(),
|
||||
clientSecret: z.undefined(),
|
||||
redirectUri: z.undefined(),
|
||||
clientId: z.undefined().optional(),
|
||||
clientSecret: z.undefined().optional(),
|
||||
redirectUri: z.undefined().optional(),
|
||||
}),
|
||||
),
|
||||
oidc: z
|
||||
@@ -348,12 +358,12 @@ export const schema = z.object({
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
clientId: z.undefined(),
|
||||
clientSecret: z.undefined(),
|
||||
authorizeUrl: z.undefined(),
|
||||
userinfoUrl: z.undefined(),
|
||||
tokenUrl: z.undefined(),
|
||||
redirectUri: z.undefined(),
|
||||
clientId: z.undefined().optional(),
|
||||
clientSecret: z.undefined().optional(),
|
||||
authorizeUrl: z.undefined().optional(),
|
||||
userinfoUrl: z.undefined().optional(),
|
||||
tokenUrl: z.undefined().optional(),
|
||||
redirectUri: z.undefined().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import { accessSync, constants, statSync } from 'fs';
|
||||
import { delimiter, join } from 'path';
|
||||
|
||||
let cachedFfmpeg: string | null;
|
||||
|
||||
function isExec(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findFfmpeg(): string | null {
|
||||
if (cachedFfmpeg !== undefined) return cachedFfmpeg;
|
||||
|
||||
if (process.env.FFMPEG_PATH && isExec(process.env.FFMPEG_PATH)) {
|
||||
cachedFfmpeg = process.env.FFMPEG_PATH;
|
||||
return cachedFfmpeg;
|
||||
}
|
||||
|
||||
const extensions =
|
||||
process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
|
||||
|
||||
for (const dir of (process.env.PATH || '').split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(dir, `ffmpeg${ext.toLowerCase()}`);
|
||||
if (isExec(candidate)) {
|
||||
cachedFfmpeg = candidate;
|
||||
return cachedFfmpeg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedFfmpeg = null;
|
||||
return cachedFfmpeg;
|
||||
}
|
||||
|
||||
export class FfmpegCommand extends EventEmitter {
|
||||
private input: string;
|
||||
private videoFilterList: string[] = [];
|
||||
private frameCount?: number;
|
||||
private outputPath?: string;
|
||||
|
||||
constructor(input: string) {
|
||||
super();
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
public on(event: 'start', listener: (command: string) => void): this;
|
||||
public on(event: 'error', listener: (err: Error, stdout: string, stderr: string) => void): this;
|
||||
public on(event: 'end', listener: (stdout: string, stderr: string) => void): this;
|
||||
public on(event: string, listener: (...args: any[]) => void): this {
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
public videoFilters(...filters: string[]): this {
|
||||
this.videoFilterList.push(...filters);
|
||||
return this;
|
||||
}
|
||||
|
||||
public frames(count: number): this {
|
||||
this.frameCount = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
public output(path: string): this {
|
||||
this.outputPath = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
private buildArgs(): string[] {
|
||||
const args = ['-hide_banner', '-i', this.input];
|
||||
|
||||
if (this.videoFilterList.length) args.push('-filter:v', this.videoFilterList.join(','));
|
||||
if (this.frameCount !== undefined) args.push('-frames:v', String(this.frameCount));
|
||||
|
||||
args.push('-y', this.outputPath!);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
public run(): void {
|
||||
if (!this.outputPath) {
|
||||
this.emit('error', new Error('no output file specified'), '', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegPath = findFfmpeg();
|
||||
if (!ffmpegPath) {
|
||||
this.emit(
|
||||
'error',
|
||||
new Error('cannot find ffmpeg executable, set FFMPEG_PATH or add ffmpeg to your PATH'),
|
||||
'',
|
||||
'',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = this.buildArgs();
|
||||
|
||||
this.emit('start', [ffmpegPath, ...args].join(' '));
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { windowsHide: true });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (chunk) => (stdout += chunk));
|
||||
proc.stderr.on('data', (chunk) => (stderr += chunk));
|
||||
|
||||
proc.on('error', (err) => {
|
||||
this.emit('error', err, stdout, stderr);
|
||||
});
|
||||
|
||||
proc.on('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
this.emit('end', stdout, stderr);
|
||||
} else if (signal) {
|
||||
this.emit('error', new Error(`ffmpeg was killed with signal ${signal}`), stdout, stderr);
|
||||
} else {
|
||||
const lastLine = stderr.trim().split('\n').pop() ?? '';
|
||||
this.emit('error', new Error(`ffmpeg exited with code ${code}: ${lastLine}`), stdout, stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function ffmpeg(input: string): FfmpegCommand {
|
||||
return new FfmpegCommand(input);
|
||||
}
|
||||
+40
-11
@@ -1,18 +1,36 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { green, red, yellow, gray, white, bold, blue } from 'colorette';
|
||||
import { isatty } from 'tty';
|
||||
import { styleText } from 'util';
|
||||
import { isMainThread } from 'worker_threads';
|
||||
|
||||
const canStyle = !process.env.ZIPLINE_NO_COLOR && isatty(1);
|
||||
|
||||
const style = (format: Parameters<typeof styleText>[0], text: string) =>
|
||||
canStyle ? styleText(format, text, { validateStream: false }) : text;
|
||||
|
||||
const colors: Record<string, (text: string) => string> = {
|
||||
green: (text: string) => style('green', text),
|
||||
red: (text: string) => style('red', text),
|
||||
yellow: (text: string) => style('yellow', text),
|
||||
gray: (text: string) => style('gray', text),
|
||||
white: (text: string) => style('white', text),
|
||||
bold: (text: string) => style('bold', text),
|
||||
blue: (text: string) => style('blue', text),
|
||||
};
|
||||
|
||||
export type LoggerLevel = 'info' | 'warn' | 'error' | 'debug' | 'trace';
|
||||
|
||||
export function log(name: string) {
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
const SEPARATOR = '.';
|
||||
|
||||
export default class Logger {
|
||||
public constructor(public name: string) {}
|
||||
|
||||
public c(name: string) {
|
||||
return new Logger(`${this.name}::${name}`);
|
||||
return new Logger(`${this.name}${SEPARATOR}${name}`);
|
||||
}
|
||||
|
||||
private isZiplineDebug(): boolean {
|
||||
@@ -28,25 +46,34 @@ export default class Logger {
|
||||
}
|
||||
|
||||
private format(message: string, level: LoggerLevel) {
|
||||
const timestamp = dayjs().format('YYYY-MM-DDTHH:mm:ss');
|
||||
const timestamp = dayjs().format(process.env.ZIPLINE_OVERRIDE_LOG_DATE_FORMAT ?? 'YYYY-MM-DDTHH:mm:ss');
|
||||
|
||||
return `${gray('[')}${timestamp} ${this.formatLevel(level)} ${this.name}${gray(']')} ${message}`;
|
||||
return `${colors.gray('[')}${timestamp} ${this.formatLevel(level)} ${this.formatName()}${colors.gray(']')} ${message}`;
|
||||
}
|
||||
|
||||
private formatName() {
|
||||
if (!canStyle) return this.name;
|
||||
|
||||
return this.name
|
||||
.split(SEPARATOR)
|
||||
.map((part) => part)
|
||||
.join(colors.gray(SEPARATOR));
|
||||
}
|
||||
|
||||
private formatLevel(level: LoggerLevel) {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return green('INFO ');
|
||||
return colors.green('INFO ');
|
||||
case 'warn':
|
||||
return yellow('WARN ');
|
||||
return colors.yellow('WARN ');
|
||||
case 'error':
|
||||
return red('ERROR');
|
||||
return colors.red('ERROR');
|
||||
case 'debug':
|
||||
return yellow(bold('DEBUG'));
|
||||
return colors.yellow(colors.bold('DEBUG'));
|
||||
case 'trace':
|
||||
return gray(bold('TRACE'));
|
||||
return colors.gray(colors.bold('TRACE'));
|
||||
default:
|
||||
return white(bold('?????'));
|
||||
return colors.white(colors.bold('?????'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +81,9 @@ export default class Logger {
|
||||
return (
|
||||
' ' +
|
||||
Object.entries(extra)
|
||||
.map(([key, value]) => `${blue(key)}${gray('=')}${JSON.stringify(value, this.replacer)}`)
|
||||
.map(
|
||||
([key, value]) => `${colors.blue(key)}${colors.gray('=')}${JSON.stringify(value, this.replacer)}`,
|
||||
)
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { decrypt, encrypt } from '@/lib/crypto';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
|
||||
export type OAuthStateJSON = {
|
||||
mode: 'default' | 'link';
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
export function encryptOAuthState(value: OAuthStateJSON): string {
|
||||
return encrypt(JSON.stringify(value), config.core.secret);
|
||||
}
|
||||
|
||||
export async function generateOAuthState(
|
||||
session: { oauthState?: string; save: () => Promise<void> },
|
||||
mode: OAuthStateJSON['mode'],
|
||||
): Promise<string> {
|
||||
const nonce = randomCharacters(32);
|
||||
session.oauthState = nonce;
|
||||
await session.save();
|
||||
|
||||
return encryptOAuthState({ mode, nonce });
|
||||
}
|
||||
|
||||
export function decryptOAuthState(state?: string): string | null {
|
||||
if (!state) return null;
|
||||
|
||||
@@ -33,6 +46,7 @@ export function parseOAuthState(state?: string): OAuthStateJSON | null {
|
||||
|
||||
return {
|
||||
mode: parsed.mode,
|
||||
nonce: parsed.nonce,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const secondlyRatelimit = (seconds: number) => ({
|
||||
config: { rateLimit: { max: 1, timeWindow: `${seconds} seconds`, allowList: [] } },
|
||||
export const secondlyRatelimit = (seconds: number, max: number = 1) => ({
|
||||
config: { rateLimit: { max, timeWindow: `${seconds} seconds`, allowList: [] } },
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -277,7 +277,7 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
|
||||
.split('/')
|
||||
.map((x) => Number(x));
|
||||
|
||||
if (isNaN(start) || isNaN(end) || isNaN(total))
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || !Number.isInteger(total))
|
||||
throwHeaderError('content-range', 'Invalid content-range');
|
||||
|
||||
response.partial = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { UploadOptions } from '@/lib/uploader/parseHeaders';
|
||||
import { onUpload } from '@/lib/webhooks';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { open, readdir, rm } from 'fs/promises';
|
||||
import { open, readdir, rm, stat } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { isMainThread, workerData } from 'worker_threads';
|
||||
import { dbProxy } from './proxiedDb';
|
||||
@@ -141,6 +141,8 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const finalSize = await stat(finalPath).then((s) => s.size);
|
||||
|
||||
if (config.datasource.type === 's3') {
|
||||
logger.debug('starting multipart upload process for s3');
|
||||
|
||||
@@ -182,10 +184,10 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await runComplete(file.id);
|
||||
await runComplete(file.id, finalSize);
|
||||
}
|
||||
|
||||
async function runComplete(id: string) {
|
||||
async function runComplete(id: string, size: number) {
|
||||
const userr = await dbProxy<User>('user.findUnique', {
|
||||
where: {
|
||||
id: user.id,
|
||||
@@ -199,7 +201,7 @@ async function runComplete(id: string) {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
size: options.partial!.range[2],
|
||||
size,
|
||||
...(options.maxViews && { maxViews: options.maxViews }),
|
||||
...(options.deletesAt && options.deletesAt !== 'never'
|
||||
? { deletesAt: options.deletesAt }
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Datasource } from '@/lib/datasource/Datasource';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import ffmpeg from '@/lib/ffmpeg';
|
||||
import { createWriteStream, existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { isMainThread, parentPort, workerData } from 'worker_threads';
|
||||
|
||||
@@ -5,7 +5,6 @@ import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { FastifyRequest } from 'fastify/types/request';
|
||||
import { getSession } from '../session';
|
||||
import * as cookie from 'cookie';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -42,14 +41,12 @@ export function parseUserToken(
|
||||
}
|
||||
|
||||
export async function userMiddleware(req: FastifyRequest, res: FastifyReply) {
|
||||
const cookies = cookie.parse(req.headers.cookie ?? '');
|
||||
|
||||
// conditions met to allow anonymous folder uploads but later handled in the upload route
|
||||
const anonFolderUpload =
|
||||
req.headers['x-zipline-folder'] &&
|
||||
['/api/upload', '/api/upload/partial'].includes(req.url.toLowerCase().split('?')[0]) &&
|
||||
!req.headers.authorization &&
|
||||
!cookies['zipline_session'];
|
||||
!req.cookies['zipline_session'];
|
||||
if (anonFolderUpload) return;
|
||||
|
||||
const authorization = req.headers.authorization;
|
||||
|
||||
@@ -73,6 +73,18 @@ async function oauthPlugin(fastify: FastifyInstance) {
|
||||
const state = parseOAuthState(query.state);
|
||||
if (!state) throw new ApiError(1064);
|
||||
|
||||
if (!state.nonce || !session.oauthState || state.nonce !== session.oauthState) {
|
||||
logger.warn('oauth state nonce mismatch!!', {
|
||||
provider,
|
||||
ua: this.headers['user-agent'],
|
||||
});
|
||||
|
||||
throw new ApiError(1064);
|
||||
}
|
||||
|
||||
delete session.oauthState;
|
||||
await session.save();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
sessions: {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default typedPlugin(
|
||||
}),
|
||||
},
|
||||
},
|
||||
...secondlyRatelimit(2),
|
||||
...secondlyRatelimit(10, 7),
|
||||
},
|
||||
async (req, res) => {
|
||||
const session = await getSession(req, res);
|
||||
|
||||
@@ -3,12 +3,15 @@ import { fetchToDataURL } from '@/lib/base64';
|
||||
import { config } from '@/lib/config';
|
||||
import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { encryptOAuthState } from '@/lib/oauth/state';
|
||||
import { generateOAuthState } from '@/lib/oauth/state';
|
||||
import { discordAuthorizeURL, discordUser } from '@/lib/oauth/providers';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
async function discordOauth(
|
||||
{ code, host, state, session }: OAuthQuery,
|
||||
logger: Logger,
|
||||
): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration) throw new ApiError(3016);
|
||||
|
||||
const { discord: discordEnabled } = enabled(config);
|
||||
@@ -20,7 +23,7 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
|
||||
discordAuthorizeURL({
|
||||
clientId: config.oauth.discord.clientId!,
|
||||
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
|
||||
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
|
||||
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
|
||||
redirectUri: config.oauth.discord.redirectUri!,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,11 +4,14 @@ import { config } from '@/lib/config';
|
||||
import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { githubAuthorizeURL, githubUser } from '@/lib/oauth/providers';
|
||||
import { encryptOAuthState } from '@/lib/oauth/state';
|
||||
import { generateOAuthState } from '@/lib/oauth/state';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
async function githubOauth(
|
||||
{ code, host, state, session }: OAuthQuery,
|
||||
logger: Logger,
|
||||
): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration) throw new ApiError(3016);
|
||||
|
||||
const { github: githubEnabled } = enabled(config);
|
||||
@@ -19,7 +22,7 @@ async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): P
|
||||
throw new RedirectError(
|
||||
githubAuthorizeURL({
|
||||
clientId: config.oauth.github.clientId!,
|
||||
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
|
||||
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
|
||||
redirectUri: config.oauth.github.redirectUri!,
|
||||
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
|
||||
}),
|
||||
|
||||
@@ -3,12 +3,15 @@ import { fetchToDataURL } from '@/lib/base64';
|
||||
import { config } from '@/lib/config';
|
||||
import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { encryptOAuthState } from '@/lib/oauth/state';
|
||||
import { generateOAuthState } from '@/lib/oauth/state';
|
||||
import { googleAuthorizeURL, googleUser } from '@/lib/oauth/providers';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
|
||||
async function googleOauth(
|
||||
{ code, host, state, session }: OAuthQuery,
|
||||
logger: Logger,
|
||||
): Promise<OAuthResponse> {
|
||||
if (!config.features.oauthRegistration) throw new ApiError(3016);
|
||||
|
||||
const { google: googleEnabled } = enabled(config);
|
||||
@@ -20,7 +23,7 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P
|
||||
googleAuthorizeURL({
|
||||
clientId: config.oauth.google.clientId!,
|
||||
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
|
||||
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
|
||||
state: await generateOAuthState(session, state === 'link' ? 'link' : 'default'),
|
||||
redirectUri: config.oauth.google.redirectUri!,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Logger from '@/lib/logger';
|
||||
import enabled from '@/lib/oauth/enabled';
|
||||
import { generatePKCEChallenge, generatePKCEVerifier } from '@/lib/oauth/pkce';
|
||||
import { oidcAuthorizeURL, oidcUser } from '@/lib/oauth/providers';
|
||||
import { encryptOAuthState } from '@/lib/oauth/state';
|
||||
import { generateOAuthState } from '@/lib/oauth/state';
|
||||
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
|
||||
@@ -21,13 +21,13 @@ async function oidcOauth({ code, host, state, session }: OAuthQuery, logger: Log
|
||||
const codeChallenge = generatePKCEChallenge(pkceVerifier);
|
||||
|
||||
session.pkceVerifier = pkceVerifier;
|
||||
await session.save();
|
||||
const oauthState = await generateOAuthState(session, state === 'link' ? 'link' : 'default');
|
||||
|
||||
throw new RedirectError(
|
||||
oidcAuthorizeURL({
|
||||
clientId: config.oauth.oidc.clientId!,
|
||||
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
|
||||
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
|
||||
state: oauthState,
|
||||
redirectUri: config.oauth.oidc.redirectUri!,
|
||||
authorizeUrl: config.oauth.oidc.authorizeUrl!,
|
||||
codeChallenge,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export default typedPlugin(
|
||||
if (!folder.public && !folder.allowUploads) throw new ApiError(9002);
|
||||
|
||||
const { page, perpage, sortBy, order } = req.query;
|
||||
if (!page && folder.allowUploads) {
|
||||
if ((!page && folder.allowUploads) || !folder.public) {
|
||||
return res.send({
|
||||
folder: {
|
||||
id: folder.id,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -96,7 +96,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
|
||||
|
||||
const ownsFolder = req.user ? folder.userId === req.user.id : false;
|
||||
if (!ownsFolder && !folder.allowUploads) throw new ApiError(req.user ? 3011 : 3002);
|
||||
}
|
||||
|
||||
let files: SavedMultipartFile[] = [];
|
||||
|
||||
@@ -22,12 +22,12 @@ const logger = log('api').c('upload').c('partial');
|
||||
|
||||
const partialsCache = new Map<string, { length: number; options: UploadOptions; prefix: string }>();
|
||||
|
||||
function createPartial(length: number, options: UploadOptions) {
|
||||
function createPartial(options: UploadOptions) {
|
||||
const identifier = randomCharacters(8);
|
||||
|
||||
const prefix = `zipline_partial_${identifier}_`;
|
||||
|
||||
partialsCache.set(identifier, { length, options, prefix });
|
||||
partialsCache.set(identifier, { length: 0, options, prefix });
|
||||
return identifier;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ export default typedPlugin(
|
||||
},
|
||||
});
|
||||
if (!folder) throw new ApiError(4001);
|
||||
if (!req.user && !folder.allowUploads) throw new ApiError(3002);
|
||||
|
||||
const ownsFolder = req.user ? folder.userId === req.user.id : false;
|
||||
if (!ownsFolder && !folder.allowUploads) throw new ApiError(req.user ? 3011 : 3002);
|
||||
}
|
||||
|
||||
const { files } = await req.saveRequestFiles({ tmpdir: config.core.tempDirectory });
|
||||
@@ -114,9 +116,21 @@ export default typedPlugin(
|
||||
const file = files[0];
|
||||
const fileSize = file.file.bytesRead;
|
||||
|
||||
const [start, end, total] = options.partial.range;
|
||||
|
||||
if (start < 0 || end < start || total < 0 || end > total || end - start !== fileSize) {
|
||||
if (options.partial.identifier) await deletePartial(options.partial.identifier);
|
||||
throw new ApiError(1002);
|
||||
}
|
||||
|
||||
if (total > bytes(config.files.maxFileSize)) {
|
||||
if (options.partial.identifier) await deletePartial(options.partial.identifier);
|
||||
throw new ApiError(5001);
|
||||
}
|
||||
|
||||
// caching for partial uploads server side checks and performance
|
||||
if (options.partial.range[0] === 0) {
|
||||
options.partial.identifier = createPartial(fileSize, options);
|
||||
if (start === 0) {
|
||||
options.partial.identifier = createPartial(options);
|
||||
} else {
|
||||
if (!options.partial.identifier || !partialsCache.has(options.partial.identifier))
|
||||
throw new ApiError(1003);
|
||||
@@ -140,6 +154,11 @@ export default typedPlugin(
|
||||
|
||||
cache.length += fileSize;
|
||||
|
||||
if (options.partial.lastchunk && cache.length !== total) {
|
||||
await deletePartial(options.partial.identifier);
|
||||
throw new ApiError(1002);
|
||||
}
|
||||
|
||||
// handle partial stuff
|
||||
const sanitized = sanitizeFilename(
|
||||
`${cache.prefix}${options.partial.range[0]}_${options.partial.range[1]}`,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { verifyAccessToken } from '@/lib/accessToken';
|
||||
import { setContentSecurity } from '@/lib/api/contentSecurity';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { parseRange } from '@/lib/api/range';
|
||||
import { config } from '@/lib/config';
|
||||
@@ -37,6 +38,8 @@ export default typedPlugin(
|
||||
async (req, res) => {
|
||||
const { token, download } = req.query;
|
||||
|
||||
setContentSecurity(res);
|
||||
|
||||
const id = sanitizeFilename(req.params.id);
|
||||
if (!id) throw new ApiError(9002);
|
||||
|
||||
|
||||
@@ -104,8 +104,14 @@ export default typedPlugin(
|
||||
|
||||
res.hijack();
|
||||
|
||||
const dl = `${folder.name}.zip`;
|
||||
const sanitized = dl.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||
|
||||
res.raw.setHeader('Content-Type', 'application/zip');
|
||||
res.raw.setHeader('Content-Disposition', `attachment; filename="${folder.name}.zip"`);
|
||||
res.raw.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${sanitized}"; filename*=UTF-8''${encodeURIComponent(dl)}`,
|
||||
);
|
||||
|
||||
const zip = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
|
||||
@@ -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,10 +3,12 @@ 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';
|
||||
import { guess } from '@/lib/mimes';
|
||||
import { setContentSecurity } from '@/lib/api/contentSecurity';
|
||||
import { TimedCache } from '@/lib/timedCache';
|
||||
import typedPlugin from '@/server/typedPlugin';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
@@ -35,6 +37,8 @@ export const rawFileHandler = async (
|
||||
const { id } = req.params;
|
||||
const { token, download } = req.query;
|
||||
|
||||
setContentSecurity(res);
|
||||
|
||||
const idSanitized = sanitizeFilename(id);
|
||||
if (!idSanitized) return res.callNotFound();
|
||||
|
||||
@@ -65,11 +69,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()) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { detectClient, ZiplineClient } from '@/lib/api/detect';
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { randomCharacters } from '@/lib/random';
|
||||
import { parse } from 'cookie';
|
||||
import { fastifyCookie } from '@fastify/cookie';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getIronSession, type SessionOptions } from 'iron-session';
|
||||
@@ -24,6 +24,7 @@ export type ZiplineSession = {
|
||||
client: ZiplineClient;
|
||||
|
||||
pkceVerifier?: string;
|
||||
oauthState?: string;
|
||||
tokenAuth?: boolean;
|
||||
};
|
||||
|
||||
@@ -50,7 +51,7 @@ export async function getSession(
|
||||
|
||||
const headers = (req as FastifyRequest).headers || (req as IncomingMessage).headers;
|
||||
session.client = detectClient(<Record<string, string>>headers);
|
||||
const cookies = parse(headers.cookie || '');
|
||||
const cookies = fastifyCookie.parse(headers.cookie || '');
|
||||
|
||||
if (headers['authorization'] && !cookies['zipline_session']) {
|
||||
const token = parseUserToken(headers['authorization'], true);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -68,12 +68,19 @@ export async function registerPlugins(server: FastifyInstance) {
|
||||
max: config.ratelimit.max,
|
||||
timeWindow: config.ratelimit.window ?? undefined,
|
||||
keyGenerator: (req) => {
|
||||
return `${req.user?.id ?? req.ip}-${req.url}-${req.method}`;
|
||||
const route = req.routeOptions?.url ?? req.url.split('?')[0];
|
||||
return `${req.user?.id ?? req.ip}-${route}-${req.method}`;
|
||||
},
|
||||
allowList: async (req, key) => {
|
||||
if (config.ratelimit.adminBypass && isAdministrator(req.user?.role)) return true;
|
||||
if (config.ratelimit.allowList.includes(key)) return true;
|
||||
if (Object.keys(req.headers).includes('x-zipline-p-filename')) return true;
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
req.routeOptions?.url === '/api/upload/partial' &&
|
||||
Object.keys(req.headers).includes('x-zipline-p-filename')
|
||||
)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user