Compare commits

...

13 Commits

Author SHA1 Message Date
diced 7bd477d7aa feat(v4.6.3): version 2026-06-25 20:05:04 -07:00
diced 26a16137b2 fix: build errors 2026-06-25 20:04:51 -07:00
diced d07c1fa99b feat: add copy links to multiple selections 2026-06-25 19:43:03 -07:00
Noah ae80d228b5 feat: extensionless file urls (#1109)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-25 10:41:01 -07:00
Noah b5c39bed47 fix: folders page loading with large file counts (#1085)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-24 22:07:07 -07:00
diced ca9bd41244 chore: update flake 2026-06-24 22:02:23 -07:00
diced 93f0210605 feat: add disk status and admin dashboard 2026-06-22 17:54:51 -07:00
diced 4329dc7cdf fix: cascade files & folders(#1106, #1042) 2026-06-22 15:42:43 -07:00
diced ae6a6536f9 fix: separate option to disable text view (#1099) 2026-06-22 15:42:43 -07:00
dicedtomato 0fc7e7a06f Merge commit from fork 2026-06-19 22:52:08 -07:00
zorex 18bc86c261 feat: add right-click context menu on file cards (#1105)
Co-authored-by: dicedtomato <git@diced.sh>
2026-06-14 13:16:13 -07:00
diced 2e210da549 fix: #1102 2026-06-14 13:10:56 -07:00
diced 3639ec0dc2 fix: #1104 2026-06-14 12:59:30 -07:00
62 changed files with 1222 additions and 883 deletions
Generated
+65 -561
View File
@@ -19,69 +19,11 @@
]
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"lastModified": 1777487137,
"narHash": "sha256-TuvKVBX60mqyMT6OB5JqVEh1YIWtFMR/igLCaCdC9tw=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"cachix_2": {
"inputs": {
"devenv": [
"devenv",
"crate2nix"
],
"flake-compat": [
"devenv",
"crate2nix"
],
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"cachix_3": {
"inputs": {
"devenv": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"git-hooks": "git-hooks_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1767714506,
"narHash": "sha256-WaTs0t1CxhgxbIuvQ97OFhDTVUGd1HA+KzLZUZBhe0s=",
"owner": "cachix",
"repo": "cachix",
"rev": "894c649f0daaa38bbcfb21de64be47dfa7cd0ec9",
"rev": "a66a440c321d35f7193472c317f42a55ccd1cb93",
"type": "github"
},
"original": {
@@ -92,60 +34,19 @@
}
},
"crate2nix": {
"inputs": {
"cachix": "cachix_2",
"crate2nix_stable": "crate2nix_stable",
"devshell": "devshell_2",
"flake-compat": "flake-compat_2",
"flake-parts": "flake-parts_2",
"nix-test-runner": "nix-test-runner_2",
"nixpkgs": [
"devenv",
"nixpkgs"
],
"pre-commit-hooks": "pre-commit-hooks_2"
},
"flake": false,
"locked": {
"lastModified": 1773440526,
"narHash": "sha256-OcX1MYqUdoalY3/vU67PEx8m6RvqGxX0LwKonjzXn7I=",
"owner": "nix-community",
"lastModified": 1772186516,
"narHash": "sha256-8s28pzmQ6TOIUzznwFibtW1CMieMUl1rYJIxoQYor58=",
"owner": "rossng",
"repo": "crate2nix",
"rev": "e697d3049c909580128caa856ab8eb709556a97b",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "crate2nix",
"type": "github"
}
},
"crate2nix_stable": {
"inputs": {
"cachix": "cachix_3",
"crate2nix_stable": [
"devenv",
"crate2nix",
"crate2nix_stable"
],
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-test-runner": "nix-test-runner",
"nixpkgs": "nixpkgs_3",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1769627083,
"narHash": "sha256-SUuruvw1/moNzCZosHaa60QMTL+L9huWdsCBN6XZIic=",
"owner": "nix-community",
"repo": "crate2nix",
"rev": "7c33e664668faecf7655fa53861d7a80c9e464a2",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "0.15.0",
"owner": "rossng",
"repo": "crate2nix",
"rev": "ba5dd398e31ee422fbe021767eb83b0650303a6e",
"type": "github"
}
},
@@ -153,9 +54,10 @@
"inputs": {
"cachix": "cachix",
"crate2nix": "crate2nix",
"flake-compat": "flake-compat_3",
"flake-parts": "flake-parts_3",
"git-hooks": "git-hooks_3",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"ghostty": "ghostty",
"git-hooks": "git-hooks",
"nix": "nix",
"nixd": "nixd",
"nixpkgs": [
@@ -164,11 +66,11 @@
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1774134162,
"narHash": "sha256-pGjE0Agjnh8FmymDi3hiOy/pflcnbS8kpkfkL5/QKAc=",
"lastModified": 1782331842,
"narHash": "sha256-7CJ2EqNVPMq0ly39aaP6dGgdO627MqUtM/+Dm+QwNdU=",
"owner": "cachix",
"repo": "devenv",
"rev": "b24c9b58457396a9a6fe275b87555ba6e8f0a5fb",
"rev": "885e1c9d62cfa12232802de77b36aaded1ca609b",
"type": "github"
},
"original": {
@@ -189,80 +91,7 @@
"url": "file:///dev/null"
}
},
"devshell": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"devshell_2": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-compat_2": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-compat_3": {
"flake": false,
"locked": {
"lastModified": 1767039857,
@@ -282,17 +111,15 @@
"inputs": {
"nixpkgs-lib": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -302,58 +129,15 @@
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_3": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_4": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"lastModified": 1778716662,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github"
},
"original": {
@@ -362,86 +146,40 @@
"type": "github"
}
},
"ghostty": {
"flake": false,
"locked": {
"lastModified": 1779069789,
"narHash": "sha256-ojo+gso45/6CVSuqfSVnlWpQ4d0QeLgwok+v/g3yu0E=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "4b7bf0b20e3baf9c1ba10c63f2ad1fd853faea8f",
"type": "github"
},
"original": {
"owner": "ghostty-org",
"repo": "ghostty",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"cachix",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"crate2nix",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_2": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"flake-compat"
],
"gitignore": "gitignore_2",
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1765404074,
"narHash": "sha256-+ZDU2d+vzWkEJiqprvV5PR26DVFN2vgddwG5SnPZcUM=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "2d6f58930fbcd82f6f9fd59fb6d13e37684ca529",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"git-hooks_3": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore_5",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1772893680,
"narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
@@ -451,102 +189,6 @@
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"cachix",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_3": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_4": {
"inputs": {
"nixpkgs": [
"devenv",
"crate2nix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_5": {
"inputs": {
"nixpkgs": [
"devenv",
@@ -594,52 +236,20 @@
]
},
"locked": {
"lastModified": 1774103430,
"narHash": "sha256-MRNVInSmvhKIg3y0UdogQJXe+omvKijGszFtYpd5r9k=",
"lastModified": 1779748925,
"narHash": "sha256-meIhqGC04O5VXbKSFXSQoOKp+XCq5RMnwAk1Guo0VQo=",
"owner": "cachix",
"repo": "nix",
"rev": "e127c1c94cefe02d8ca4cca79ef66be4c527510e",
"rev": "0bc443c8ff235c3547d09327b48aaa2ab98b15f2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.32",
"ref": "devenv-2.34",
"repo": "nix",
"type": "github"
}
},
"nix-test-runner": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nix-test-runner_2": {
"flake": false,
"locked": {
"lastModified": 1588761593,
"narHash": "sha256-FKJykltAN/g3eIceJl4SfDnnyuH2jHImhMrXS2KvGIs=",
"owner": "stoeffel",
"repo": "nix-test-runner",
"rev": "c45d45b11ecef3eb9d834c3b6304c05c49b06ca2",
"type": "github"
},
"original": {
"owner": "stoeffel",
"repo": "nix-test-runner",
"type": "github"
}
},
"nixd": {
"inputs": {
"flake-parts": [
@@ -653,11 +263,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1773634079,
"narHash": "sha256-49qb4QNMv77VOeEux+sMd0uBhPvvHgVc0r938Bulvbo=",
"lastModified": 1778381404,
"narHash": "sha256-FqhdOTA8vyoIpkHhbs2cCT7h6EWM7nsLeOYJc1ifQLE=",
"owner": "nix-community",
"repo": "nixd",
"rev": "8ecf93d4d93745e05ea53534e8b94f5e9506e6bd",
"rev": "e3e45eb76663f522e196b7f0cf34cab201db7779",
"type": "github"
},
"original": {
@@ -667,69 +277,6 @@
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1772328832,
"narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1765186076,
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1769433173,
"narHash": "sha256-Gf1dFYgD344WZ3q0LPlRoWaNdNQq8kSBDLEWulRQSEs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13b0f9e6ac78abbbb736c635d87845c4f4bee51b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1773964973,
"narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=",
@@ -745,61 +292,18 @@
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"crate2nix_stable",
"flake-compat"
],
"gitignore": "gitignore_3",
"nixpkgs": [
"devenv",
"crate2nix",
"crate2nix_stable",
"nixpkgs"
]
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"lastModified": 1777168982,
"narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"pre-commit-hooks_2": {
"inputs": {
"flake-compat": [
"devenv",
"crate2nix",
"flake-compat"
],
"gitignore": "gitignore_4",
"nixpkgs": [
"devenv",
"crate2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769069492,
"narHash": "sha256-Efs3VUPelRduf3PpfPP2ovEB4CXT7vHf8W+xc49RL/U=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "a1ef738813b15cf8ec759bdff5761b027e3e1d23",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
@@ -807,8 +311,8 @@
"inputs": {
"devenv": "devenv",
"devenv-root": "devenv-root",
"flake-parts": "flake-parts_4",
"nixpkgs": "nixpkgs_4"
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs"
}
},
"rust-overlay": {
@@ -819,11 +323,11 @@
]
},
"locked": {
"lastModified": 1773630837,
"narHash": "sha256-zJhgAGnbVKeBMJOb9ctZm4BGS/Rnrz+5lfSXTVah4HQ=",
"lastModified": 1779074409,
"narHash": "sha256-6aXy8Ga41iLVM8ibddFU1O5+wYWcBGNEfZzZuL91eIc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "f600ea449c7b5bb596fa1cf21c871cc5b9e31316",
"rev": "2a77b5b1dc952f214e8102acdef1622b68515560",
"type": "github"
},
"original": {
@@ -841,11 +345,11 @@
]
},
"locked": {
"lastModified": 1772660329,
"narHash": "sha256-IjU1FxYqm+VDe5qIOxoW+pISBlGvVApRjiw/Y/ttJzY=",
"lastModified": 1775636079,
"narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "3710e0e1218041bbad640352a0440114b1e10428",
"rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "zipline",
"private": true,
"license": "MIT",
"version": "4.6.2",
"version": "4.6.3",
"scripts": {
"build": "tsx scripts/build.ts",
"dev": "cross-env NODE_ENV=development DEBUG=zipline tsx --require ./src/dotenv.js --enable-source-maps ./src/server",
@@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "File_folderId_createdAt_idx" ON "public"."File"("folderId", "createdAt");
-- CreateIndex
CREATE INDEX "File_name_idx" ON "public"."File"("name");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Zipline" ADD COLUMN "filesExtensionlessUrls" BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -49,6 +49,7 @@ model Zipline {
filesRandomWordsSeparator String @default("-")
filesDefaultCompressionFormat String? @default("jpg")
filesMaxFilesPerUpload Int @default(1000)
filesExtensionlessUrls Boolean @default(false)
urlsRoute String @default("/go")
urlsLength Int @default(6)
@@ -297,6 +298,9 @@ model File {
folderId String?
thumbnail Thumbnail?
@@index([name])
@@index([folderId, createdAt])
}
model Thumbnail {
@@ -0,0 +1,10 @@
import DashboardAdminHome from '@/components/pages/admin';
import { useTitle } from '@/lib/client/hooks/useTitle';
export function Component() {
useTitle('Administrator');
return <DashboardAdminHome />;
}
Component.displayName = 'Dashboard/Admin';
+1
View File
@@ -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') },
+15 -12
View File
@@ -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(
{
+11 -5
View File
@@ -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,
})),
},
{
+37
View File
@@ -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>
);
}
+91 -87
View File
@@ -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'>
+8 -9
View File
@@ -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>
</>
);
}
+329
View File
@@ -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 &quot;{folderSearch.trim()}&quot;
</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>
</>
);
}
+1 -1
View File
@@ -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);
+66
View File
@@ -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>
);
}
+23 -2
View File
@@ -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 && (
+1 -1
View File
@@ -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}`,
);
+4 -1
View File
@@ -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>
+6 -1
View File
@@ -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 />
+8 -30
View File
@@ -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'
+2 -2
View File
@@ -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;
}) {
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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,
});
+2 -3
View File
@@ -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]!;
+1
View File
@@ -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',
+2
View File
@@ -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;
+1 -1
View File
@@ -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 };
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function openWarningModal(options: WarningModalOptions) {
onCancel: () => modals.closeAll(),
onConfirm: options.onConfirm,
zIndex: 10320948239487,
size: 'auto',
size: 'md',
});
}
+1
View File
@@ -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',
+1
View File
@@ -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),
+1
View File
@@ -49,6 +49,7 @@ export const rawConfig: any = {
randomWordsSeparator: undefined,
defaultCompressionFormat: undefined,
maxFilesPerUpload: undefined,
extensionlessUrls: undefined,
},
urls: {
route: undefined,
+2
View File
@@ -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;
}
+1
View File
@@ -149,6 +149,7 @@ export const schema = z.object({
.default('jpg')
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
maxFilesPerUpload: z.number().max(2147483647).min(1).default(1000),
extensionlessUrls: z.boolean().default(false),
}),
urls: z.object({
route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/go'),
+14 -1
View File
@@ -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
View File
@@ -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>);
+16
View File
@@ -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;
+15 -6
View File
@@ -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);
}
+22
View File
@@ -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>;
+41
View File
@@ -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();
}
+6
View File
@@ -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':
+2
View File
@@ -70,6 +70,8 @@ export default typedPlugin(
preHandler: [userMiddleware, administratorMiddleware],
},
async (req, res) => {
if (req.user.role !== 'SUPERADMIN') throw new ApiError(3015);
if (req.query.counts) {
const counts = await getCounts();
@@ -204,6 +204,7 @@ export default typedPlugin(
.enum(COMPRESS_TYPES)
.refine((v) => checkOutput(v), 'System does not support outputting this image format.'),
filesMaxFilesPerUpload: z.number().min(1).max(2147483647),
filesExtensionlessUrls: z.boolean(),
urlsRoute: z
.string()
+41
View File
@@ -0,0 +1,41 @@
import { config } from '@/lib/config';
import { diskStatus, diskStatusSchema } from '@/lib/disk';
import { administratorMiddleware } from '@/server/middleware/administrator';
import { userMiddleware } from '@/server/middleware/user';
import typedPlugin from '@/server/typedPlugin';
import z from 'zod';
export const apiServerStatusResponseSchema = z.object({
datasource: z.enum(['local', 's3']).describe('Configured datasource type for this Zipline instance.'),
storage: diskStatusSchema,
});
export type ApiServerStatusResponse = z.infer<typeof apiServerStatusResponseSchema>;
export const PATH = '/api/server/status';
export default typedPlugin(
async (server) => {
server.get(
PATH,
{
schema: {
description: 'Get disk status for the configured datasource',
response: {
200: apiServerStatusResponseSchema,
},
tags: ['auth', 'admin'],
},
preHandler: [userMiddleware, administratorMiddleware],
},
async (_, res) => {
const status = await diskStatus();
return res.send({
datasource: config.datasource.type,
storage: status,
});
},
);
},
{ name: PATH },
);
@@ -1,11 +1,12 @@
import { ApiError } from '@/lib/api/errors';
import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { buildParentChain, Folder, cleanFolder, folderSchema } from '@/lib/db/models/folder';
import { User } from '@/lib/db/models/user';
import { log } from '@/lib/logger';
import { canInteract } from '@/lib/role';
import { zStringTrimmed } from '@/lib/validation';
import { zQsBoolean, zStringTrimmed } from '@/lib/validation';
import { userMiddleware } from '@/server/middleware/user';
import typedPlugin from '@/server/typedPlugin';
import { FastifyRequest } from 'fastify';
@@ -29,6 +30,11 @@ const paramsSchema = z.object({
id: z.string(),
});
const folderMutationInclude = {
_count: { select: { children: true, files: true } },
parent: { select: { id: true, name: true, parentId: true } },
} as const;
const folderExistsAndEditable = async (req: FastifyRequest) => {
const { id } = req.params as z.infer<typeof paramsSchema>;
@@ -52,8 +58,12 @@ export default typedPlugin(
PATH,
{
schema: {
description: 'Fetch a specific folder by ID, including files, children, and its parent chain.',
description:
'Fetch a specific folder by ID, optionally including files, children, and its parent chain.',
params: paramsSchema,
querystring: z.object({
noincl: zQsBoolean.optional(),
}),
response: {
200: folderSchema.partial(),
},
@@ -63,18 +73,21 @@ export default typedPlugin(
},
async (req, res) => {
const { id } = req.params;
const { noincl } = req.query;
const folder = await prisma.folder.findUnique({
where: {
id,
},
include: {
files: {
select: {
...fileSelect,
password: true,
...(!noincl && {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
User: true,
children: {
orderBy: { createdAt: 'desc' },
@@ -98,7 +111,7 @@ export default typedPlugin(
(folder as any).parent = await buildParentChain(folder.parentId);
}
return res.send(cleanFolder(folder));
return res.send(cleanFolder(folder as unknown as Partial<Folder>));
},
);
@@ -149,15 +162,7 @@ export default typedPlugin(
data: {
files: { connect: { id } },
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
User: true,
},
include: folderMutationInclude,
});
logger.info('file added to folder', { folder: folderId, file: id });
@@ -227,20 +232,7 @@ export default typedPlugin(
...(allowUploads !== undefined && { allowUploads }),
...(parentId !== undefined && { parentId }),
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
_count: {
select: { children: true, files: true },
},
parent: {
select: { id: true, name: true, parentId: true },
},
},
include: folderMutationInclude,
});
logger.info('folder updated', {
@@ -267,7 +259,7 @@ export default typedPlugin(
delete: z.enum(['file', 'folder']),
id: zStringTrimmed.optional(),
childrenAction: z.enum(['root', 'folder', 'cascade']).optional(),
childrenAction: z.enum(['root', 'folder', 'cascade', 'cascade-files']).optional(),
targetFolderId: z.string().optional(),
}),
params: paramsSchema,
@@ -299,6 +291,8 @@ export default typedPlugin(
}
try {
const toDeleteFiles: string[] = [];
const result = await prisma.$transaction(async (tx) => {
if (!childrenAction) {
return { success: true };
@@ -320,7 +314,9 @@ export default typedPlugin(
});
return { success: true };
} else if (childrenAction === 'cascade') {
} else if (childrenAction === 'cascade' || childrenAction === 'cascade-files') {
const deleteFiles = childrenAction === 'cascade-files';
const deleteRecursive = async (id: string) => {
const children = await tx.folder.findMany({
where: { parentId: id },
@@ -329,6 +325,16 @@ export default typedPlugin(
for (const child of children) {
await deleteRecursive(child.id);
}
if (deleteFiles) {
const files = await tx.file.findMany({
where: { folderId: id },
select: { name: true },
});
toDeleteFiles.push(...files.map((f) => f.name));
await tx.file.deleteMany({ where: { folderId: id } });
}
await tx.folder.delete({ where: { id } });
};
@@ -341,7 +347,11 @@ export default typedPlugin(
if (!result?.success) throw new ApiError(1019);
if (result?.isCascade) {
logger.info('folder cascade deleted', { folder: folderId });
for (const name of toDeleteFiles) {
await datasource.delete(name);
}
logger.info('folder cascade deleted', { folder: folderId, files: toDeleteFiles.length });
return res.send({ success: true });
} else {
await prisma.folder.delete({ where: { id: folderId } });
@@ -379,14 +389,7 @@ export default typedPlugin(
data: {
files: { disconnect: { id } },
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
include: folderMutationInclude,
});
logger.info('file removed from folder', { folder: nFolder.id, file: id });
+8 -9
View File
@@ -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 }),
},
+4
View File
@@ -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 && {
+19 -17
View File
@@ -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`, {
+10 -11
View File
@@ -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`, {
+3 -2
View File
@@ -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();
+28 -11
View File
@@ -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);
}
+4 -5
View File
@@ -3,6 +3,7 @@ import { ApiError } from '@/lib/api/errors';
import { parseRange } from '@/lib/api/range';
import { config } from '@/lib/config';
import { datasource } from '@/lib/datasource';
import { findFileByName } from '@/lib/db/models/file';
import { prisma } from '@/lib/db';
import { sanitizeFilename } from '@/lib/fs';
import { log } from '@/lib/logger';
@@ -65,11 +66,9 @@ export const rawFileHandler = async (
.send(buf);
}
const file = await prisma.file.findFirst({
where: {
name: decodeURIComponent(id),
},
});
const file = await findFileByName(idSanitized, (where, orderBy) =>
prisma.file.findFirst({ where, ...(orderBy && { orderBy }) }),
);
if (!file) return res.callNotFound();
if (file?.deletesAt && file.deletesAt <= new Date()) {
+2
View File
@@ -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);
}