first commit

This commit is contained in:
Schluffe
2026-09-07 18:55:05 +02:00
commit d198332c92
15 changed files with 1125 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 HellCon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+57
View File
@@ -0,0 +1,57 @@
# HellCon
Peer-to-peer file sharing between two browsers. No server, no account, no
upload. The page you host is only a delivery mechanism for the JavaScript —
the files themselves travel directly from one browser to the other over
WebRTC, end-to-end encrypted (DTLS-SRTP) by the browsers themselves.
Built with Vite + Vue 3, toolchain is `bun`.
## How it works
1. **Sender** picks a file, chooses a STUN provider, and clicks
*Create code*. Their browser generates a WebRTC offer and the app presents
it as one copyable string.
2. The sender pastes that code to the receiver through **any channel they
already trust** — a chat app, email, a piece of paper read over the phone.
3. **Receiver** pastes the code; their browser answers and the app shows the
answer string. They send it back the same way.
4. The sender pastes the answer. Both browsers exchange the SDP fingerprints
as a two-line security check — if both sides see the same codes, no one
can have interposed on the exchange.
5. The connection opens and the file is transferred in chunks with
backpressure, reassembled on the receiver side and handed to the user as
a download.
**STUN**: if both peers are on the same network nothing external is needed
(pick "None"). For peers on different networks the browsers need one
stateless UDP lookup against a public STUN server (Google or Cloudflare, or
your own) to discover their public IP. That's the only third-party contact,
and it never sees connection or file data. Remove the `iceServers` entry in
`src/lib/webrtc.js` if you want to talk to nobody but your peers.
**MITM model**: the file data is end-to-end encrypted by the browsers and
cannot be MITM'd by a passive attacker. The only theoretical attack is
interposing on the *code exchange* itself; the built-in fingerprint
comparison ("Security check" after signaling) closes it — compare the codes
over a channel you trust.
## Commands
```sh
bun install
bun run dev # dev server
bun run build # static build -> dist/ (3 static files + favicon + robots.txt)
bun run preview # serve the production build
```
## Honest disclaimer
This is **vibe-coded throw-away software**. It was generated for fun, it
worked on the machines it was tried on, and it is roughly audited rather
than battle-tested. It may break on your browser, your NAT, or your use
case. If it works for you, great. If not, fixing it is now your hobby.
## License
MIT — do whatever you want with this. No warranty, see `LICENSE`.
+187
View File
@@ -0,0 +1,187 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "hellcon",
"dependencies": {
"vue": "^3.5.0",
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0",
},
},
},
"packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.42", "@vue/compiler-dom": "3.5.42", "@vue/compiler-ssr": "3.5.42", "@vue/shared": "3.5.42", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.42", "", { "dependencies": { "@vue/shared": "3.5.42" } }, "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/runtime-core": "3.5.42", "@vue/shared": "3.5.42", "csstype": "^3.2.3" } }, "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.42", "", { "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ=="],
"@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
<link rel="icon" type="image/png" href="./HellNet_alt.png" />
<title>HellCon — P2P File Share</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hellcon",
"private": true,
"version": "0.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+32
View File
@@ -0,0 +1,32 @@
<script setup>
import { ref } from 'vue';
import SendPanel from './components/SendPanel.vue';
import ReceivePanel from './components/ReceivePanel.vue';
const mode = ref('send');
</script>
<template>
<h1>HellCon</h1>
<p class="subtitle">
Peer-to-peer file sharing. No server, no account — files go straight from
browser to browser over WebRTC.
</p>
<div class="tabs" style="width: 100%; max-width: 680px">
<button :class="{ active: mode === 'send' }" @click="mode = 'send'">Send</button>
<button :class="{ active: mode === 'receive' }" @click="mode = 'receive'">Receive</button>
</div>
<SendPanel v-if="mode === 'send'" />
<ReceivePanel v-else />
<footer>
<p>
All transfers are end-to-end encrypted (WebRTC/DTLS) and never pass
through this server — it only delivers this page. Verify the security
check codes with your peer to exclude interception. You are responsible
for what you send, and for whom you send it to.
</p>
</footer>
</template>
+190
View File
@@ -0,0 +1,190 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import {
createPeerConnection,
createAnswerCode,
getFingerprints,
STUN_PROVIDERS,
} from '../lib/webrtc';
const STATUS = {
idle: 'Paste the sender\'s connection code to start.',
'creating-answer': 'Gathering connection info…',
'answer-ready': 'Send the code below back to the sender.',
connecting: 'Waiting for the P2P connection…',
receiving: 'Receiving…',
done: 'File received!',
closed: 'Connection closed.',
};
const step = ref('idle');
const offerInput = ref('');
const answerCode = ref('');
const progress = ref(0);
const error = ref('');
const copied = ref(false);
const fps = ref(null);
const stunChoice = ref('google');
const customStun = ref('');
const fileName = ref('');
const fileSize = ref(0);
const downloadUrl = ref('');
let pc = null;
const statusText = computed(() => STATUS[step.value] ?? '');
const resolvedStun = computed(() => {
if (stunChoice.value === 'custom') {
const raw = customStun.value.trim();
return raw ? raw : null;
}
return STUN_PROVIDERS.find((p) => p.id === stunChoice.value)?.url ?? null;
});
onBeforeUnmount(() => {
pc?.close();
if (downloadUrl.value) URL.revokeObjectURL(downloadUrl.value);
});
async function acceptOffer() {
error.value = '';
if (downloadUrl.value) {
URL.revokeObjectURL(downloadUrl.value);
downloadUrl.value = '';
}
progress.value = 0;
fps.value = null;
pc?.close();
pc = createPeerConnection(resolvedStun.value);
pc.ondatachannel = (e) => setupReceiver(e.channel);
pc.onconnectionstatechange = () => {
if (['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
if (step.value !== 'done') step.value = 'closed';
}
};
step.value = 'creating-answer';
try {
answerCode.value = await createAnswerCode(pc, offerInput.value);
fps.value = getFingerprints(pc);
step.value = 'answer-ready';
} catch {
step.value = 'idle';
error.value = 'Invalid offer code — paste the full code from the sender.';
}
}
function setupReceiver(dc) {
dc.binaryType = 'arraybuffer';
let meta = null;
let chunks = [];
let received = 0;
dc.onopen = () => {
if (step.value === 'answer-ready') step.value = 'connecting';
};
dc.onmessage = (e) => {
if (typeof e.data === 'string') {
// Metadata frame
meta = JSON.parse(e.data);
fileName.value = meta.name;
fileSize.value = meta.size;
received = 0;
if (meta.size === 0) finish(meta, []);
else step.value = 'receiving';
return;
}
chunks.push(e.data);
received += e.data.byteLength;
if (meta) {
progress.value = Math.min(100, Math.floor((received / meta.size) * 100));
if (received >= meta.size) finish(meta, chunks);
}
};
}
function finish(meta, chunks) {
if (downloadUrl.value) URL.revokeObjectURL(downloadUrl.value);
const blob = new Blob(chunks, { type: meta.type || 'application/octet-stream' });
downloadUrl.value = URL.createObjectURL(blob);
progress.value = 100;
step.value = 'done';
}
async function copy(text) {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
</script>
<template>
<div class="panel">
<label class="step">1 — Choose a STUN provider</label>
<select v-model="stunChoice" class="stun-select">
<option v-for="p in STUN_PROVIDERS" :key="p.id" :value="p.id">
{{ p.label }}
</option>
<option value="custom">Custom…</option>
</select>
<input
v-if="stunChoice === 'custom'"
v-model="customStun"
class="stun-input"
type="text"
placeholder="stun:your.domain:3478"
/>
<p class="status stun-hint">
STUN is only needed for peers on different networks — a stateless UDP
lookup of your public IP; it never sees the connection or the file.
Same-network or VPN peers work with "None".
</p>
<label class="step">2 — Paste the sender's connection code</label>
<textarea
v-model="offerInput"
placeholder="Paste the code from the sender…"
/>
<button class="primary" :disabled="!offerInput.trim()" @click="acceptOffer">
Accept & create answer
</button>
<template v-if="answerCode">
<label class="step">3 — Send this answer code back to the sender</label>
<textarea :value="answerCode" readonly @focus="$event.target.select()" />
<button @click="copy(answerCode)">{{ copied ? 'Copied!' : 'Copy code' }}</button>
</template>
<div class="progress" v-if="step === 'receiving' || step === 'done'">
<div :style="{ width: progress + '%' }"></div>
</div>
<div v-if="fps" class="fingerprint">
<label class="step">Security check</label>
<div class="fp-row">
<span>Your code</span>
<code>{{ fps.local }}</code>
</div>
<div class="fp-row">
<span>Their code</span>
<code>{{ fps.remote }}</code>
</div>
<p class="status">
To rule out an intercepted exchange, compare with your peer over a
channel you trust: your "Your code" must equal their "Their code",
and vice versa.
</p>
</div>
<p class="status">{{ statusText }}</p>
<p class="error" v-if="error">{{ error }}</p>
<div v-if="downloadUrl" class="file-chip">
<span>{{ fileName }}</span>
<a class="download" :href="downloadUrl" :download="fileName">Save file</a>
</div>
</div>
</template>
+248
View File
@@ -0,0 +1,248 @@
<script setup>
import { ref, computed } from 'vue';
import {
createPeerConnection,
createOfferCode,
acceptAnswerCode,
getFingerprints,
STUN_PROVIDERS,
CHUNK_SIZE,
MAX_BUFFERED,
waitForBufferDrain,
} from '../lib/webrtc';
const STATUS = {
idle: 'Pick a file to start.',
'creating-offer': 'Gathering connection info…',
'offer-ready': 'Send the code below to the receiver (any chat works).',
connecting: 'Answer accepted — waiting for the P2P connection…',
sending: 'Transferring…',
done: 'Transfer complete!',
closed: 'Connection closed.',
};
const file = ref(null);
const step = ref('idle');
const offerCode = ref('');
const answerInput = ref('');
const progress = ref(0);
const error = ref('');
const copied = ref(false);
const dragover = ref(false);
const fps = ref(null);
const stunChoice = ref('google');
const customStun = ref('');
let pc = null;
let dc = null;
const statusText = computed(() => STATUS[step.value] ?? '');
const resolvedStun = computed(() => {
if (stunChoice.value === 'custom') {
const raw = customStun.value.trim();
return raw ? raw : null;
}
return STUN_PROVIDERS.find((p) => p.id === stunChoice.value)?.url ?? null;
});
function reset() {
pc?.close();
pc = null;
dc = null;
offerCode.value = '';
answerInput.value = '';
progress.value = 0;
error.value = '';
copied.value = false;
fps.value = null;
step.value = 'idle';
}
function pickFiles(e) {
const f = e.target?.files?.[0] ?? e.dataTransfer?.files?.[0];
if (f) {
reset();
file.value = f;
}
}
async function makeOffer() {
if (!file.value) return;
error.value = '';
step.value = 'creating-offer';
pc = createPeerConnection(resolvedStun.value);
pc.onconnectionstatechange = () => {
if (pc && ['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
step.value = 'closed';
}
};
dc = pc.createDataChannel('file');
dc.binaryType = 'arraybuffer';
dc.onopen = () => sendFile();
dc.onclose = () => {
if (step.value === 'sending' && progress.value < 100) step.value = 'closed';
};
try {
offerCode.value = await createOfferCode(pc);
step.value = 'offer-ready';
} catch (err) {
error.value = String(err);
step.value = 'idle';
}
}
async function useAnswer() {
if (!pc) return;
error.value = '';
try {
await acceptAnswerCode(pc, answerInput.value);
fps.value = getFingerprints(pc);
step.value = 'connecting';
} catch {
error.value = 'Invalid answer code — paste the full code from the receiver.';
}
}
async function sendFile() {
const f = file.value;
if (!dc || !f) return;
step.value = 'sending';
progress.value = 0;
// First frame: metadata as JSON. Everything after is raw bytes.
dc.send(JSON.stringify({ name: f.name, size: f.size, type: f.type }));
let sent = 0;
try {
for (let offset = 0; offset < f.size; offset += CHUNK_SIZE) {
if (dc.readyState !== 'open') break;
if (dc.bufferedAmount > MAX_BUFFERED) await waitForBufferDrain(dc);
const buf = await f.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
dc.send(buf);
sent += buf.byteLength;
progress.value = Math.floor((sent / Math.max(f.size, 1)) * 100);
}
if (dc.readyState === 'open') {
progress.value = 100;
step.value = 'done';
}
} catch (err) {
error.value = String(err);
step.value = 'closed';
}
}
async function copy(text) {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
function formatBytes(n) {
if (n < 1024) return n + ' B';
const u = ['KiB', 'MiB', 'GiB'];
let v = n;
let i = -1;
do {
v /= 1024;
i++;
} while (v >= 1024 && i < u.length - 1);
return v.toFixed(1) + ' ' + u[i];
}
</script>
<template>
<div class="panel">
<label class="step">1 — Choose a file</label>
<div
class="dropzone"
:class="{ dragover }"
@dragover.prevent="dragover = true"
@dragleave="dragover = false"
@drop.prevent="dragover = false; pickFiles($event)"
@click="$refs.input.click()"
>
<span v-if="!file">Drop a file here or click to browse</span>
<div v-else class="file-chip" @click.stop>
<span>{{ file.name }}</span>
<span class="status">{{ formatBytes(file.size) }}</span>
</div>
<input
ref="input"
type="file"
style="display: none"
@change="pickFiles"
/>
</div>
<template v-if="file">
<label class="step">2 — Choose a STUN provider</label>
<select v-model="stunChoice" class="stun-select">
<option v-for="p in STUN_PROVIDERS" :key="p.id" :value="p.id">
{{ p.label }}
</option>
<option value="custom">Custom…</option>
</select>
<input
v-if="stunChoice === 'custom'"
v-model="customStun"
class="stun-input"
type="text"
placeholder="stun:your.domain:3478"
/>
<p class="status stun-hint">
STUN is only needed for peers on different networks — a stateless UDP
lookup of your public IP; it never sees the connection or the file.
Same-network or VPN peers work with "None".
</p>
<label class="step">3 — Create your connection code</label>
<button class="primary" :disabled="step !== 'idle'" @click="makeOffer">
Create code
</button>
<template v-if="offerCode">
<textarea :value="offerCode" readonly @focus="$event.target.select()" />
<button @click="copy(offerCode)">{{ copied ? 'Copied!' : 'Copy code' }}</button>
</template>
<template v-if="offerCode">
<label class="step">4 — Paste the receiver's answer code</label>
<textarea
v-model="answerInput"
placeholder="Paste the code the receiver sends back…"
/>
<button class="primary" :disabled="!answerInput.trim()" @click="useAnswer">
Connect
</button>
</template>
<div class="progress" v-if="step === 'sending' || step === 'done'">
<div :style="{ width: progress + '%' }"></div>
</div>
<div v-if="fps" class="fingerprint">
<label class="step">Security check</label>
<div class="fp-row">
<span>Your code</span>
<code>{{ fps.local }}</code>
</div>
<div class="fp-row">
<span>Their code</span>
<code>{{ fps.remote }}</code>
</div>
<p class="status">
To rule out an intercepted exchange, compare with your peer over a
channel you trust: your "Your code" must equal their "Their code",
and vice versa.
</p>
</div>
<p class="status">{{ statusText }}</p>
<p class="error" v-if="error">{{ error }}</p>
</template>
</div>
</template>
+98
View File
@@ -0,0 +1,98 @@
// P2P with manual signaling: no server, no third party.
// Peers exchange SDP blobs by copy/paste. ICE gathering runs to completion
// so the full connection info fits in a single copyable string.
// Chunking / buffering for the data channel.
export const CHUNK_SIZE = 16 * 1024; // 16 KiB per message
export const MAX_BUFFERED = 1024 * 1024; // backpressure limit (bytes)
// Built-in public STUN providers. STUN is a stateless, unencrypted UDP
// lookup of your public IP — the server never sees connection or file data.
export const STUN_PROVIDERS = [
{ id: 'google', label: 'Google', url: 'stun:stun.l.google.com:19302' },
{ id: 'cloudflare', label: 'Cloudflare', url: 'stun:stun.cloudflare.com:3478' },
{ id: 'none', label: 'None (same-network / VPN peers only)', url: null },
];
// stunUrl: a URL from STUN_PROVIDERS, or a user-supplied "stun:host:port"
// string, or null for host candidates only.
export function createPeerConnection(stunUrl = null) {
return new RTCPeerConnection({
iceServers: stunUrl ? [{ urls: stunUrl }] : [],
});
}
function waitForIceComplete(pc) {
if (pc.iceGatheringState === 'complete') return Promise.resolve();
return new Promise((resolve, reject) => {
const onChange = () => {
if (pc.iceGatheringState === 'complete') {
pc.removeEventListener('icegatheringstatechange', onChange);
resolve();
}
};
pc.addEventListener('icegatheringstatechange', onChange);
setTimeout(() => {
pc.removeEventListener('icegatheringstatechange', onChange);
// Resolve anyway — partial candidate lists often still connect fine.
resolve();
}, 5000);
});
}
// Sender side: create an offer and return it as a copyable code.
export async function createOfferCode(pc) {
await pc.setLocalDescription(await pc.createOffer());
await waitForIceComplete(pc);
return encodeSignal(pc.localDescription.sdp);
}
// Receiver side: accept an offer code, return the answer code to send back.
export async function createAnswerCode(pc, offerText) {
await pc.setRemoteDescription({ type: 'offer', sdp: decodeSignal(offerText) });
await pc.setLocalDescription(await pc.createAnswer());
await waitForIceComplete(pc);
return encodeSignal(pc.localDescription.sdp);
}
// Sender side: apply the answer code the receiver sent back.
export function acceptAnswerCode(pc, answerText) {
return pc.setRemoteDescription({ type: 'answer', sdp: decodeSignal(answerText) });
}
// Anti-MITM check: extracts the DTLS certificate fingerprints from both
// descriptions and shortens them for human comparison.
// Rule: my "local" must equal the peer's "remote" (and vice versa).
// If both match, nobody interposed on the code exchange.
export function getFingerprints(pc) {
const extract = (sdp) => {
const m = sdp?.match(/a=fingerprint:(?:sha-256|sha-384|sha-512) ([0-9A-Fa-f:]+)/);
return m ? m[1].toUpperCase() : null;
};
// The fingerprint is already a certificate hash — its first 5 bytes
// (40 bits) are plenty for two humans to read to each other.
const shorten = (fp) => (fp ? fp.split(':').slice(0, 5).join(' ') : '—');
return {
local: shorten(extract(pc.localDescription?.sdp)),
remote: shorten(extract(pc.remoteDescription?.sdp)),
};
}
// SDP is ASCII plus CRLFs, so plain base64 is safe and compact enough (~2-4 KB).
export function encodeSignal(sdp) {
return btoa(sdp).replace(/=+$/, '');
}
export function decodeSignal(text) {
const clean = String(text).trim().replace(/\s+/g, '');
const padded = clean + '='.repeat((4 - (clean.length % 4)) % 4);
return atob(padded);
}
// Waits until the channel's send buffer drains below MAX_BUFFERED.
export function waitForBufferDrain(dc) {
dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
return new Promise((resolve) => {
dc.addEventListener('bufferedamountlow', resolve, { once: true });
});
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
+241
View File
@@ -0,0 +1,241 @@
:root {
color-scheme: dark;
--bg: #0f1117;
--panel: #181b24;
--border: #2a2f3d;
--accent: #e8452c;
--accent-soft: rgba(232, 69, 44, 0.15);
--text: #e8eaf0;
--muted: #8b91a3;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding-inline: 0.9rem;
}
.panel {
width: 100%;
max-width: 680px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
h1 {
margin: 2rem 0 0.25rem;
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.subtitle {
color: var(--muted);
margin: 0 0 1.5rem;
font-size: 0.9rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabs button {
flex: 1;
}
button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.95rem;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
.tabs button.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
label.step {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
textarea {
width: 100%;
min-height: 90px;
resize: vertical;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
word-break: break-all;
}
.dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 2rem;
text-align: center;
color: var(--muted);
cursor: pointer;
}
.dropzone.dragover {
border-color: var(--accent);
background: var(--accent-soft);
}
.progress {
height: 10px;
background: var(--bg);
border-radius: 6px;
overflow: hidden;
}
.progress > div {
height: 100%;
background: var(--accent);
transition: width 0.15s linear;
}
.status {
color: var(--muted);
font-size: 0.85rem;
}
.error {
color: #ff6b5e;
font-size: 0.85rem;
}
.file-chip {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
}
a.download {
color: var(--accent);
font-weight: 600;
}
.fingerprint {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.fp-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
}
.fp-row code {
font-family: ui-monospace, monospace;
color: var(--accent);
letter-spacing: 0.12em;
}
.fingerprint .status {
margin: 0.2rem 0 0;
}
footer {
max-width: 680px;
width: 100%;
margin: 1.5rem 0 2.5rem;
padding: 0 0.25rem;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.55;
}
footer p {
margin: 0;
}
.stun-select,
.stun-input {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
}
.stun-input {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.stun-select:focus,
.stun-input:focus {
outline: none;
border-color: var(--accent);
}
p.stun-hint {
margin: -0.35rem 0 0;
font-size: 0.78rem;
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
// base './' so the built app can be served from any static path (or opened from a subdirectory)
export default defineConfig({
base: './',
plugins: [vue()],
});