Send a file straight to another browser at /Send
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-09-20 14:18 UTC
- commit
- dbdcaffffe6433e1ef6da6b3075aa42554a98920
- parent
- 0882c1477a
- tree
- browse at this commit
8 files changed +1032 -2
| Blog/Components/Layout/SiteFooter.razor | +1 -1 | |
| Blog/Components/Pages/Send.razor | added | +79 -0 |
| Blog/Components/Pages/Send.razor.js | added | +519 -0 |
| Blog/Program.cs | +26 -0 | |
| Blog/Services/SignalingService.cs | added | +300 -0 |
| Blog/appsettings.json | +3 -0 | |
| Blog/wwwroot/app.css | +67 -0 | |
| CLAUDE.md | +37 -1 |
Blog/Components/Layout/SiteFooter.razor +1 -1
| @@ -12,9 +12,9 @@ | ||
| 12 | 12 | <li><NavLink href="/Query">Query</NavLink></li> |
| 13 | 13 | <li><NavLink href="/Storage">Storage</NavLink></li> |
| 14 | 14 | <li><NavLink href="/Warframe">Warframe drops</NavLink></li> |
| 15 | + <li><NavLink href="/Send">Send (peer-to-peer file transfer)</NavLink></li> | |
| 15 | 16 | @* <li><NavLink href="/rvrb">rvrb bot stats</NavLink></li> *@ |
| 16 | 17 | <li><NavLink href="/git">Git repositories</NavLink></li> |
| 17 | - <!-- <li><a href="/webrtc.html">WebRTC</a></li> --> | |
| 18 | 18 | <!-- <li><a href="/spotify/index.html">Spotify</a></li> --> |
| 19 | 19 | </ul> |
| 20 | 20 | </footer> |
Blog/Components/Pages/Send.razor +79 -0
| @@ -0,0 +1,79 @@ | ||
| 1 | + "/Send" | |
| 2 | + System.Text.Json | |
| 3 | + Blog.Services | |
| 4 | + Microsoft.Extensions.Options | |
| 5 | + IOptions<SendOptions> Options | |
| 6 | + | |
| 7 | +<PageTitle>Send</PageTitle> | |
| 8 | +<script type="module" src="@Assets["Components/Pages/Send.razor.js"]"></script> | |
| 9 | + | |
| 10 | +<main> | |
| 11 | + <h1>Send</h1> | |
| 12 | + | |
| 13 | + <p> | |
| 14 | + Send a file to another browser. Open this page in both, pair them with the link or the | |
| 15 | + code, and the file goes straight from one to the other. | |
| 16 | + </p> | |
| 17 | + | |
| 18 | + @* The ICE servers are configuration, so the page carries them rather than the script | |
| 19 | + hard-coding them. A data attribute rather than a <script type="application/json"> block: | |
| 20 | + Razor HTML-encodes element content, and attribute encoding round-trips through JSON.parse. *@ | |
| 21 | + <div id="ice" data-servers="@IceServers" hidden></div> | |
| 22 | + | |
| 23 | + <Panel Legend="Connection"> | |
| 24 | + <p id="state">Starting…</p> | |
| 25 | + | |
| 26 | + @* Put away once the room has its two browsers: the link is no use to a third. *@ | |
| 27 | + <div id="pairing"> | |
| 28 | + <div class="flex-row"> | |
| 29 | + <label for="link">Link</label> | |
| 30 | + <input type="text" id="link" readonly/> | |
| 31 | + <button type="button" id="copy">Copy</button> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <p>…or read out the code: <strong id="code"></strong></p> | |
| 35 | + | |
| 36 | + <div id="qrcode"></div> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <form id="joinForm" class="flex-row"> | |
| 40 | + <label for="joinCode">Been given a code?</label> | |
| 41 | + <input type="text" id="joinCode" placeholder="ABCD-EFGH-JKMN" autocomplete="off" | |
| 42 | + spellcheck="false"/> | |
| 43 | + <button type="submit">Join</button> | |
| 44 | + </form> | |
| 45 | + </Panel> | |
| 46 | + | |
| 47 | + <Panel Legend="Sending"> | |
| 48 | + <div id="drop" class="dropzone"> | |
| 49 | + <input type="file" id="files" multiple disabled/> | |
| 50 | + <p>…or drop files here.</p> | |
| 51 | + </div> | |
| 52 | + <ul id="outgoing" class="transfers"></ul> | |
| 53 | + </Panel> | |
| 54 | + | |
| 55 | + <Panel Legend="Received"> | |
| 56 | + <ul id="incoming" class="transfers"></ul> | |
| 57 | + <p id="nothingYet">Nothing yet.</p> | |
| 58 | + </Panel> | |
| 59 | + | |
| 60 | + <Panel Legend="How this works"> | |
| 61 | + <p> | |
| 62 | + <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API" | |
| 63 | + rel="external noreferrer">WebRTC</a> is the browser's own way of opening a | |
| 64 | + connection straight to another browser. The two ends swap what they know about how | |
| 65 | + to reach each other, settle on a route, and from then on talk directly — on a shared | |
| 66 | + network, over the local one. The file is read off disk in chunks and written down | |
| 67 | + that connection. | |
| 68 | + </p> | |
| 69 | + </Panel> | |
| 70 | + | |
| 71 | + <Log/> | |
| 72 | +</main> | |
| 73 | + | |
| 74 | + { | |
| 75 | + | |
| 76 | + /// <summary>The configured STUN/TURN URLs, as a JSON array for <c>RTCPeerConnection</c>.</summary> | |
| 77 | + private string IceServers => JsonSerializer.Serialize(Options.Value.IceServers); | |
| 78 | + | |
| 79 | +} | |
Blog/Components/Pages/Send.razor.js +519 -0
| @@ -0,0 +1,519 @@ | ||
| 1 | +// /Send — a file from one browser to another, over a WebRTC data channel. | |
| 2 | +// | |
| 3 | +// The site's part is the introduction only: a WebSocket at /api/send/{code} carries the offer, | |
| 4 | +// the answer and the ICE candidates between the two browsers holding the same code. Once the data | |
| 5 | +// channel opens, the bytes go peer to peer and this page stops talking to the server. Closing the | |
| 6 | +// signalling socket after that would not interrupt a transfer. | |
| 7 | + | |
| 8 | +import {getById, h, writeDebug, writeError, writeInfo} from "/common.module.js"; | |
| 9 | +import {QRCode} from "/qrcode.js"; | |
| 10 | + | |
| 11 | +// Crockford base32: no I, L, O or U, so a code survives being read out loud or written down. | |
| 12 | +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; | |
| 13 | +const CODE_LENGTH = 12; | |
| 14 | +const CODE_PATTERN = /^[0-9A-HJKMNP-TV-Z]{12}$/; | |
| 15 | + | |
| 16 | +// 64 KiB is inside every current browser's SCTP limit, and pc.sctp trims it further where a peer | |
| 17 | +// asks for less. Smaller chunks cost a round through the event loop each; larger ones buy little. | |
| 18 | +const CHUNK = 64 * 1024; | |
| 19 | + | |
| 20 | +// Hand the channel a few megabytes of work at a time and then wait. Without this the whole file | |
| 21 | +// is queued in the first tick, which costs the tab the file's worth of memory and gives the | |
| 22 | +// progress bar nothing to say. | |
| 23 | +const HIGH_WATER = 8 * 1024 * 1024; | |
| 24 | +const LOW_WATER = 1 * 1024 * 1024; | |
| 25 | + | |
| 26 | +const state = getById("state"); | |
| 27 | +const pairing = getById("pairing"); | |
| 28 | +const linkField = getById("link"); | |
| 29 | +const codeText = getById("code"); | |
| 30 | +const copyButton = getById("copy"); | |
| 31 | +const joinForm = getById("joinForm"); | |
| 32 | +const joinInput = getById("joinCode"); | |
| 33 | +const fileInput = getById("files"); | |
| 34 | +const dropZone = getById("drop"); | |
| 35 | +const outgoing = getById("outgoing"); | |
| 36 | +const incoming = getById("incoming"); | |
| 37 | +const nothingYet = getById("nothingYet"); | |
| 38 | + | |
| 39 | +/** @type {RTCPeerConnection|null} */ | |
| 40 | +let connection = null; | |
| 41 | +/** @type {RTCDataChannel|null} */ | |
| 42 | +let channel = null; | |
| 43 | + | |
| 44 | +/** Set once the server has said why this socket is over, so the close event stays quiet. */ | |
| 45 | +let signallingEnded = false; | |
| 46 | + | |
| 47 | +/** Whether this connection ever came up, which is the difference between "no route" and "lost". */ | |
| 48 | +let everConnected = false; | |
| 49 | + | |
| 50 | +/** Candidates that arrived before the description they belong to; added once it lands. */ | |
| 51 | +let earlyCandidates = []; | |
| 52 | + | |
| 53 | +/** The file currently arriving, if any. Only one is ever in flight in each direction. */ | |
| 54 | +let arriving = null; | |
| 55 | + | |
| 56 | +/** Outgoing files go one at a time, because the receiver's side of the protocol assumes it. */ | |
| 57 | +let queue = Promise.resolve(); | |
| 58 | + | |
| 59 | + | |
| 60 | +/* -- The room ------------------------------------------------------------- */ | |
| 61 | + | |
| 62 | +function newCode() { | |
| 63 | + const bytes = new Uint8Array(CODE_LENGTH); | |
| 64 | + crypto.getRandomValues(bytes); | |
| 65 | + // 5 bits per character out of 8: the other 3 are thrown away rather than folded in, which | |
| 66 | + // keeps every character uniform over the alphabet. | |
| 67 | + return Array.from(bytes, byte => ALPHABET[byte & 31]).join(""); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** Put a typed or pasted code back into the shape the alphabet uses. */ | |
| 71 | +function normalise(text) { | |
| 72 | + return text.toUpperCase().replace(/[^0-9A-Z]/g, "") | |
| 73 | + .replace(/[IL]/g, "1") | |
| 74 | + .replace(/O/g, "0"); | |
| 75 | +} | |
| 76 | + | |
| 77 | +function group(code) { | |
| 78 | + return code.replace(/(.{4})(?=.)/g, "$1-"); | |
| 79 | +} | |
| 80 | + | |
| 81 | +const fromHash = normalise(location.hash.slice(1)); | |
| 82 | +const room = CODE_PATTERN.test(fromHash) ? fromHash : newCode(); | |
| 83 | + | |
| 84 | +const shareUrl = new URL(location.href); | |
| 85 | +shareUrl.hash = room; | |
| 86 | + | |
| 87 | +// replaceState rather than assigning to location.hash: the code belongs in the address bar so the | |
| 88 | +// link can be copied out of it, but arriving here should not leave a history entry behind. The | |
| 89 | +// whole URL, not a bare "#code": App.razor sets <base href="/">, and replaceState resolves a | |
| 90 | +// relative URL against that, which would quietly move this page to the site root. | |
| 91 | +if (room !== fromHash) history.replaceState(null, "", shareUrl.href); | |
| 92 | + | |
| 93 | +linkField.value = shareUrl.href; | |
| 94 | +codeText.textContent = group(room); | |
| 95 | +new QRCode("qrcode", shareUrl.href); | |
| 96 | + | |
| 97 | +copyButton.addEventListener("click", async () => { | |
| 98 | + try { | |
| 99 | + await navigator.clipboard.writeText(shareUrl.href); | |
| 100 | + writeInfo("Link copied."); | |
| 101 | + } catch { | |
| 102 | + // No clipboard permission, or an insecure origin. Select it so ctrl-C still works. | |
| 103 | + linkField.select(); | |
| 104 | + writeError("Could not copy — the link is selected instead."); | |
| 105 | + } | |
| 106 | +}); | |
| 107 | + | |
| 108 | +joinForm.addEventListener("submit", event => { | |
| 109 | + event.preventDefault(); | |
| 110 | + const code = normalise(joinInput.value); | |
| 111 | + | |
| 112 | + if (!CODE_PATTERN.test(code)) { | |
| 113 | + writeError("That is not a code — twelve letters and digits, no I, L, O or U."); | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + if (code === room) return; | |
| 117 | + | |
| 118 | + location.hash = code; | |
| 119 | +}); | |
| 120 | + | |
| 121 | +// Someone already on this page who follows a link to another room, or types a code above, only | |
| 122 | +// changes the fragment — the browser does not reload for that, and this module body *is* the | |
| 123 | +// page's setup, so without this the page would sit in the room it opened with. A whole load | |
| 124 | +// rather than rewiring the connection in place, which is what every other page here relies on | |
| 125 | +// too. replaceState above does not fire this. | |
| 126 | +addEventListener("hashchange", () => location.reload()); | |
| 127 | + | |
| 128 | + | |
| 129 | +/* -- Signalling ----------------------------------------------------------- */ | |
| 130 | + | |
| 131 | +if (!window.isSecureContext || !("RTCPeerConnection" in window)) { | |
| 132 | + setState("This browser will not do WebRTC here — it needs https (or localhost)."); | |
| 133 | + throw new Error("WebRTC unavailable"); | |
| 134 | +} | |
| 135 | + | |
| 136 | +const iceUrls = JSON.parse(getById("ice").dataset.servers); | |
| 137 | +const rtcConfig = {iceServers: iceUrls.length > 0 ? [{urls: iceUrls}] : []}; | |
| 138 | + | |
| 139 | +const socket = new WebSocket(`${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/api/send/${room}`); | |
| 140 | + | |
| 141 | +socket.addEventListener("open", () => writeDebug("Signalling socket open.")); | |
| 142 | +socket.addEventListener("error", () => writeError("The signalling socket failed.")); | |
| 143 | +socket.addEventListener("close", () => { | |
| 144 | + writeDebug("Signalling socket closed."); | |
| 145 | + // Only worth reporting while it still had a job to do, and only when the server has not | |
| 146 | + // already said something better than "it closed" — the close follows that message. | |
| 147 | + if (!signallingEnded && channel?.readyState !== "open") { | |
| 148 | + setState("Lost the signalling socket. Reload to try again."); | |
| 149 | + } | |
| 150 | +}); | |
| 151 | + | |
| 152 | +// One at a time, in the order they arrived. Handling an offer is several awaits long, and a | |
| 153 | +// candidate that overtook it would be added against a connection that is still half-described. | |
| 154 | +let handling = Promise.resolve(); | |
| 155 | + | |
| 156 | +socket.addEventListener("message", event => { | |
| 157 | + let message; | |
| 158 | + try { | |
| 159 | + message = JSON.parse(event.data); | |
| 160 | + } catch { | |
| 161 | + writeError("Unreadable signalling message."); | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + | |
| 165 | + handling = handling.then(() => onSignal(message)).catch(error => writeError(error)); | |
| 166 | +}); | |
| 167 | + | |
| 168 | +async function onSignal(message) { | |
| 169 | + switch (message.type) { | |
| 170 | + case "waiting": | |
| 171 | + setState("Waiting for the other side — send them the link."); | |
| 172 | + break; | |
| 173 | + | |
| 174 | + case "ready": | |
| 175 | + setState("Other side found; connecting…"); | |
| 176 | + await connect(message.initiator); | |
| 177 | + break; | |
| 178 | + | |
| 179 | + case "peer-left": | |
| 180 | + if (channel?.readyState === "open") { | |
| 181 | + // The peer connection stands on its own once it is up; only the introduction ended. | |
| 182 | + writeInfo("The other side's signalling link dropped. The transfer link is still up."); | |
| 183 | + } else { | |
| 184 | + teardown(); | |
| 185 | + setState("The other side left. Waiting for someone to join."); | |
| 186 | + } | |
| 187 | + break; | |
| 188 | + | |
| 189 | + case "busy": | |
| 190 | + signallingEnded = true; | |
| 191 | + setState("That room already has two browsers in it. Start a fresh one from /Send."); | |
| 192 | + break; | |
| 193 | + | |
| 194 | + case "offer": | |
| 195 | + await connect(false); | |
| 196 | + await connection.setRemoteDescription(message.sdp); | |
| 197 | + await connection.setLocalDescription(await connection.createAnswer()); | |
| 198 | + signal({type: "answer", sdp: connection.localDescription}); | |
| 199 | + await flushCandidates(); | |
| 200 | + break; | |
| 201 | + | |
| 202 | + case "answer": | |
| 203 | + await connection.setRemoteDescription(message.sdp); | |
| 204 | + await flushCandidates(); | |
| 205 | + break; | |
| 206 | + | |
| 207 | + case "ice": | |
| 208 | + // A candidate can outrun the description it belongs to; hold it until there is one. | |
| 209 | + if (connection?.remoteDescription) await connection.addIceCandidate(message.candidate); | |
| 210 | + else earlyCandidates.push(message.candidate); | |
| 211 | + break; | |
| 212 | + | |
| 213 | + default: | |
| 214 | + writeDebug(`Ignoring signalling message: ${message.type}`); | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +function signal(message) { | |
| 219 | + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); | |
| 220 | +} | |
| 221 | + | |
| 222 | +async function flushCandidates() { | |
| 223 | + const candidates = earlyCandidates; | |
| 224 | + earlyCandidates = []; | |
| 225 | + for (const candidate of candidates) await connection.addIceCandidate(candidate); | |
| 226 | +} | |
| 227 | + | |
| 228 | + | |
| 229 | +/* -- The peer connection --------------------------------------------------- */ | |
| 230 | + | |
| 231 | +/** | |
| 232 | + * Build the peer connection, once. The browser that arrives second makes the offer, because it is | |
| 233 | + * the only one that knows both ends are present. | |
| 234 | + */ | |
| 235 | +async function connect(initiator) { | |
| 236 | + if (connection) return; | |
| 237 | + | |
| 238 | + connection = new RTCPeerConnection(rtcConfig); | |
| 239 | + | |
| 240 | + connection.addEventListener("icecandidate", event => { | |
| 241 | + if (event.candidate) signal({type: "ice", candidate: event.candidate}); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + connection.addEventListener("connectionstatechange", () => { | |
| 245 | + // Null once teardown has run: closing the connection queues one last event of its own. | |
| 246 | + const current = connection?.connectionState; | |
| 247 | + if (!current) return; | |
| 248 | + | |
| 249 | + writeDebug(`Connection: ${current}`); | |
| 250 | + | |
| 251 | + if (current === "connected") { | |
| 252 | + everConnected = true; | |
| 253 | + return; | |
| 254 | + } | |
| 255 | + | |
| 256 | + if (current !== "failed" && current !== "closed") return; | |
| 257 | + | |
| 258 | + // Take it all down rather than leaving a dead connection in place: the other side may | |
| 259 | + // still be holding the link, and a peer that comes back is a fresh negotiation that | |
| 260 | + // connect() would otherwise refuse to start. | |
| 261 | + const lost = everConnected; | |
| 262 | + teardown(); | |
| 263 | + setState(lost | |
| 264 | + ? "The connection dropped. Waiting for the other side to come back." | |
| 265 | + : "Could not find a route between the two browsers."); | |
| 266 | + }); | |
| 267 | + | |
| 268 | + // The answering side is handed the channel the offering side made. | |
| 269 | + connection.addEventListener("datachannel", event => attach(event.channel)); | |
| 270 | + | |
| 271 | + if (initiator) { | |
| 272 | + attach(connection.createDataChannel("files", {ordered: true})); | |
| 273 | + await connection.setLocalDescription(await connection.createOffer()); | |
| 274 | + signal({type: "offer", sdp: connection.localDescription}); | |
| 275 | + } | |
| 276 | +} | |
| 277 | + | |
| 278 | +function teardown() { | |
| 279 | + channel?.close(); | |
| 280 | + connection?.close(); | |
| 281 | + channel = null; | |
| 282 | + connection = null; | |
| 283 | + earlyCandidates = []; | |
| 284 | + arriving = null; | |
| 285 | + everConnected = false; | |
| 286 | + fileInput.disabled = true; | |
| 287 | + pairing.hidden = false; | |
| 288 | +} | |
| 289 | + | |
| 290 | +function attach(dataChannel) { | |
| 291 | + channel = dataChannel; | |
| 292 | + channel.binaryType = "arraybuffer"; | |
| 293 | + channel.bufferedAmountLowThreshold = LOW_WATER; | |
| 294 | + | |
| 295 | + channel.addEventListener("open", () => { | |
| 296 | + fileInput.disabled = false; | |
| 297 | + pairing.hidden = true; | |
| 298 | + setState("Connected. Pick a file."); | |
| 299 | + describeRoute(); | |
| 300 | + }); | |
| 301 | + | |
| 302 | + channel.addEventListener("close", () => { | |
| 303 | + fileInput.disabled = true; | |
| 304 | + pairing.hidden = false; | |
| 305 | + setState("The connection closed."); | |
| 306 | + }); | |
| 307 | + | |
| 308 | + channel.addEventListener("error", event => writeError(event.error ?? "Data channel error.")); | |
| 309 | + channel.addEventListener("message", onData); | |
| 310 | +} | |
| 311 | + | |
| 312 | +/** | |
| 313 | + * Say which route the two ends settled on, since "peer to peer" is the whole point of the page | |
| 314 | + * and a relayed connection would not be one. | |
| 315 | + */ | |
| 316 | +async function describeRoute() { | |
| 317 | + try { | |
| 318 | + const stats = await connection.getStats(); | |
| 319 | + let pair = null; | |
| 320 | + stats.forEach(report => { | |
| 321 | + if (report.type === "candidate-pair" && report.state === "succeeded") pair = report; | |
| 322 | + }); | |
| 323 | + if (!pair) return; | |
| 324 | + | |
| 325 | + const local = stats.get(pair.localCandidateId)?.candidateType; | |
| 326 | + const remote = stats.get(pair.remoteCandidateId)?.candidateType; | |
| 327 | + const relayed = local === "relay" || remote === "relay"; | |
| 328 | + writeInfo(`Route: ${local} to ${remote}${relayed ? " (relayed)" : " (direct)"}.`); | |
| 329 | + } catch (error) { | |
| 330 | + writeDebug(`No route stats: ${error}`); | |
| 331 | + } | |
| 332 | +} | |
| 333 | + | |
| 334 | + | |
| 335 | +/* -- Sending --------------------------------------------------------------- */ | |
| 336 | + | |
| 337 | +fileInput.addEventListener("change", () => { | |
| 338 | + enqueue(fileInput.files); | |
| 339 | + // Clear it, so picking the same file again is still a change event. | |
| 340 | + fileInput.value = ""; | |
| 341 | +}); | |
| 342 | + | |
| 343 | +for (const type of ["dragenter", "dragover"]) { | |
| 344 | + dropZone.addEventListener(type, event => { | |
| 345 | + event.preventDefault(); | |
| 346 | + dropZone.classList.add("over"); | |
| 347 | + }); | |
| 348 | +} | |
| 349 | + | |
| 350 | +for (const type of ["dragleave", "drop"]) { | |
| 351 | + dropZone.addEventListener(type, event => { | |
| 352 | + event.preventDefault(); | |
| 353 | + dropZone.classList.remove("over"); | |
| 354 | + }); | |
| 355 | +} | |
| 356 | + | |
| 357 | +dropZone.addEventListener("drop", event => { | |
| 358 | + if (channel?.readyState !== "open") { | |
| 359 | + writeError("Not connected yet."); | |
| 360 | + return; | |
| 361 | + } | |
| 362 | + if (event.dataTransfer?.files?.length) enqueue(event.dataTransfer.files); | |
| 363 | +}); | |
| 364 | + | |
| 365 | +function enqueue(files) { | |
| 366 | + for (const file of files) { | |
| 367 | + queue = queue.then(() => sendFile(file)).catch(error => writeError(error)); | |
| 368 | + } | |
| 369 | +} | |
| 370 | + | |
| 371 | +async function sendFile(file) { | |
| 372 | + if (channel?.readyState !== "open") { | |
| 373 | + writeError(`Not connected — ${file.name} was not sent.`); | |
| 374 | + return; | |
| 375 | + } | |
| 376 | + | |
| 377 | + const row = addRow(outgoing, file.name, file.size); | |
| 378 | + channel.send(JSON.stringify({kind: "start", name: file.name, size: file.size, mime: file.type})); | |
| 379 | + | |
| 380 | + // pc.sctp only exists once the transport is up, which it is by the time a channel is open. | |
| 381 | + const limit = Math.min(CHUNK, connection.sctp?.maxMessageSize || CHUNK); | |
| 382 | + | |
| 383 | + for (let offset = 0; offset < file.size; offset += limit) { | |
| 384 | + const chunk = await file.slice(offset, offset + limit).arrayBuffer(); | |
| 385 | + await drain(); | |
| 386 | + | |
| 387 | + if (channel.readyState !== "open") { | |
| 388 | + row.fail("interrupted"); | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + | |
| 392 | + channel.send(chunk); | |
| 393 | + // What the channel still holds has not gone anywhere yet, so take it back off the total. | |
| 394 | + row.progress(Math.max(0, offset + chunk.byteLength - channel.bufferedAmount)); | |
| 395 | + } | |
| 396 | + | |
| 397 | + channel.send(JSON.stringify({kind: "end"})); | |
| 398 | + row.done("sent"); | |
| 399 | +} | |
| 400 | + | |
| 401 | +/** Resolve once the channel has worked off its backlog, or given up. */ | |
| 402 | +function drain() { | |
| 403 | + if (channel.bufferedAmount < HIGH_WATER) return Promise.resolve(); | |
| 404 | + | |
| 405 | + return new Promise(resolve => { | |
| 406 | + const go = () => { | |
| 407 | + channel.removeEventListener("bufferedamountlow", go); | |
| 408 | + channel.removeEventListener("close", go); | |
| 409 | + resolve(); | |
| 410 | + }; | |
| 411 | + channel.addEventListener("bufferedamountlow", go); | |
| 412 | + // A channel that closes mid-file would otherwise leave this promise hanging, and the | |
| 413 | + // queue behind it with nothing to resolve it. | |
| 414 | + channel.addEventListener("close", go); | |
| 415 | + }); | |
| 416 | +} | |
| 417 | + | |
| 418 | + | |
| 419 | +/* -- Receiving ------------------------------------------------------------- */ | |
| 420 | + | |
| 421 | +function onData(event) { | |
| 422 | + if (typeof event.data === "string") { | |
| 423 | + onControl(JSON.parse(event.data)); | |
| 424 | + return; | |
| 425 | + } | |
| 426 | + | |
| 427 | + if (!arriving) { | |
| 428 | + writeError("A chunk arrived with no file to put it in."); | |
| 429 | + return; | |
| 430 | + } | |
| 431 | + | |
| 432 | + arriving.parts.push(event.data); | |
| 433 | + arriving.received += event.data.byteLength; | |
| 434 | + arriving.row.progress(arriving.received); | |
| 435 | +} | |
| 436 | + | |
| 437 | +function onControl(message) { | |
| 438 | + if (message.kind === "start") { | |
| 439 | + nothingYet.hidden = true; | |
| 440 | + arriving = { | |
| 441 | + name: message.name, | |
| 442 | + size: message.size, | |
| 443 | + mime: message.mime, | |
| 444 | + parts: [], | |
| 445 | + received: 0, | |
| 446 | + row: addRow(incoming, message.name, message.size), | |
| 447 | + }; | |
| 448 | + return; | |
| 449 | + } | |
| 450 | + | |
| 451 | + if (message.kind === "end") { | |
| 452 | + if (!arriving) return; | |
| 453 | + | |
| 454 | + if (arriving.received !== arriving.size) { | |
| 455 | + writeError(`${arriving.name} came to ${arriving.received} bytes, not ${arriving.size}.`); | |
| 456 | + } | |
| 457 | + | |
| 458 | + // Held in memory until it is saved: the parts become one Blob and the browser hands it | |
| 459 | + // over as a download. A file much larger than the tab can hold will not survive this. | |
| 460 | + const blob = new Blob(arriving.parts, {type: arriving.mime || "application/octet-stream"}); | |
| 461 | + arriving.row.finish(blob, arriving.name); | |
| 462 | + arriving = null; | |
| 463 | + } | |
| 464 | +} | |
| 465 | + | |
| 466 | + | |
| 467 | +/* -- Rows ------------------------------------------------------------------ */ | |
| 468 | + | |
| 469 | +/** A name, a size, a bar and a word, and the handful of things that change them. */ | |
| 470 | +function addRow(list, name, size) { | |
| 471 | + const label = h("span", {class: "transfer-name"}, name); | |
| 472 | + const bar = h("progress", {max: String(size || 1), value: "0"}); | |
| 473 | + const status = h("span", {class: "transfer-status"}, "0%"); | |
| 474 | + const row = h("li", {class: "transfer"}, [ | |
| 475 | + label, | |
| 476 | + h("span", {class: "transfer-size"}, formatSize(size)), | |
| 477 | + bar, | |
| 478 | + status, | |
| 479 | + ]); | |
| 480 | + | |
| 481 | + list.appendChild(row); | |
| 482 | + | |
| 483 | + return { | |
| 484 | + progress(done) { | |
| 485 | + bar.value = done; | |
| 486 | + status.textContent = size ? `${Math.floor(done / size * 100)}%` : "0%"; | |
| 487 | + }, | |
| 488 | + done(text) { | |
| 489 | + bar.value = bar.max; | |
| 490 | + status.textContent = text; | |
| 491 | + }, | |
| 492 | + finish(blob, filename) { | |
| 493 | + bar.value = bar.max; | |
| 494 | + status.textContent = "ready"; | |
| 495 | + row.replaceChild( | |
| 496 | + h("a", {class: "transfer-name", href: URL.createObjectURL(blob), download: filename}, name), | |
| 497 | + label); | |
| 498 | + }, | |
| 499 | + fail(text) { | |
| 500 | + status.textContent = text; | |
| 501 | + row.classList.add("failed"); | |
| 502 | + }, | |
| 503 | + }; | |
| 504 | +} | |
| 505 | + | |
| 506 | +function formatSize(bytes) { | |
| 507 | + const units = ["B", "kB", "MB", "GB", "TB"]; | |
| 508 | + let size = bytes; | |
| 509 | + let unit = 0; | |
| 510 | + while (size >= 1000 && unit < units.length - 1) { | |
| 511 | + size /= 1000; | |
| 512 | + unit++; | |
| 513 | + } | |
| 514 | + return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`; | |
| 515 | +} | |
| 516 | + | |
| 517 | +function setState(text) { | |
| 518 | + state.textContent = text; | |
| 519 | +} | |
Blog/Program.cs +26 -0
| @@ -28,6 +28,12 @@ builder.Services.AddSingleton<WarframeDropService>(); | ||
| 28 | 28 | builder.Services.Configure<GitOptions>(builder.Configuration.GetSection(GitOptions.Section)); |
| 29 | 29 | builder.Services.AddSingleton<GitService>(); |
| 30 | 30 | |
| 31 | +// /Send introduces two browsers to each other and then gets out of the way: the signalling | |
| 32 | +// socket below carries their WebRTC handshake, and the file goes straight from one to the other. | |
| 33 | +// Singleton because the rooms are the service - see SignalingService. | |
| 34 | +builder.Services.Configure<SendOptions>(builder.Configuration.GetSection(SendOptions.Section)); | |
| 35 | +builder.Services.AddSingleton<SignalingService>(); | |
| 36 | + | |
| 31 | 37 | // /rvrb reads the rvrb bot's stats straight off its BEAM, over Erlang distribution. The node this |
| 32 | 38 | // site dials with is started on the first request, not here - see RvrbService. |
| 33 | 39 | builder.Services.Configure<RvrbOptions>(builder.Configuration.GetSection(RvrbOptions.Section)); |
| @@ -48,6 +54,9 @@ if (!app.Environment.IsDevelopment()) | ||
| 48 | 54 | |
| 49 | 55 | app.UseHttpsRedirection(); |
| 50 | 56 | |
| 57 | +// /Send's signalling socket. Nothing else on this site uses WebSockets. | |
| 58 | +app.UseWebSockets(); | |
| 59 | + | |
| 51 | 60 | app.UseAntiforgery(); |
| 52 | 61 | |
| 53 | 62 | app.MapStaticAssets(); |
| @@ -82,4 +91,21 @@ app.MapGet("/api/warframe/names", async (string? q, WarframeDropService drops, C | ||
| 82 | 91 | return status.Tables?.Search(q ?? "", 10) ?? []; |
| 83 | 92 | }); |
| 84 | 93 | |
| 94 | +// The introduction for /Send: two browsers holding the same room code trade their WebRTC offer, | |
| 95 | +// answer and ICE candidates through here, and once the peer connection is up this socket has | |
| 96 | +// nothing left to carry. The file never passes through this process. | |
| 97 | +app.MapGet("/api/send/{room}", async ( | |
| 98 | + string room, | |
| 99 | + HttpContext context, | |
| 100 | + SignalingService signaling) => | |
| 101 | +{ | |
| 102 | + if (!context.WebSockets.IsWebSocketRequest) return Results.BadRequest("Expected a WebSocket request."); | |
| 103 | + if (!SignalingService.IsRoomCode(room)) return Results.BadRequest("Malformed room code."); | |
| 104 | + | |
| 105 | + using var socket = await context.WebSockets.AcceptWebSocketAsync(); | |
| 106 | + await signaling.RelayAsync(room, socket, context.RequestAborted); | |
| 107 | + | |
| 108 | + return Results.Empty; | |
| 109 | +}); | |
| 110 | + | |
| 85 | 111 | app.Run(); |
| No newline at end of file | ||
Blog/Services/SignalingService.cs +300 -0
| @@ -0,0 +1,300 @@ | ||
| 1 | +using System.Buffers; | |
| 2 | +using System.Collections.Concurrent; | |
| 3 | +using System.Net.WebSockets; | |
| 4 | +using System.Text; | |
| 5 | +using System.Text.RegularExpressions; | |
| 6 | +using Microsoft.Extensions.Options; | |
| 7 | + | |
| 8 | +namespace Blog.Services; | |
| 9 | + | |
| 10 | +public sealed class SendOptions | |
| 11 | +{ | |
| 12 | + public const string Section = "Send"; | |
| 13 | + | |
| 14 | + /// <summary> | |
| 15 | + /// The ICE servers the page hands to <c>RTCPeerConnection</c>, so two browsers behind NAT can | |
| 16 | + /// find a route to each other. STUN only: it tells a peer what its public address looks like | |
| 17 | + /// and then steps aside. A TURN server would <em>relay</em> the bytes, which is the one thing | |
| 18 | + /// this page is for not doing, so there is deliberately none — a pair of symmetric NATs with | |
| 19 | + /// no route between them fails instead of quietly going through a server. | |
| 20 | + /// </summary> | |
| 21 | + public string[] IceServers { get; set; } = ["stun:stun.l.google.com:19302"]; | |
| 22 | + | |
| 23 | + /// <summary>How many rooms may exist at once, before a new code is turned away.</summary> | |
| 24 | + public int MaxRooms { get; set; } = 500; | |
| 25 | + | |
| 26 | + /// <summary> | |
| 27 | + /// The largest signalling message that will be relayed. An SDP offer carrying a handful of | |
| 28 | + /// candidates is a few kilobytes; anything near this is not a handshake. | |
| 29 | + /// </summary> | |
| 30 | + public int MaxMessageBytes { get; set; } = 64 * 1024; | |
| 31 | + | |
| 32 | + /// <summary> | |
| 33 | + /// How long a signalling socket is kept open. Closing it does not interrupt a transfer: once | |
| 34 | + /// the peer connection is up the two browsers talk to each other directly, and this socket has | |
| 35 | + /// nothing left to carry. | |
| 36 | + /// </summary> | |
| 37 | + public TimeSpan RoomLifetime { get; set; } = TimeSpan.FromMinutes(30); | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// <summary> | |
| 41 | +/// The introduction service behind /Send: it carries the WebRTC handshake between the two browsers | |
| 42 | +/// in a room, and carries nothing else. The file goes peer to peer over a data channel that this | |
| 43 | +/// process is not part of and cannot see. | |
| 44 | +/// </summary> | |
| 45 | +/// <remarks> | |
| 46 | +/// <para> | |
| 47 | +/// The server never mints a room code and never stores one. The page generates twelve Crockford | |
| 48 | +/// base32 characters — sixty bits out of the browser's CSPRNG — and a room springs into existence | |
| 49 | +/// when the first socket asks for it and disappears when the last one leaves. So there is no | |
| 50 | +/// registry to expire, no state to persist, and nothing to clean up on a restart beyond dropping | |
| 51 | +/// the sockets. | |
| 52 | +/// </para> | |
| 53 | +/// <para> | |
| 54 | +/// A room holds at most two peers and a third is refused, so nobody can slip into a pair that has | |
| 55 | +/// already formed. What this does <em>not</em> give you is protection from the server itself: it | |
| 56 | +/// relays the DTLS fingerprints the two peers authenticate each other with, so whoever runs this | |
| 57 | +/// process could stand in the middle if they rewrote them. The page says so. | |
| 58 | +/// </para> | |
| 59 | +/// <para> | |
| 60 | +/// Messages are relayed verbatim as text. Nothing here parses SDP — the server has no opinion | |
| 61 | +/// about what the two ends agree on, which is also why a browser can change its mind about codecs | |
| 62 | +/// or restart ICE without this code learning a new message type. | |
| 63 | +/// </para> | |
| 64 | +/// </remarks> | |
| 65 | +public sealed partial class SignalingService(IOptions<SendOptions> options, ILogger<SignalingService> logger) | |
| 66 | +{ | |
| 67 | + private readonly SendOptions _options = options.Value; | |
| 68 | + private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.Ordinal); | |
| 69 | + | |
| 70 | + /// <summary> | |
| 71 | + /// The shape the page generates: Crockford base32, which drops I, L, O and U so a code read | |
| 72 | + /// out loud survives the trip. Codes are checked rather than parsed — the server only needs | |
| 73 | + /// them to be a bounded, boring dictionary key. | |
| 74 | + /// </summary> | |
| 75 | + | |
| 76 | + private static partial Regex RoomCode { get; } | |
| 77 | + | |
| 78 | + public static bool IsRoomCode(string code) => RoomCode.IsMatch(code); | |
| 79 | + | |
| 80 | + /// <summary> | |
| 81 | + /// Runs one peer's signalling socket to completion: join the room, relay until it goes away. | |
| 82 | + /// </summary> | |
| 83 | + public async Task RelayAsync(string code, WebSocket socket, CancellationToken cancellationToken) | |
| 84 | + { | |
| 85 | + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); | |
| 86 | + lifetime.CancelAfter(_options.RoomLifetime); | |
| 87 | + var token = lifetime.Token; | |
| 88 | + | |
| 89 | + var peer = new Peer(socket); | |
| 90 | + | |
| 91 | + if (Join(code, peer) is not { } room) | |
| 92 | + { | |
| 93 | + await peer.SendAsync("""{"type":"busy"}""", token).ConfigureAwait(false); | |
| 94 | + await CloseAsync(socket, WebSocketCloseStatus.PolicyViolation, "Room full", token).ConfigureAwait(false); | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + | |
| 98 | + try | |
| 99 | + { | |
| 100 | + await AnnounceAsync(room, token).ConfigureAwait(false); | |
| 101 | + await PumpAsync(room, peer, token).ConfigureAwait(false); | |
| 102 | + } | |
| 103 | + catch (OperationCanceledException) | |
| 104 | + { | |
| 105 | + // Either the request was aborted or the room hit its lifetime. Both are ordinary ends. | |
| 106 | + } | |
| 107 | + catch (WebSocketException exception) | |
| 108 | + { | |
| 109 | + logger.LogDebug(exception, "Send socket ended abruptly"); | |
| 110 | + } | |
| 111 | + finally | |
| 112 | + { | |
| 113 | + await LeaveAsync(code, room, peer).ConfigureAwait(false); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + /// <summary> | |
| 118 | + /// Puts the peer in the room named by the code, creating the room if it is the first one there. | |
| 119 | + /// Null when the room already has its two peers, or the server is holding as many rooms as it | |
| 120 | + /// is willing to. | |
| 121 | + /// </summary> | |
| 122 | + private Room? Join(string code, Peer peer) | |
| 123 | + { | |
| 124 | + while (true) | |
| 125 | + { | |
| 126 | + if (!_rooms.TryGetValue(code, out var room)) | |
| 127 | + { | |
| 128 | + if (_rooms.Count >= _options.MaxRooms) return null; | |
| 129 | + room = _rooms.GetOrAdd(code, _ => new Room()); | |
| 130 | + } | |
| 131 | + | |
| 132 | + lock (room.Gate) | |
| 133 | + { | |
| 134 | + // The last peer left between the lookup and the lock, taking the room out of the | |
| 135 | + // dictionary with it. Whatever is under that key now is what we want. | |
| 136 | + if (room.Removed) continue; | |
| 137 | + if (room.Peers.Count >= 2) return null; | |
| 138 | + | |
| 139 | + room.Peers.Add(peer); | |
| 140 | + return room; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// <summary> | |
| 146 | + /// Tells everyone in the room where they stand. Alone, you wait; in a pair, the one who just | |
| 147 | + /// arrived makes the offer, because it is the only one that knows both ends are present. | |
| 148 | + /// </summary> | |
| 149 | + private static async Task AnnounceAsync(Room room, CancellationToken cancellationToken) | |
| 150 | + { | |
| 151 | + Peer[] peers; | |
| 152 | + lock (room.Gate) peers = [.. room.Peers]; | |
| 153 | + | |
| 154 | + if (peers.Length == 1) | |
| 155 | + { | |
| 156 | + await peers[0].SendAsync("""{"type":"waiting"}""", cancellationToken).ConfigureAwait(false); | |
| 157 | + return; | |
| 158 | + } | |
| 159 | + | |
| 160 | + for (var i = 0; i < peers.Length; i++) | |
| 161 | + { | |
| 162 | + var initiator = i == peers.Length - 1 ? "true" : "false"; | |
| 163 | + await peers[i].SendAsync($$"""{"type":"ready","initiator":{{initiator}}}""", cancellationToken).ConfigureAwait(false); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + /// <summary>Reads this peer's messages and hands each one to the other peer, unread.</summary> | |
| 168 | + private async Task PumpAsync(Room room, Peer peer, CancellationToken cancellationToken) | |
| 169 | + { | |
| 170 | + var buffer = ArrayPool<byte>.Shared.Rent(8 * 1024); | |
| 171 | + var message = new ArrayBufferWriter<byte>(8 * 1024); | |
| 172 | + | |
| 173 | + try | |
| 174 | + { | |
| 175 | + while (peer.Socket.State == WebSocketState.Open) | |
| 176 | + { | |
| 177 | + var result = await peer.Socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); | |
| 178 | + | |
| 179 | + if (result.MessageType == WebSocketMessageType.Close) return; | |
| 180 | + | |
| 181 | + if (result.MessageType != WebSocketMessageType.Text) | |
| 182 | + { | |
| 183 | + await CloseAsync(peer.Socket, WebSocketCloseStatus.InvalidMessageType, "Text only", cancellationToken).ConfigureAwait(false); | |
| 184 | + return; | |
| 185 | + } | |
| 186 | + | |
| 187 | + message.Write(buffer.AsSpan(0, result.Count)); | |
| 188 | + | |
| 189 | + if (message.WrittenCount > _options.MaxMessageBytes) | |
| 190 | + { | |
| 191 | + await CloseAsync(peer.Socket, WebSocketCloseStatus.MessageTooBig, "Signalling message too large", cancellationToken).ConfigureAwait(false); | |
| 192 | + return; | |
| 193 | + } | |
| 194 | + | |
| 195 | + if (!result.EndOfMessage) continue; | |
| 196 | + | |
| 197 | + var text = Encoding.UTF8.GetString(message.WrittenSpan); | |
| 198 | + message.ResetWrittenCount(); | |
| 199 | + | |
| 200 | + Peer[] others; | |
| 201 | + lock (room.Gate) others = [.. room.Peers.Where(other => other != peer)]; | |
| 202 | + | |
| 203 | + foreach (var other in others) | |
| 204 | + { | |
| 205 | + await other.SendAsync(text, cancellationToken).ConfigureAwait(false); | |
| 206 | + } | |
| 207 | + } | |
| 208 | + } | |
| 209 | + finally | |
| 210 | + { | |
| 211 | + ArrayPool<byte>.Shared.Return(buffer); | |
| 212 | + } | |
| 213 | + } | |
| 214 | + | |
| 215 | + /// <summary> | |
| 216 | + /// Takes the peer out of the room, tells whoever is left, and drops the room once it is empty. | |
| 217 | + /// </summary> | |
| 218 | + private async Task LeaveAsync(string code, Room room, Peer peer) | |
| 219 | + { | |
| 220 | + Peer[] remaining; | |
| 221 | + | |
| 222 | + lock (room.Gate) | |
| 223 | + { | |
| 224 | + room.Peers.Remove(peer); | |
| 225 | + remaining = [.. room.Peers]; | |
| 226 | + | |
| 227 | + // Marked before the removal, so a peer that is mid-Join on this room sees it and takes | |
| 228 | + // the room that replaces it instead of joining one nobody can reach. | |
| 229 | + if (remaining.Length == 0) room.Removed = true; | |
| 230 | + } | |
| 231 | + | |
| 232 | + if (remaining.Length == 0) | |
| 233 | + { | |
| 234 | + _rooms.TryRemove(new KeyValuePair<string, Room>(code, room)); | |
| 235 | + return; | |
| 236 | + } | |
| 237 | + | |
| 238 | + foreach (var other in remaining) | |
| 239 | + { | |
| 240 | + await other.SendAsync("""{"type":"peer-left"}""", CancellationToken.None).ConfigureAwait(false); | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, CancellationToken cancellationToken) | |
| 245 | + { | |
| 246 | + if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) return; | |
| 247 | + | |
| 248 | + try | |
| 249 | + { | |
| 250 | + await socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false); | |
| 251 | + } | |
| 252 | + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException) | |
| 253 | + { | |
| 254 | + // The other end is already gone; there is nobody to say goodbye to. | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + private sealed class Room | |
| 259 | + { | |
| 260 | + public readonly Lock Gate = new(); | |
| 261 | + | |
| 262 | + /// <summary>Never more than two, so a list beats anything cleverer.</summary> | |
| 263 | + public readonly List<Peer> Peers = []; | |
| 264 | + | |
| 265 | + /// <summary>Set under <see cref="Gate"/> once this room is on its way out of the dictionary.</summary> | |
| 266 | + public bool Removed; | |
| 267 | + } | |
| 268 | + | |
| 269 | + private sealed class Peer(WebSocket socket) | |
| 270 | + { | |
| 271 | + /// <summary>One write at a time: two interleaved sends would corrupt the frame.</summary> | |
| 272 | + private readonly SemaphoreSlim _sending = new(1, 1); | |
| 273 | + | |
| 274 | + public WebSocket Socket { get; } = socket; | |
| 275 | + | |
| 276 | + public async Task SendAsync(string message, CancellationToken cancellationToken) | |
| 277 | + { | |
| 278 | + await _sending.WaitAsync(cancellationToken).ConfigureAwait(false); | |
| 279 | + | |
| 280 | + try | |
| 281 | + { | |
| 282 | + if (Socket.State != WebSocketState.Open) return; | |
| 283 | + | |
| 284 | + await Socket.SendAsync( | |
| 285 | + Encoding.UTF8.GetBytes(message), | |
| 286 | + WebSocketMessageType.Text, | |
| 287 | + endOfMessage: true, | |
| 288 | + cancellationToken).ConfigureAwait(false); | |
| 289 | + } | |
| 290 | + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException) | |
| 291 | + { | |
| 292 | + // A peer that has gone away is not this peer's problem: its own pump will notice. | |
| 293 | + } | |
| 294 | + finally | |
| 295 | + { | |
| 296 | + _sending.Release(); | |
| 297 | + } | |
| 298 | + } | |
| 299 | + } | |
| 300 | +} | |
Blog/appsettings.json +3 -0
| @@ -10,6 +10,9 @@ | ||
| 10 | 10 | : "rvrb@127.0.0.1", |
| 11 | 11 | : "blog@127.0.0.1" |
| 12 | 12 | }, |
| 13 | + : { | |
| 14 | + : [ "stun:stun.l.google.com:19302" ] | |
| 15 | + }, | |
| 13 | 16 | : { |
| 14 | 17 | : "/home/git", |
| 15 | 18 | : "ssh://git@git.bes.is/{repo}" |
Blog/wwwroot/app.css +67 -0
| @@ -802,6 +802,73 @@ progress { | ||
| 802 | 802 | } |
| 803 | 803 | |
| 804 | 804 | |
| 805 | +/* -- Send (peer-to-peer file transfer) ------------------------------------ */ | |
| 806 | + | |
| 807 | +/* A target big enough to aim a dragged file at, dashed so it reads as a place to drop rather | |
| 808 | + than a box with something in it. */ | |
| 809 | +.dropzone { | |
| 810 | + border: var() dashed var(); | |
| 811 | + border-radius: var(); | |
| 812 | + padding: var(); | |
| 813 | + text-align: center; | |
| 814 | +} | |
| 815 | + | |
| 816 | +.dropzone > p { | |
| 817 | + margin-block-end: 0; | |
| 818 | +} | |
| 819 | + | |
| 820 | +/* The only feedback that the drop will land here. Translucent so it works over either scheme | |
| 821 | + without a token of its own. */ | |
| 822 | +.dropzone.over { | |
| 823 | + background-color: color-mix(in srgb, var() 12%, transparent); | |
| 824 | +} | |
| 825 | + | |
| 826 | +.transfers { | |
| 827 | + list-style: none; | |
| 828 | + margin: 0; | |
| 829 | + padding: 0; | |
| 830 | +} | |
| 831 | + | |
| 832 | +.transfers:not(:empty) { | |
| 833 | + margin-block-start: var(); | |
| 834 | +} | |
| 835 | + | |
| 836 | +/* Name and size on one line, bar and status under them: two rows of two, with the name free to | |
| 837 | + wrap and the two right-hand cells sized to their text. */ | |
| 838 | +.transfer { | |
| 839 | + display: grid; | |
| 840 | + grid-template-columns: minmax(0, 1fr) auto; | |
| 841 | + column-gap: var(); | |
| 842 | + align-items: baseline; | |
| 843 | +} | |
| 844 | + | |
| 845 | +.transfer + .transfer { | |
| 846 | + margin-block-start: var(); | |
| 847 | +} | |
| 848 | + | |
| 849 | +/* A file name is not prose and has no spaces to break at. */ | |
| 850 | +.transfer-name { | |
| 851 | + overflow-wrap: anywhere; | |
| 852 | +} | |
| 853 | + | |
| 854 | +.transfer-size, | |
| 855 | +.transfer-status { | |
| 856 | + white-space: nowrap; | |
| 857 | + font-variant-numeric: tabular-nums; | |
| 858 | +} | |
| 859 | + | |
| 860 | +/* The one control that arrives with a colour of its own: the browser's accent is nowhere else | |
| 861 | + on this site. */ | |
| 862 | +.transfer progress { | |
| 863 | + inline-size: 100%; | |
| 864 | + accent-color: var(); | |
| 865 | +} | |
| 866 | + | |
| 867 | +.transfer.failed .transfer-status { | |
| 868 | + color: var(); | |
| 869 | +} | |
| 870 | + | |
| 871 | + | |
| 805 | 872 | /* -- Scroll shortcuts ------------------------------------------------------ */ |
| 806 | 873 | |
| 807 | 874 | /* Docked to the viewport edge rather than the page flow, so it stays |
CLAUDE.md +37 -1
| @@ -84,7 +84,7 @@ BRP dataset in `wwwroot/brp.json`). | ||
| 84 | 84 | |
| 85 | 85 | `Rvrb.razor`/`.razor.cs`/`.razor.js` (route `/rvrb`) shows the status and stats of the |
| 86 | 86 | rvrb Elixir bot (`~/Developer/elixir/rvrb`), which runs on the same server. `Services/RvrbService.cs` |
| 87 | -gets them by joining the bot's Erlang cluster: | |
| 87 | +gets them by joining the bot's Erlang cluster: | |
| 88 | 88 | (referenced as a project from the sibling checkout, it is not on NuGet yet) makes this site a |
| 89 | 89 | hidden Erlang node and calls `Rvrb.Stats.snapshot/0` on the bot the way any BEAM node would — |
| 90 | 90 | no HTTP endpoint on the Elixir side. |
| @@ -159,6 +159,42 @@ results are linkable and work without JS. `Warframe.razor.js` only fills the sea | ||
| 159 | 159 | `<datalist>` from `/api/warframe/names` (mapped in `Program.cs`). It does *not* import |
| 160 | 160 | `/common.module.js`: that module binds to a `#log` element this page doesn't have. |
| 161 | 161 | |
| 162 | +### Send (`/Send`) — a file from one browser to another | |
| 163 | + | |
| 164 | +`Send.razor`/`.razor.js` sends a file over a **WebRTC data channel**. The site's only part is the | |
| 165 | +introduction: `Services/SignalingService.cs` behind a WebSocket at `/api/send/{code}` (mapped in | |
| 166 | +`Program.cs`, and the only reason `app.UseWebSockets()` is there) relays the offer, the answer and | |
| 167 | +the ICE candidates between the two browsers holding the same code. The file never passes through | |
| 168 | +this process, and closing the signalling socket does not interrupt a transfer. | |
| 169 | + | |
| 170 | +The server never mints or stores a code: the page generates twelve Crockford base32 characters | |
| 171 | +from `crypto.getRandomValues`, a room exists while a socket is in it, and a third peer is refused. | |
| 172 | +Messages are relayed verbatim — nothing parses SDP, which is why a browser can restart ICE or | |
| 173 | +change codecs without this code learning a new message type. Rooms are a `ConcurrentDictionary` | |
| 174 | +in a **singleton**, so the rooms *are* the service. | |
| 175 | + | |
| 176 | +Configuration lives under `Send` (`SendOptions`): `IceServers`, `MaxRooms`, `MaxMessageBytes`, | |
| 177 | +`RoomLifetime`. STUN only, deliberately — a TURN server would relay the bytes, which is the one | |
| 178 | +thing the page is for not doing, so a pair with no route between them fails rather than quietly | |
| 179 | +going through a third party. The URLs reach the script as a `data-` attribute rather than a | |
| 180 | +`<script type="application/json">` block, because Razor HTML-encodes element content. | |
| 181 | + | |
| 182 | +Three things about the client that are easy to undo by accident: | |
| 183 | + | |
| 184 | +- The code lives in the URL **fragment**, and `App.razor` sets `<base href="/">`. A bare | |
| 185 | + `history.replaceState(null, "", "#code")` resolves against that base and silently moves the page | |
| 186 | + to the site root — pass the whole URL. | |
| 187 | +- Following a link to another room from an already-open page changes only the fragment, so the | |
| 188 | + browser does not reload and the module body — which *is* this page's setup — never re-runs. | |
| 189 | + `hashchange` reloads on purpose. | |
| 190 | +- Signalling messages are handled one at a time through a promise chain. Handling an offer is | |
| 191 | + several awaits long, and a candidate that overtook it would be added against a connection that | |
| 192 | + is still half-described. | |
| 193 | + | |
| 194 | +Sending is chunked with backpressure (`bufferedAmountLowThreshold`, a high-water mark) and one | |
| 195 | +file at a time, because the receiver's side of the protocol assumes it; a received file is held in | |
| 196 | +memory as `Blob` parts until it is saved. Both ends can send — the page is symmetric. | |
| 197 | + | |
| 162 | 198 | ### Git browser (`/git`) |
| 163 | 199 | |
| 164 | 200 | `Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a |