129 lines
4.2 KiB
Dart
129 lines
4.2 KiB
Dart
/// The Spartan protocol client: plaintext TCP on port 300, a request line of
|
|
/// `<host> <path> <content-length>` CRLF optionally followed by that many
|
|
/// bytes of body, and a response of `<status> <meta>` CRLF plus a body on
|
|
/// success. There is no TLS, so no identity and no trust to decide: anyone
|
|
/// on the line can read or write the conversation.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
/// The whole response of one request, redirects already followed.
|
|
class SpartanResponse {
|
|
const SpartanResponse({
|
|
required this.url,
|
|
required this.status,
|
|
required this.meta,
|
|
this.body,
|
|
});
|
|
|
|
/// The address the content finally came from, after redirects.
|
|
final Uri url;
|
|
|
|
/// The single-digit status code: 2 success, 3 redirect, 4 client error,
|
|
/// 5 server error.
|
|
final int status;
|
|
|
|
/// The MIME type on success, the target address on a redirect, an error
|
|
/// message otherwise.
|
|
final String meta;
|
|
|
|
/// The response body, present on a success.
|
|
final Uint8List? body;
|
|
|
|
bool get isSuccess => status == 2;
|
|
bool get isRedirect => status == 3;
|
|
}
|
|
|
|
/// A connection, protocol or timeout failure. One failure to the reader.
|
|
class SpartanException implements Exception {
|
|
const SpartanException(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// The same address redirected more times than the client follows.
|
|
class SpartanTooManyRedirects implements Exception {
|
|
const SpartanTooManyRedirects();
|
|
}
|
|
|
|
class SpartanClient {
|
|
SpartanClient({this.timeout = const Duration(seconds: 20)});
|
|
|
|
static const defaultPort = 300;
|
|
static const maxRedirects = 5;
|
|
|
|
final Duration timeout;
|
|
|
|
/// Fetches [url], following status 3 redirects up to [maxRedirects] times.
|
|
/// [upload] becomes the request body, the protocol's only way of sending
|
|
/// data to a server.
|
|
Future<SpartanResponse> fetch(Uri url, {Uint8List? upload}) async {
|
|
var current = url;
|
|
for (var hops = 0;; hops++) {
|
|
final response = await _requestOnce(current, upload);
|
|
if (!response.isRedirect) return response;
|
|
if (hops >= maxRedirects) throw const SpartanTooManyRedirects();
|
|
final target = current.resolve(response.meta);
|
|
// Only spartan addresses are followed; anything else the redirect
|
|
// points to is left for the reader to open as its own navigation.
|
|
if (!target.hasScheme || target.scheme != 'spartan' || target.host.isEmpty) {
|
|
return response;
|
|
}
|
|
current = target;
|
|
}
|
|
}
|
|
|
|
Future<SpartanResponse> _requestOnce(Uri url, Uint8List? upload) async {
|
|
final host = url.host;
|
|
final port = url.hasPort ? url.port : defaultPort;
|
|
final path = url.path.isEmpty ? '/' : url.path + (url.hasQuery ? '?${url.query}' : '');
|
|
|
|
final Socket socket;
|
|
try {
|
|
socket = await Socket.connect(host, port, timeout: timeout);
|
|
} catch (_) {
|
|
throw const SpartanException('could not connect');
|
|
}
|
|
|
|
try {
|
|
final length = upload?.length ?? 0;
|
|
socket.write('$host $path $length\r\n');
|
|
if (upload != null) socket.add(upload);
|
|
await socket.flush();
|
|
|
|
final raw = await _readToEnd(socket).timeout(timeout);
|
|
final headerEnd = _indexOfCrlf(raw);
|
|
if (headerEnd < 1) throw const SpartanException('response without a status line');
|
|
final header = String.fromCharCodes(raw.sublist(0, headerEnd));
|
|
if (header.length < 3 || header.codeUnitAt(1) != 0x20) {
|
|
throw const SpartanException('malformed status line');
|
|
}
|
|
final status = header.codeUnitAt(0) - 0x30;
|
|
if (status < 0 || status > 9) throw const SpartanException('malformed status line');
|
|
final body = status == 2 ? Uint8List.fromList(raw.sublist(headerEnd + 2)) : null;
|
|
return SpartanResponse(url: url, status: status, meta: header.substring(2), body: body);
|
|
} finally {
|
|
socket.destroy();
|
|
}
|
|
}
|
|
|
|
static Future<Uint8List> _readToEnd(Socket socket) async {
|
|
final builder = BytesBuilder(copy: false);
|
|
await for (final chunk in socket) {
|
|
builder.add(chunk);
|
|
}
|
|
return builder.toBytes();
|
|
}
|
|
|
|
static int _indexOfCrlf(Uint8List bytes) {
|
|
for (var i = 0; i + 1 < bytes.length; i++) {
|
|
if (bytes[i] == 0x0d && bytes[i + 1] == 0x0a) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
}
|