/// Serving one cached attachment from the desktop wrapper, for an `` in /// the page or a file opened in a new tab. /// /// The wrapper's origin also holds the GitHub token (localStorage) and the /// notes (IndexedDB), so an attached `.html` or `.svg` rendered here would /// run script with access to both. Only types that cannot script are shown /// inline — raster images, PDF, plain text, audio and video — always with /// `nosniff` so the browser cannot second-guess the type; everything else is /// sent as a download. /// /// Single byte ranges are honoured so a video can seek. library; import 'dart:io'; import 'package:todo/images/attachment_ref.dart'; /// The content type for an attachment with [extension], and whether it may /// be shown inline (see the library comment). ({ContentType type, bool inline}) attachmentContentType(String extension) { final type = switch (extension) { 'jpg' || 'jpeg' => ContentType('image', 'jpeg'), 'png' || 'gif' || 'webp' || 'bmp' => ContentType('image', extension), 'pdf' => ContentType('application', 'pdf'), 'txt' || 'md' || 'log' || 'csv' => ContentType('text', 'plain', charset: 'utf-8'), 'mp4' || 'm4v' => ContentType('video', 'mp4'), 'webm' => ContentType('video', 'webm'), 'ogv' => ContentType('video', 'ogg'), 'mov' => ContentType('video', 'quicktime'), 'mp3' => ContentType('audio', 'mpeg'), 'm4a' => ContentType('audio', 'mp4'), 'ogg' || 'oga' || 'opus' => ContentType('audio', 'ogg'), 'wav' => ContentType('audio', 'wav'), 'flac' => ContentType('audio', 'flac'), _ => null, }; return type == null ? (type: ContentType.binary, inline: false) : (type: type, inline: true); } /// `Content-Disposition` for [fileName]: RFC 6266 with an RFC 5987 /// `filename*`, so any name (spaces, Polish letters, quotes) survives. String contentDisposition(String fileName, {required bool inline}) { final encoded = Uri.encodeComponent(fileName).replaceAllMapped( RegExp("[!'()*]"), (m) => '%${m[0]!.codeUnitAt(0).toRadixString(16).toUpperCase()}', ); return "${inline ? 'inline' : 'attachment'}; filename*=UTF-8''$encoded"; } /// Answers a GET or HEAD for [ref] from [file], named [fileName] when the /// page passed one (else the stored name). 404 when it is not cached. Future serveAttachment( HttpRequest request, AttachmentRef ref, File file, { String? fileName, }) async { final response = request.response; if (!file.existsSync()) { response.statusCode = HttpStatus.notFound; return; } final length = await file.length(); final kind = attachmentContentType(ref.extension); response.headers ..contentType = kind.type ..set('x-content-type-options', 'nosniff') ..set(HttpHeaders.acceptRangesHeader, 'bytes') ..set( 'content-disposition', contentDisposition(fileName ?? ref.file, inline: kind.inline), ) // The name is a fresh uuid per attachment, so the bytes never change. ..set(HttpHeaders.cacheControlHeader, 'private, max-age=31536000'); var start = 0; var end = length - 1; final range = request.headers.value(HttpHeaders.rangeHeader); if (range != null) { final parsed = parseByteRange(range, length); if (parsed == null) { response ..statusCode = HttpStatus.requestedRangeNotSatisfiable ..headers.set(HttpHeaders.contentRangeHeader, 'bytes */$length'); return; } (start, end) = parsed; response ..statusCode = HttpStatus.partialContent ..headers.set( HttpHeaders.contentRangeHeader, 'bytes $start-$end/$length', ); } response.contentLength = length == 0 ? 0 : end - start + 1; if (request.method == 'HEAD' || length == 0) return; // A client leaving mid-body (a video seek abandons its old range) does not // throw here: dart:io absorbs it and the stream just stops being read. await response.addStream(file.openRead(start, end + 1)); } /// The inclusive byte range a single-range `Range` header asks for within /// [length] bytes, or null when it is malformed, multi-range or outside the /// file. (int, int)? parseByteRange(String header, int length) { final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(header.trim()); if (match == null) return null; final first = int.tryParse(match[1]!); final last = int.tryParse(match[2]!); final int start; final int end; if (first == null) { // `bytes=-N`: the final N bytes. if (last == null || last == 0) return null; start = last >= length ? 0 : length - last; end = length - 1; } else { start = first; end = last == null || last >= length ? length - 1 : last; } if (start >= length || start > end) return null; return (start, end); }