zero-knowledge file sharing
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

init

Juliet 0b34d03d

+1321
+6
.env.example
··· 1 + PORT=3000 2 + DATA_DIR=./data 3 + MAX_FILE_SIZE=524288000 4 + MAX_TTL=30d 5 + RATE_LIMIT_MAX=20 6 + RATE_LIMIT_WINDOW_S=3600
+4
.gitignore
··· 1 + node_modules 2 + data 3 + .env 4 + .DS_Store
+12
LICENSE
··· 1 + Copyright (c) 2026 Juliet Philippe <m@juli.ee> 2 + 3 + Permission to use, copy, modify, and/or distribute this software for any 4 + purpose with or without fee is hereby granted. 5 + 6 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 7 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 8 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 9 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 10 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 11 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 12 + PERFORMANCE OF THIS SOFTWARE.
+164
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "drop", 7 + "dependencies": { 8 + "hono": "^4.12.3", 9 + }, 10 + "devDependencies": { 11 + "@tailwindcss/cli": "^4.2.1", 12 + "@types/bun": "latest", 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5.9.3", 16 + }, 17 + }, 18 + }, 19 + "packages": { 20 + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 21 + 22 + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], 23 + 24 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 25 + 26 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 27 + 28 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], 29 + 30 + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], 31 + 32 + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], 33 + 34 + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], 35 + 36 + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], 37 + 38 + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], 39 + 40 + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], 41 + 42 + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], 43 + 44 + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], 45 + 46 + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], 47 + 48 + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], 49 + 50 + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], 51 + 52 + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], 53 + 54 + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], 55 + 56 + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], 57 + 58 + "@tailwindcss/cli": ["@tailwindcss/cli@4.2.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.1" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA=="], 59 + 60 + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], 61 + 62 + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], 63 + 64 + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], 65 + 66 + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], 67 + 68 + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], 69 + 70 + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], 71 + 72 + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], 73 + 74 + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], 75 + 76 + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], 77 + 78 + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], 79 + 80 + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], 81 + 82 + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], 83 + 84 + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], 85 + 86 + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], 87 + 88 + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], 89 + 90 + "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], 91 + 92 + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], 93 + 94 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 95 + 96 + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], 97 + 98 + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 99 + 100 + "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], 101 + 102 + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 103 + 104 + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 105 + 106 + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 107 + 108 + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], 109 + 110 + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], 111 + 112 + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], 113 + 114 + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], 115 + 116 + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], 117 + 118 + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], 119 + 120 + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], 121 + 122 + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], 123 + 124 + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], 125 + 126 + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], 127 + 128 + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], 129 + 130 + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], 131 + 132 + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 133 + 134 + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], 135 + 136 + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], 137 + 138 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 139 + 140 + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 141 + 142 + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 143 + 144 + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], 145 + 146 + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], 147 + 148 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 149 + 150 + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], 151 + 152 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], 153 + 154 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], 155 + 156 + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], 157 + 158 + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], 159 + 160 + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 161 + 162 + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 163 + } 164 + }
+21
package.json
··· 1 + { 2 + "name": "drop", 3 + "module": "src/index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "build:css": "bunx @tailwindcss/cli -i src/styles.css -o public/style.css --minify", 8 + "dev": "bunx @tailwindcss/cli -i src/styles.css -o public/style.css --watch & bun run --hot src/index.ts", 9 + "start": "bun run build:css && bun run src/index.ts" 10 + }, 11 + "devDependencies": { 12 + "@tailwindcss/cli": "^4.2.1", 13 + "@types/bun": "latest" 14 + }, 15 + "peerDependencies": { 16 + "typescript": "^5.9.3" 17 + }, 18 + "dependencies": { 19 + "hono": "^4.12.3" 20 + } 21 + }
+82
public/crypto.js
··· 1 + // AES-256-GCM helpers using Web Crypto API 2 + // Each upload gets a unique key, so a fixed zero IV is safe. 3 + // Filename + file body are packed into a single blob before encryption, 4 + // so only one (key, IV) pair is ever used. 5 + 6 + const IV = new Uint8Array(12); // 12 zero bytes 7 + const PADDING_BLOCK = 4096; 8 + 9 + export async function generateKey() { 10 + const key = await crypto.subtle.generateKey( 11 + { name: "AES-GCM", length: 256 }, 12 + true, 13 + ["encrypt", "decrypt"], 14 + ); 15 + const raw = await crypto.subtle.exportKey("raw", key); 16 + return { 17 + key, 18 + encoded: new Uint8Array(raw).toBase64({ 19 + alphabet: "base64url", 20 + omitPadding: true, 21 + }), 22 + }; 23 + } 24 + 25 + export async function importKey(encoded) { 26 + const raw = Uint8Array.fromBase64(encoded, { alphabet: "base64url" }); 27 + return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [ 28 + "encrypt", 29 + "decrypt", 30 + ]); 31 + } 32 + 33 + // Pack: [u16 filenameLen][u64 fileLen][filename][file][zero padding to 4K boundary] 34 + // Then encrypt the whole thing as one AES-GCM ciphertext. 35 + export async function encrypt(fileName, fileBuffer, key) { 36 + const nameBytes = new TextEncoder().encode(fileName); 37 + if (nameBytes.length > 0xffff) throw new Error("Filename too long"); 38 + 39 + const headerSize = 2 + 8; // u16 + u64 40 + const payloadSize = headerSize + nameBytes.length + fileBuffer.byteLength; 41 + const paddedSize = Math.ceil(payloadSize / PADDING_BLOCK) * PADDING_BLOCK; 42 + 43 + const buf = new ArrayBuffer(paddedSize); 44 + const view = new DataView(buf); 45 + const bytes = new Uint8Array(buf); 46 + 47 + // Header 48 + view.setUint16(0, nameBytes.length, false); // big-endian 49 + // u64 file length — DataView has no setUint64, use two u32s 50 + const fileLen = fileBuffer.byteLength; 51 + view.setUint32(2, Math.floor(fileLen / 0x100000000), false); // high 32 52 + view.setUint32(6, fileLen >>> 0, false); // low 32 53 + 54 + // Filename + file body 55 + bytes.set(nameBytes, headerSize); 56 + bytes.set(new Uint8Array(fileBuffer), headerSize + nameBytes.length); 57 + 58 + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: IV }, key, buf); 59 + return new Uint8Array(ct); 60 + } 61 + 62 + // Decrypt and unpack — returns { fileName, fileData } 63 + export async function decrypt(ciphertext, key) { 64 + const plain = await crypto.subtle.decrypt( 65 + { name: "AES-GCM", iv: IV }, 66 + key, 67 + ciphertext, 68 + ); 69 + 70 + const view = new DataView(plain); 71 + const nameLen = view.getUint16(0, false); 72 + const fileLenHi = view.getUint32(2, false); 73 + const fileLenLo = view.getUint32(6, false); 74 + const fileLen = fileLenHi * 0x100000000 + fileLenLo; 75 + 76 + const headerSize = 2 + 8; 77 + const nameBytes = new Uint8Array(plain, headerSize, nameLen); 78 + const fileName = new TextDecoder().decode(nameBytes); 79 + const fileData = new Uint8Array(plain, headerSize + nameLen, fileLen); 80 + 81 + return { fileName, fileData }; 82 + }
+3
public/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 + <path d="M16 2 C15 6 9 12 7 17 C5 22 8 30 16 30 C24 30 27 22 25 17 C23 12 17 6 16 2Z" fill="#c4956a"/> 3 + </svg>
+304
public/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>drop</title> 7 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 8 + <link rel="stylesheet" href="/style.css" /> 9 + </head> 10 + <body 11 + class="bg-bg text-text min-h-screen flex flex-col items-center justify-center px-4 font-sans" 12 + > 13 + <div class="w-full max-w-md"> 14 + <h1 class="text-lg font-medium mb-6"> 15 + <a 16 + href="/" 17 + class="text-text no-underline hover:text-accent transition-colors" 18 + >drop</a 19 + > 20 + </h1> 21 + 22 + <div 23 + id="drop-zone" 24 + class="w-full h-[200px] bg-surface border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-accent transition-colors" 25 + > 26 + <svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + width="32" 29 + height="32" 30 + class="text-muted" 31 + fill="none" 32 + viewBox="0 0 24 24" 33 + stroke="currentColor" 34 + stroke-width="1.5" 35 + > 36 + <path 37 + stroke-linecap="round" 38 + stroke-linejoin="round" 39 + d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" 40 + /> 41 + </svg> 42 + <p class="text-sm text-muted"> 43 + Drop a file or 44 + <button 45 + id="browse-btn" 46 + class="text-accent underline bg-transparent border-none cursor-pointer p-0 text-sm" 47 + > 48 + browse 49 + </button> 50 + </p> 51 + <input type="file" id="file-input" class="hidden" /> 52 + </div> 53 + 54 + <div id="file-chip" class="hidden mt-3 flex items-center gap-2"> 55 + <span 56 + id="selected-file" 57 + class="inline-flex items-center gap-1.5 bg-surface border border-border rounded-full px-3 py-1 text-xs font-mono text-text" 58 + ></span> 59 + <button 60 + id="remove-file" 61 + class="text-muted hover:text-text bg-transparent border-none cursor-pointer p-0 text-sm leading-none" 62 + > 63 + &times; 64 + </button> 65 + </div> 66 + 67 + <div class="flex gap-4 items-center mt-4"> 68 + <input 69 + id="expiry" 70 + type="text" 71 + value="24h" 72 + placeholder="e.g. 30m, 24h, 7d" 73 + class="w-24 bg-surface border border-border rounded-md text-text py-1.5 px-2.5 text-xs outline-none focus:border-accent transition-colors" 74 + /> 75 + <label 76 + class="text-xs text-muted flex items-center gap-1.5 cursor-pointer select-none" 77 + > 78 + <input type="checkbox" id="burn" class="accent-accent" /> 79 + Burn after read 80 + </label> 81 + </div> 82 + 83 + <button 84 + id="submit" 85 + class="w-full mt-4 bg-accent text-white border-none rounded-md py-2.5 text-sm font-medium cursor-pointer hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors relative overflow-hidden" 86 + > 87 + Upload 88 + </button> 89 + 90 + <div 91 + id="progress-bar" 92 + class="hidden w-full h-1 bg-surface rounded-full mt-2 overflow-hidden" 93 + > 94 + <div 95 + id="progress-fill" 96 + class="h-full bg-accent rounded-full transition-all duration-150" 97 + style="width: 0%" 98 + ></div> 99 + </div> 100 + 101 + <div id="error" class="hidden mt-4 text-danger text-xs"></div> 102 + 103 + <div 104 + id="result" 105 + class="hidden mt-6 bg-surface border border-border rounded-lg p-4" 106 + > 107 + <label class="text-xs text-muted block mb-1.5">drop link</label> 108 + <div class="flex gap-2"> 109 + <input 110 + id="link-input" 111 + type="text" 112 + readonly 113 + class="flex-1 min-w-0 bg-bg border border-border rounded-md text-text py-1.5 px-2.5 text-xs font-mono outline-none" 114 + /> 115 + <button 116 + id="copy" 117 + class="bg-accent text-white border-none rounded-md py-1.5 px-3 text-xs font-medium cursor-pointer hover:bg-accent-hover transition-colors shrink-0" 118 + > 119 + Copy 120 + </button> 121 + </div> 122 + <div class="mt-3 flex items-center gap-2"> 123 + <a 124 + id="delete-url" 125 + href="#" 126 + class="text-xs text-muted hover:text-text transition-colors font-mono truncate" 127 + ></a> 128 + <button 129 + id="copy-delete" 130 + class="text-xs text-muted hover:text-text bg-transparent border-none cursor-pointer p-0 underline shrink-0" 131 + > 132 + Copy 133 + </button> 134 + </div> 135 + </div> 136 + </div> 137 + 138 + <p class="mt-auto pt-8 pb-4 text-[10px] text-muted">end-to-end encrypted</p> 139 + 140 + <!-- hidden anchor for link href --> 141 + <a id="link" href="#" class="hidden"></a> 142 + 143 + <script type="module"> 144 + import { generateKey, encrypt } from "/crypto.js"; 145 + 146 + const dropZone = document.getElementById("drop-zone"); 147 + const fileInput = document.getElementById("file-input"); 148 + const browseBtn = document.getElementById("browse-btn"); 149 + const fileChip = document.getElementById("file-chip"); 150 + const selectedFileEl = document.getElementById("selected-file"); 151 + const removeFileBtn = document.getElementById("remove-file"); 152 + const expiry = document.getElementById("expiry"); 153 + const btn = document.getElementById("submit"); 154 + const progressBar = document.getElementById("progress-bar"); 155 + const progressFill = document.getElementById("progress-fill"); 156 + const result = document.getElementById("result"); 157 + const linkInput = document.getElementById("link-input"); 158 + const linkEl = document.getElementById("link"); 159 + const copyBtn = document.getElementById("copy"); 160 + const deleteUrlEl = document.getElementById("delete-url"); 161 + const copyDeleteBtn = document.getElementById("copy-delete"); 162 + const errorEl = document.getElementById("error"); 163 + 164 + let selectedFile = null; 165 + 166 + browseBtn.addEventListener("click", (e) => { 167 + e.stopPropagation(); 168 + fileInput.click(); 169 + }); 170 + 171 + dropZone.addEventListener("click", () => fileInput.click()); 172 + 173 + fileInput.addEventListener("change", () => { 174 + if (fileInput.files[0]) setFile(fileInput.files[0]); 175 + }); 176 + 177 + // Full-page drag target 178 + document.addEventListener("dragover", (e) => { 179 + e.preventDefault(); 180 + dropZone.classList.add("border-accent", "bg-accent-subtle"); 181 + }); 182 + 183 + document.addEventListener("dragleave", (e) => { 184 + if ( 185 + e.relatedTarget === null || 186 + !document.body.contains(e.relatedTarget) 187 + ) { 188 + dropZone.classList.remove("border-accent", "bg-accent-subtle"); 189 + } 190 + }); 191 + 192 + document.addEventListener("drop", (e) => { 193 + e.preventDefault(); 194 + dropZone.classList.remove("border-accent", "bg-accent-subtle"); 195 + if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); 196 + }); 197 + 198 + removeFileBtn.addEventListener("click", () => { 199 + selectedFile = null; 200 + fileInput.value = ""; 201 + fileChip.classList.add("hidden"); 202 + }); 203 + 204 + function setFile(f) { 205 + selectedFile = f; 206 + selectedFileEl.textContent = `${f.name} (${formatBytes(f.size)})`; 207 + fileChip.classList.remove("hidden"); 208 + } 209 + 210 + function formatBytes(n) { 211 + if (n < 1024) return `${n} B`; 212 + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; 213 + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; 214 + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; 215 + } 216 + 217 + btn.addEventListener("click", async () => { 218 + if (!selectedFile) return; 219 + 220 + btn.disabled = true; 221 + btn.textContent = "Encrypting\u2026"; 222 + errorEl.classList.add("hidden"); 223 + result.classList.add("hidden"); 224 + progressBar.classList.add("hidden"); 225 + progressFill.style.width = "0%"; 226 + 227 + try { 228 + const { key, encoded } = await generateKey(); 229 + const buffer = await selectedFile.arrayBuffer(); 230 + const ciphertext = await encrypt(selectedFile.name || "file", buffer, key); 231 + 232 + const formData = new FormData(); 233 + formData.append("file", new Blob([ciphertext])); 234 + formData.append("expiresIn", expiry.value.trim()); 235 + formData.append( 236 + "burnAfterRead", 237 + document.getElementById("burn").checked ? "true" : "false", 238 + ); 239 + 240 + btn.textContent = "Uploading\u2026"; 241 + progressBar.classList.remove("hidden"); 242 + 243 + const res = await new Promise((resolve, reject) => { 244 + const xhr = new XMLHttpRequest(); 245 + xhr.open("POST", "/api/file"); 246 + 247 + xhr.upload.onprogress = (e) => { 248 + if (e.lengthComputable) { 249 + const pct = Math.round((e.loaded / e.total) * 100); 250 + progressFill.style.width = pct + "%"; 251 + btn.textContent = `Uploading\u2026 ${pct}%`; 252 + } 253 + }; 254 + 255 + xhr.onload = () => { 256 + if (xhr.status >= 200 && xhr.status < 300) { 257 + resolve(JSON.parse(xhr.responseText)); 258 + } else { 259 + try { 260 + const err = JSON.parse(xhr.responseText); 261 + reject(new Error(err.error || "Upload failed")); 262 + } catch { 263 + reject(new Error("Upload failed")); 264 + } 265 + } 266 + }; 267 + 268 + xhr.onerror = () => reject(new Error("Upload failed")); 269 + xhr.send(formData); 270 + }); 271 + 272 + const { id, deleteToken } = res; 273 + const url = `${location.origin}/p/${id}#${encoded}`; 274 + const deleteUrl = `${location.origin}/delete/${id}?token=${deleteToken}`; 275 + linkEl.href = url; 276 + linkInput.value = url; 277 + deleteUrlEl.href = deleteUrl; 278 + deleteUrlEl.textContent = "Delete link"; 279 + result.classList.remove("hidden"); 280 + progressBar.classList.add("hidden"); 281 + } catch (e) { 282 + errorEl.textContent = e.message; 283 + errorEl.classList.remove("hidden"); 284 + progressBar.classList.add("hidden"); 285 + } finally { 286 + btn.disabled = false; 287 + btn.textContent = "Upload"; 288 + } 289 + }); 290 + 291 + copyBtn.addEventListener("click", () => { 292 + navigator.clipboard.writeText(linkInput.value); 293 + copyBtn.textContent = "Copied!"; 294 + setTimeout(() => (copyBtn.textContent = "Copy"), 1500); 295 + }); 296 + 297 + copyDeleteBtn.addEventListener("click", () => { 298 + navigator.clipboard.writeText(deleteUrlEl.href); 299 + copyDeleteBtn.textContent = "Copied!"; 300 + setTimeout(() => (copyDeleteBtn.textContent = "Copy"), 1500); 301 + }); 302 + </script> 303 + </body> 304 + </html>
+2
public/style.css
··· 1 + /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ 2 + @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-medium:500;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:var(--c-bg);--color-surface:var(--c-surface);--color-border:var(--c-border);--color-text:var(--c-text);--color-muted:var(--c-muted);--color-accent:var(--c-accent);--color-accent-hover:var(--c-accent-hover);--color-accent-subtle:var(--c-accent-subtle);--color-danger:var(--c-danger)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.top-2{top:calc(var(--spacing) * 2)}.right-2{right:calc(var(--spacing) * 2)}.z-10{z-index:10}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-1{height:calc(var(--spacing) * 1)}.h-\[200px\]{height:200px}.h-full{height:100%}.max-h-\[60vh\]{max-height:60vh}.min-h-screen{min-height:100vh}.w-24{width:calc(var(--spacing) * 24)}.w-full{width:100%}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-none{--tw-border-style:none;border-style:none}.border-accent{border-color:var(--color-accent)}.border-border{border-color:var(--color-border)}.bg-accent{background-color:var(--color-accent)}.bg-accent-subtle{background-color:var(--color-accent-subtle)}.bg-bg{background-color:var(--color-bg)}.bg-surface{background-color:var(--color-surface)}.bg-transparent{background-color:#0000}.p-0{padding:calc(var(--spacing) * 0)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-4{padding:calc(var(--spacing) * 4)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.wrap-break-word{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-danger{color:var(--color-danger)}.text-muted{color:var(--color-muted)}.text-text{color:var(--color-text)}.text-white{color:var(--color-white)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.accent-accent{accent-color:var(--color-accent)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:border-accent:hover{border-color:var(--color-accent)}.hover\:bg-accent-hover:hover{background-color:var(--color-accent-hover)}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-text:hover{color:var(--color-text)}}.focus\:border-accent:focus{border-color:var(--color-accent)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}}html{color-scheme:light dark}:root{--c-bg:#faf7f4;--c-surface:#f0ebe4;--c-border:#ddd3c8;--c-text:#1c1410;--c-muted:#8c7b6e;--c-accent:#9b5e23;--c-accent-hover:#7c4a1a;--c-accent-subtle:#f5ece3;--c-danger:#b91c1c}@media (prefers-color-scheme:dark){:root{--c-bg:#100c09;--c-surface:#1a1410;--c-border:#2e2218;--c-text:#f0e8df;--c-muted:#9e8878;--c-accent:#c4956a;--c-accent-hover:#d4a87c;--c-accent-subtle:#c4956a1a;--c-danger:#f87171}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}
+346
public/view.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>drop</title> 7 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 8 + <link rel="stylesheet" href="/style.css" /> 9 + </head> 10 + <body 11 + class="bg-bg text-text min-h-screen flex flex-col items-center justify-center px-4 font-sans" 12 + > 13 + <div class="w-full max-w-md"> 14 + <h1 class="text-lg font-medium mb-6"> 15 + <a 16 + href="/" 17 + class="text-text no-underline hover:text-accent transition-colors" 18 + >drop</a 19 + > 20 + </h1> 21 + 22 + <!-- Metadata card (shown after HEAD) --> 23 + <div id="meta" class="hidden"> 24 + <div class="font-mono text-base mb-1" id="meta-name"></div> 25 + <div class="flex gap-3 text-muted text-xs mb-1"> 26 + <span id="meta-size"></span> 27 + <span id="meta-expiry"></span> 28 + </div> 29 + <div id="burn-warning" class="hidden mb-3"> 30 + <span 31 + class="inline-block text-danger text-[10px] font-medium rounded-full px-2 py-0.5" 32 + style=" 33 + background: color-mix( 34 + in srgb, 35 + var(--color-danger) 10%, 36 + transparent 37 + ); 38 + " 39 + > 40 + Burns after viewing 41 + </span> 42 + </div> 43 + <div class="mt-4"> 44 + <button 45 + id="load-btn" 46 + class="w-full bg-accent text-white border-none rounded-md py-2.5 text-sm font-medium cursor-pointer hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 47 + > 48 + View 49 + </button> 50 + </div> 51 + </div> 52 + 53 + <!-- Content (shown after GET + decrypt) --> 54 + <div id="content-wrapper" class="hidden relative"> 55 + <div class="absolute top-2 right-2 flex gap-1 z-10"> 56 + <button 57 + id="copy" 58 + class="bg-surface text-muted hover:text-text border border-border rounded-md p-1.5 cursor-pointer transition-colors" 59 + title="Copy" 60 + > 61 + <svg 62 + xmlns="http://www.w3.org/2000/svg" 63 + width="14" 64 + height="14" 65 + viewBox="0 0 24 24" 66 + fill="none" 67 + stroke="currentColor" 68 + stroke-width="2" 69 + stroke-linecap="round" 70 + stroke-linejoin="round" 71 + > 72 + <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> 73 + <path 74 + d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" 75 + /> 76 + </svg> 77 + </button> 78 + <button 79 + id="raw" 80 + class="bg-surface text-muted hover:text-text border border-border rounded-md p-1.5 cursor-pointer transition-colors" 81 + title="Raw" 82 + > 83 + <svg 84 + xmlns="http://www.w3.org/2000/svg" 85 + width="14" 86 + height="14" 87 + viewBox="0 0 24 24" 88 + fill="none" 89 + stroke="currentColor" 90 + stroke-width="2" 91 + stroke-linecap="round" 92 + stroke-linejoin="round" 93 + > 94 + <polyline points="4 7 4 4 20 4 20 7" /> 95 + <line x1="9" x2="15" y1="20" y2="20" /> 96 + <line x1="12" x2="12" y1="4" y2="20" /> 97 + </svg> 98 + </button> 99 + </div> 100 + <div 101 + id="content" 102 + class="w-full bg-surface border border-border rounded-lg p-4 pt-10 font-mono text-xs leading-relaxed whitespace-pre-wrap wrap-break-word max-h-[60vh] overflow-auto" 103 + ></div> 104 + </div> 105 + 106 + <div id="image-container" class="hidden w-full"> 107 + <img 108 + id="image-el" 109 + src="" 110 + alt="" 111 + class="max-w-full rounded-lg shadow-sm" 112 + /> 113 + </div> 114 + 115 + <div id="binary-container" class="hidden"> 116 + <div class="bg-surface border border-border rounded-lg p-4 text-center"> 117 + <p id="binary-name" class="font-mono text-sm mb-3"></p> 118 + <button 119 + id="download" 120 + class="w-full bg-accent text-white border-none rounded-md py-2.5 text-sm font-medium cursor-pointer hover:bg-accent-hover transition-colors" 121 + > 122 + Download 123 + </button> 124 + </div> 125 + </div> 126 + 127 + <div id="burn-notice" class="hidden mt-3 text-xs text-danger"> 128 + This file has been burned and can no longer be viewed. 129 + </div> 130 + 131 + <div id="image-actions" class="hidden mt-3"> 132 + <button 133 + id="save" 134 + class="w-full bg-surface text-text border border-border rounded-md py-2 text-sm font-medium cursor-pointer hover:bg-surface transition-colors" 135 + > 136 + Save 137 + </button> 138 + </div> 139 + 140 + <div id="error" class="text-danger text-xs mt-4"></div> 141 + </div> 142 + 143 + <p class="mt-auto pt-8 pb-4 text-[10px] text-muted">end-to-end encrypted</p> 144 + 145 + <script type="module"> 146 + import { importKey, decrypt } from "/crypto.js"; 147 + 148 + const metaEl = document.getElementById("meta"); 149 + const metaName = document.getElementById("meta-name"); 150 + const metaSize = document.getElementById("meta-size"); 151 + const metaExpiry = document.getElementById("meta-expiry"); 152 + const burnWarning = document.getElementById("burn-warning"); 153 + const loadBtn = document.getElementById("load-btn"); 154 + const contentWrapper = document.getElementById("content-wrapper"); 155 + const contentEl = document.getElementById("content"); 156 + const imageContainer = document.getElementById("image-container"); 157 + const imageEl = document.getElementById("image-el"); 158 + const binaryContainer = document.getElementById("binary-container"); 159 + const binaryName = document.getElementById("binary-name"); 160 + const burnNotice = document.getElementById("burn-notice"); 161 + const imageActions = document.getElementById("image-actions"); 162 + const copyBtn = document.getElementById("copy"); 163 + const rawBtn = document.getElementById("raw"); 164 + const downloadBtn = document.getElementById("download"); 165 + const saveBtn = document.getElementById("save"); 166 + const errorEl = document.getElementById("error"); 167 + 168 + let decryptedText = ""; 169 + 170 + const IMAGE_EXTS = new Set([ 171 + "png", 172 + "jpg", 173 + "jpeg", 174 + "gif", 175 + "webp", 176 + "svg", 177 + "ico", 178 + "bmp", 179 + "tiff", 180 + "avif", 181 + ]); 182 + const TEXT_EXTS = new Set([ 183 + "txt", 184 + "md", 185 + "js", 186 + "ts", 187 + "jsx", 188 + "tsx", 189 + "json", 190 + "yaml", 191 + "yml", 192 + "toml", 193 + "xml", 194 + "html", 195 + "css", 196 + "sh", 197 + "py", 198 + "rb", 199 + "go", 200 + "rs", 201 + "java", 202 + "c", 203 + "cpp", 204 + "h", 205 + "php", 206 + "sql", 207 + "csv", 208 + "log", 209 + "ini", 210 + "env", 211 + ]); 212 + const IMAGE_MIME = { 213 + png: "image/png", 214 + jpg: "image/jpeg", 215 + jpeg: "image/jpeg", 216 + gif: "image/gif", 217 + webp: "image/webp", 218 + svg: "image/svg+xml", 219 + ico: "image/x-icon", 220 + bmp: "image/bmp", 221 + tiff: "image/tiff", 222 + avif: "image/avif", 223 + }; 224 + 225 + function getExt(name) { 226 + const parts = name.split("."); 227 + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; 228 + } 229 + 230 + function formatBytes(n) { 231 + if (n < 1024) return `${n} B`; 232 + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; 233 + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; 234 + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; 235 + } 236 + 237 + function formatExpiry(unixSec) { 238 + const secs = unixSec - Math.floor(Date.now() / 1000); 239 + if (secs < 60) return "expires in less than a minute"; 240 + if (secs < 3600) return `expires in ${Math.floor(secs / 60)}m`; 241 + if (secs < 86400) return `expires in ${Math.floor(secs / 3600)}h`; 242 + return `expires in ${Math.floor(secs / 86400)}d`; 243 + } 244 + 245 + function triggerDownload(blob, fileName) { 246 + const url = URL.createObjectURL(blob); 247 + const a = document.createElement("a"); 248 + a.href = url; 249 + a.download = fileName; 250 + a.click(); 251 + setTimeout(() => URL.revokeObjectURL(url), 60000); 252 + } 253 + 254 + async function load() { 255 + const id = location.pathname.split("/p/")[1]; 256 + const keyEncoded = location.hash.slice(1); 257 + 258 + if (!id || !keyEncoded) { 259 + errorEl.textContent = "Invalid URL."; 260 + return; 261 + } 262 + 263 + const key = await importKey(keyEncoded); 264 + 265 + // HEAD — get metadata without fetching or burning the file 266 + const headRes = await fetch(`/api/file/${id}`, { method: "HEAD" }); 267 + if (!headRes.ok) { 268 + errorEl.textContent = "File not found or expired."; 269 + return; 270 + } 271 + 272 + const burnAfterRead = headRes.headers.get("X-Burn-After-Read") === "1"; 273 + const expiresAt = parseInt(headRes.headers.get("X-Expires-At") || "0"); 274 + const size = parseInt(headRes.headers.get("Content-Length") || "0"); 275 + 276 + metaName.textContent = "Encrypted file"; 277 + metaSize.textContent = formatBytes(size); 278 + metaExpiry.textContent = formatExpiry(expiresAt); 279 + loadBtn.textContent = "View"; 280 + if (burnAfterRead) burnWarning.classList.remove("hidden"); 281 + metaEl.classList.remove("hidden"); 282 + 283 + loadBtn.addEventListener("click", async () => { 284 + loadBtn.disabled = true; 285 + loadBtn.textContent = "Decrypting\u2026"; 286 + 287 + try { 288 + const res = await fetch(`/api/file/${id}`); 289 + if (!res.ok) { 290 + errorEl.textContent = "File not found or expired."; 291 + return; 292 + } 293 + 294 + const buf = await res.arrayBuffer(); 295 + const { fileName, fileData } = await decrypt(new Uint8Array(buf), key); 296 + const ext = getExt(fileName); 297 + 298 + metaEl.classList.add("hidden"); 299 + 300 + if (burnAfterRead) burnNotice.classList.remove("hidden"); 301 + 302 + if (IMAGE_EXTS.has(ext)) { 303 + const mime = IMAGE_MIME[ext] || "image/png"; 304 + const blob = new Blob([fileData], { type: mime }); 305 + imageEl.src = URL.createObjectURL(blob); 306 + imageEl.alt = fileName; 307 + imageContainer.classList.remove("hidden"); 308 + imageActions.classList.remove("hidden"); 309 + saveBtn.addEventListener("click", () => 310 + triggerDownload(blob, fileName), 311 + ); 312 + } else if (TEXT_EXTS.has(ext)) { 313 + decryptedText = new TextDecoder().decode(fileData); 314 + contentEl.textContent = decryptedText; 315 + contentWrapper.classList.remove("hidden"); 316 + } else { 317 + const blob = new Blob([fileData]); 318 + binaryName.textContent = fileName; 319 + binaryContainer.classList.remove("hidden"); 320 + downloadBtn.addEventListener("click", () => 321 + triggerDownload(blob, fileName), 322 + ); 323 + } 324 + } catch (e) { 325 + errorEl.textContent = "Failed to decrypt. The key may be wrong."; 326 + loadBtn.disabled = false; 327 + loadBtn.textContent = "Retry"; 328 + } 329 + }); 330 + } 331 + 332 + copyBtn.addEventListener("click", () => { 333 + navigator.clipboard.writeText(decryptedText); 334 + copyBtn.title = "Copied!"; 335 + setTimeout(() => (copyBtn.title = "Copy"), 1500); 336 + }); 337 + 338 + rawBtn.addEventListener("click", () => { 339 + const w = window.open(); 340 + w.document.write(`<pre>${decryptedText.replace(/</g, "&lt;")}</pre>`); 341 + }); 342 + 343 + load(); 344 + </script> 345 + </body> 346 + </html>
+8
src/config.ts
··· 1 + export const config = { 2 + port: parseInt(process.env.PORT || "3000"), 3 + dataDir: process.env.DATA_DIR || "./data", 4 + maxFileSize: parseInt(process.env.MAX_FILE_SIZE || "524288000"), // 500MB 5 + maxTtl: process.env.MAX_TTL || "30d", 6 + rateLimitWindowS: parseInt(process.env.RATE_LIMIT_WINDOW_S || "3600"), // 1 hour 7 + rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || "20"), 8 + };
+113
src/db.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { mkdirSync, unlinkSync } from "fs"; 3 + import { config } from "./config.ts"; 4 + 5 + const DATA_DIR = config.dataDir; 6 + const FILES_DIR = `${DATA_DIR}/files`; 7 + mkdirSync(FILES_DIR, { recursive: true }); 8 + 9 + const db = new Database(`${DATA_DIR}/drop.db`, { create: true }); 10 + db.run("PRAGMA journal_mode = WAL;"); 11 + 12 + db.run(` 13 + CREATE TABLE IF NOT EXISTS files ( 14 + id TEXT PRIMARY KEY, 15 + expires_at INTEGER NOT NULL, 16 + burn_after_read INTEGER NOT NULL DEFAULT 0, 17 + created_at INTEGER NOT NULL, 18 + delete_token TEXT NOT NULL 19 + ); 20 + `); 21 + 22 + const insertStmt = db.prepare< 23 + void, 24 + [string, number, number, number, string] 25 + >( 26 + "INSERT INTO files (id, expires_at, burn_after_read, created_at, delete_token) VALUES (?, ?, ?, ?, ?)", 27 + ); 28 + 29 + type FileRow = { 30 + id: string; 31 + expires_at: number; 32 + burn_after_read: number; 33 + created_at: number; 34 + delete_token: string; 35 + }; 36 + 37 + const selectStmt = db.prepare<FileRow, [string]>( 38 + "SELECT * FROM files WHERE id = ?", 39 + ); 40 + 41 + const deleteStmt = db.prepare<void, [string]>("DELETE FROM files WHERE id = ?"); 42 + 43 + const burnStmt = db.prepare<FileRow, [string]>( 44 + "DELETE FROM files WHERE id = ? AND burn_after_read = 1 RETURNING *", 45 + ); 46 + 47 + const deleteWithTokenStmt = db.prepare<void, [string, string]>( 48 + "DELETE FROM files WHERE id = ? AND delete_token = ?", 49 + ); 50 + 51 + const cleanStmt = db.prepare<{ id: string }, [number]>( 52 + "DELETE FROM files WHERE expires_at <= ? RETURNING id", 53 + ); 54 + 55 + export function createFile( 56 + id: string, 57 + expiresAt: number, 58 + burnAfterRead: boolean, 59 + deleteToken: string, 60 + ): void { 61 + insertStmt.run( 62 + id, 63 + expiresAt, 64 + burnAfterRead ? 1 : 0, 65 + Math.floor(Date.now() / 1000), 66 + deleteToken, 67 + ); 68 + } 69 + 70 + // Read-only lookup — does not trigger burn-after-read or delete expired rows 71 + export function peekFile(id: string) { 72 + const row = selectStmt.get(id); 73 + if (!row) return null; 74 + if (row.expires_at <= Math.floor(Date.now() / 1000)) return null; 75 + return row; 76 + } 77 + 78 + export function getFile(id: string) { 79 + // Try atomic burn-after-read first — if the row has burn_after_read=1, 80 + // this deletes and returns it in one step, preventing double-reads 81 + const burned = burnStmt.get(id); 82 + if (burned) { 83 + if (burned.expires_at <= Math.floor(Date.now() / 1000)) return null; 84 + return burned; 85 + } 86 + const row = selectStmt.get(id); 87 + if (!row) return null; 88 + if (row.expires_at <= Math.floor(Date.now() / 1000)) { 89 + deleteStmt.run(id); 90 + return null; 91 + } 92 + return row; 93 + } 94 + 95 + export function unlinkFile(id: string): void { 96 + try { 97 + unlinkSync(`${FILES_DIR}/${id}`); 98 + } catch {} 99 + } 100 + 101 + export function deleteFile(id: string, token: string): boolean { 102 + const result = deleteWithTokenStmt.run(id, token); 103 + if (!result.changes) return false; 104 + unlinkFile(id); 105 + return true; 106 + } 107 + 108 + export function cleanExpired(): void { 109 + const now = Math.floor(Date.now() / 1000); 110 + for (const { id } of cleanStmt.all(now)) { 111 + unlinkFile(id); 112 + } 113 + }
+31
src/index.ts
··· 1 + import { Hono } from "hono"; 2 + import { serveStatic } from "hono/bun"; 3 + import file from "./routes/file.ts"; 4 + import del from "./routes/delete.ts"; 5 + import { cleanExpired } from "./db.ts"; 6 + import { config } from "./config.ts"; 7 + 8 + const app = new Hono(); 9 + 10 + app.route("/api/file", file); 11 + app.route("/delete", del); 12 + 13 + app.get("/p/:id", () => { 14 + return new Response(Bun.file("public/view.html"), { 15 + headers: { "Content-Type": "text/html; charset=utf-8" }, 16 + }); 17 + }); 18 + 19 + app.use("/*", serveStatic({ root: "./public" })); 20 + 21 + // Clean expired uploads every 5 minutes 22 + setInterval(cleanExpired, 5 * 60 * 1000); 23 + 24 + const port = config.port; 25 + console.log(`Server running on http://localhost:${port}`); 26 + 27 + export default { 28 + port, 29 + maxRequestBodySize: config.maxFileSize, 30 + fetch: app.fetch, 31 + };
+34
src/middleware/rateLimit.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import { config } from "../config.ts"; 3 + 4 + const WINDOW_MS = config.rateLimitWindowS * 1000; 5 + const MAX_REQUESTS = config.rateLimitMax; 6 + 7 + const requests = new Map<string, number[]>(); 8 + 9 + // Clean up stale entries periodically 10 + setInterval(() => { 11 + const now = Date.now(); 12 + for (const [ip, timestamps] of requests) { 13 + if (timestamps.every((t) => now - t > WINDOW_MS)) { 14 + requests.delete(ip); 15 + } 16 + } 17 + }, WINDOW_MS); 18 + 19 + export const rateLimit = createMiddleware(async (c, next) => { 20 + const ip = 21 + c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown"; 22 + const now = Date.now(); 23 + const timestamps = (requests.get(ip) ?? []).filter( 24 + (t) => now - t < WINDOW_MS, 25 + ); 26 + 27 + if (timestamps.length >= MAX_REQUESTS) { 28 + return c.json({ error: "Too many requests" }, 429); 29 + } 30 + 31 + timestamps.push(now); 32 + requests.set(ip, timestamps); 33 + await next(); 34 + });
+18
src/routes/delete.ts
··· 1 + import { Hono } from "hono"; 2 + import { deleteFile } from "../db.ts"; 3 + 4 + const del = new Hono(); 5 + 6 + del.get("/:id", (c) => { 7 + const id = c.req.param("id"); 8 + const token = c.req.query("token"); 9 + if (!token) { 10 + return c.json({ error: "Missing token" }, 401); 11 + } 12 + if (!deleteFile(id, token)) { 13 + return c.json({ error: "Invalid token or file not found" }, 403); 14 + } 15 + return c.json({ ok: true }); 16 + }); 17 + 18 + export default del;
+99
src/routes/file.ts
··· 1 + import { Hono } from "hono"; 2 + import { createFile, getFile, peekFile, unlinkFile } from "../db.ts"; 3 + import { rateLimit } from "../middleware/rateLimit.ts"; 4 + import { config } from "../config.ts"; 5 + 6 + function randomId(length: number): string { 7 + return Buffer.from(crypto.getRandomValues(new Uint8Array(Math.ceil(length * 3 / 4)))) 8 + .toString("base64url") 9 + .slice(0, length); 10 + } 11 + 12 + const DURATION_UNITS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 }; 13 + 14 + function parseDuration(s: string): number | undefined { 15 + const n = parseInt(s); 16 + const mult = DURATION_UNITS[s.slice(-1)]; 17 + if (isNaN(n) || mult === undefined) return undefined; 18 + return n * mult; 19 + } 20 + 21 + const FILES_DIR = `${config.dataDir}/files`; 22 + const MAX_FILE_SIZE = config.maxFileSize; 23 + const MAX_TTL = parseDuration(config.maxTtl)!; 24 + 25 + const file = new Hono(); 26 + 27 + file.post("/", rateLimit, async (c) => { 28 + const formData = await c.req.formData(); 29 + const fileField = formData.get("file"); 30 + const expiresIn = formData.get("expiresIn"); 31 + const burnAfterRead = formData.get("burnAfterRead") === "true"; 32 + 33 + if (!fileField || !(fileField instanceof File)) { 34 + return c.json({ error: "file field is required" }, 400); 35 + } 36 + 37 + if (fileField.size > MAX_FILE_SIZE) { 38 + return c.json({ error: "File too large" }, 413); 39 + } 40 + 41 + const expiresInStr = typeof expiresIn === "string" ? expiresIn.trim() : ""; 42 + const expiresInSec = expiresInStr ? parseDuration(expiresInStr) : undefined; 43 + if (!expiresInSec) { 44 + return c.json( 45 + { error: "Invalid expiresIn. Use a duration like 30m, 24h, 7d" }, 46 + 400, 47 + ); 48 + } 49 + if (expiresInSec > MAX_TTL) { 50 + return c.json({ error: "expiresIn exceeds maximum allowed TTL" }, 400); 51 + } 52 + 53 + const id = randomId(12); 54 + const deleteToken = randomId(32); 55 + const expiresAt = Math.floor(Date.now() / 1000) + expiresInSec; 56 + const filePath = `${FILES_DIR}/${id}`; 57 + 58 + const buffer = await fileField.arrayBuffer(); 59 + await Bun.write(filePath, buffer); 60 + 61 + try { 62 + createFile(id, expiresAt, burnAfterRead, deleteToken); 63 + } catch (err) { 64 + unlinkFile(id); 65 + throw err; 66 + } 67 + 68 + return c.json({ id, deleteToken }); 69 + }); 70 + 71 + file.on(["HEAD", "GET"], "/:id", (c) => { 72 + const id = c.req.param("id"); 73 + const isHead = c.req.method === "HEAD"; 74 + const row = isHead ? peekFile(id) : getFile(id); 75 + 76 + if (!row) { 77 + return c.json({ error: "File not found or expired" }, 404); 78 + } 79 + 80 + const filePath = `${FILES_DIR}/${id}`; 81 + const bunFile = Bun.file(filePath); 82 + 83 + const headers = new Headers({ 84 + "Content-Type": "application/octet-stream", 85 + "Content-Length": String(bunFile.size), 86 + "X-Expires-At": String(row.expires_at), 87 + "X-Burn-After-Read": row.burn_after_read ? "1" : "0", 88 + }); 89 + 90 + const response = new Response(bunFile, { headers }); 91 + 92 + if (!isHead && row.burn_after_read) { 93 + setTimeout(() => unlinkFile(id), 0); 94 + } 95 + 96 + return response; 97 + }); 98 + 99 + export default file;
+46
src/styles.css
··· 1 + @import "tailwindcss"; 2 + 3 + @source "../public"; 4 + 5 + @theme { 6 + --color-bg: var(--c-bg); 7 + --color-surface: var(--c-surface); 8 + --color-border: var(--c-border); 9 + --color-text: var(--c-text); 10 + --color-muted: var(--c-muted); 11 + --color-accent: var(--c-accent); 12 + --color-accent-hover: var(--c-accent-hover); 13 + --color-accent-subtle: var(--c-accent-subtle); 14 + --color-danger: var(--c-danger); 15 + --font-mono: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace; 16 + } 17 + 18 + html { 19 + color-scheme: light dark; 20 + } 21 + 22 + :root { 23 + --c-bg: #faf7f4; 24 + --c-surface: #f0ebe4; 25 + --c-border: #ddd3c8; 26 + --c-text: #1c1410; 27 + --c-muted: #8c7b6e; 28 + --c-accent: #9b5e23; 29 + --c-accent-hover: #7c4a1a; 30 + --c-accent-subtle: #f5ece3; 31 + --c-danger: #b91c1c; 32 + } 33 + 34 + @media (prefers-color-scheme: dark) { 35 + :root { 36 + --c-bg: #100c09; 37 + --c-surface: #1a1410; 38 + --c-border: #2e2218; 39 + --c-text: #f0e8df; 40 + --c-muted: #9e8878; 41 + --c-accent: #c4956a; 42 + --c-accent-hover: #d4a87c; 43 + --c-accent-subtle: rgba(196, 149, 106, 0.1); 44 + --c-danger: #f87171; 45 + } 46 + }
+28
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "allowJs": true, 9 + 10 + // Bundler mode 11 + "moduleResolution": "bundler", 12 + "allowImportingTsExtensions": true, 13 + "verbatimModuleSyntax": true, 14 + "noEmit": true, 15 + 16 + // Best practices 17 + "strict": true, 18 + "skipLibCheck": true, 19 + "noFallthroughCasesInSwitch": true, 20 + "noUncheckedIndexedAccess": true, 21 + "noImplicitOverride": true, 22 + 23 + // Some stricter flags (disabled by default) 24 + "noUnusedLocals": false, 25 + "noUnusedParameters": false, 26 + "noPropertyAccessFromIndexSignature": false 27 + } 28 + }