221 lines
6.1 KiB
Dart
221 lines
6.1 KiB
Dart
|
import 'dart:async';
|
||
|
import 'dart:convert';
|
||
|
import 'dart:io';
|
||
|
import 'dart:typed_data';
|
||
|
|
||
|
enum DocumentType {
|
||
|
/*Canonical types RFC1436 line 548*/
|
||
|
/// Item is a file (0)
|
||
|
TextFile("0"),
|
||
|
|
||
|
/// Item is a directory (1)
|
||
|
Directory("1"),
|
||
|
|
||
|
/// Item is a CSO phone-book server (2)
|
||
|
CSO("2"),
|
||
|
|
||
|
/// Error returned by server (3)
|
||
|
ErrorCode("3"),
|
||
|
|
||
|
/// Item is a BinHexed Macintosh file. (4)
|
||
|
BinHex("4"),
|
||
|
|
||
|
/// Item is DOS binary archive of some sort. (5)
|
||
|
DosBinary("5"),
|
||
|
|
||
|
/// Item is a UNIX uuencoded file. (6)
|
||
|
Uuencoded("6"),
|
||
|
|
||
|
/// Item is an Index-Search server. (7)
|
||
|
Search("7"),
|
||
|
|
||
|
/// Item points to a text-based telnet session. (8)
|
||
|
Telnet("8"),
|
||
|
|
||
|
/// Item is a binary file! (9)
|
||
|
Binary("9"),
|
||
|
|
||
|
/// It em is a redundant server (*)
|
||
|
MirrorOrAlternate("*"),
|
||
|
|
||
|
/// Item points to a text-based tn3270 session. (T)
|
||
|
Telnet3270("T"),
|
||
|
|
||
|
/// Item is a GIF format graphics file. (g)
|
||
|
Gif("g"),
|
||
|
|
||
|
/// Item is some kind of image file. Client decides how to display. (I)
|
||
|
Image("I"),
|
||
|
/**** Gopher+ ****/
|
||
|
/// Bitmap image (:)
|
||
|
Bitmap(":"),
|
||
|
|
||
|
/// Movie file (;)
|
||
|
Movie(";"),
|
||
|
|
||
|
/// Sound file (<)
|
||
|
Soundfile("<"),
|
||
|
/*non-canonical types*/
|
||
|
/// Doc. Used for .doc and .pdf (d)
|
||
|
Doc("d"),
|
||
|
|
||
|
/// HTML file (h)
|
||
|
Html("h"),
|
||
|
|
||
|
/// Informational message, widely used (i)
|
||
|
Informational("i"),
|
||
|
|
||
|
/// Usually png image (p)
|
||
|
PngImage("p"),
|
||
|
|
||
|
/// Rich text document (r)
|
||
|
RichTextDocument("r"),
|
||
|
|
||
|
/// usually WAV (s)
|
||
|
SoundFile("s"),
|
||
|
|
||
|
/// Pdf (P)
|
||
|
Pdf("P"),
|
||
|
|
||
|
/// XML file (X)
|
||
|
Xml("X"),
|
||
|
|
||
|
/// Document type isn't any of the known types
|
||
|
Unknown("?");
|
||
|
|
||
|
const DocumentType(this.code);
|
||
|
final String code;
|
||
|
|
||
|
/// Returns the canonical name of the document type
|
||
|
String get typeName => name;
|
||
|
|
||
|
/// Returns character code used for this document type
|
||
|
String get typeCode => code;
|
||
|
}
|
||
|
|
||
|
class ParsedGopherItem {
|
||
|
static final docMap = {for (var item in DocumentType.values) item.code: item};
|
||
|
|
||
|
Uri url;
|
||
|
String? name;
|
||
|
DocumentType documentType;
|
||
|
|
||
|
ParsedGopherItem._(this.url, this.name, this.documentType);
|
||
|
|
||
|
/// Parse gopher item from url, from url file type is not known
|
||
|
/// so it is guessed by file extension
|
||
|
// TODO: Do actuall filetype guessing
|
||
|
static ParsedGopherItem parseUrl(String url) {
|
||
|
if (!url.startsWith("gopher://")) url = "gopher://$url";
|
||
|
var uri = Uri.parse(url);
|
||
|
|
||
|
// We expect TextFile by default unless we think it's menu
|
||
|
final doctype = (uri.path == "" || uri.path.endsWith("/")) ? DocumentType.Directory : DocumentType.TextFile;
|
||
|
|
||
|
if (uri.port == 0) uri = uri.replace(port: 70);
|
||
|
|
||
|
return ParsedGopherItem._(uri, "", doctype);
|
||
|
}
|
||
|
|
||
|
/// Parses single gopher item from single menu line
|
||
|
static ParsedGopherItem? parseMenuLine(String menuLine) {
|
||
|
// If theres not even the item type and name we won't bother
|
||
|
if (menuLine.length < 2) return null;
|
||
|
|
||
|
var parts = menuLine.split("\t");
|
||
|
|
||
|
if (parts.length != 4) return null;
|
||
|
|
||
|
// If string is correct, we should have 4 parts
|
||
|
// 0Pony Prices Prices/Ponies pserver.bookstore.umn.edu 70
|
||
|
//type;name ; selektor ; host ; port
|
||
|
// 0;Pony Prices; Prices/Ponies; pserver.bookstore.umn.edu; 70
|
||
|
var uri = Uri(
|
||
|
scheme: "gopher",
|
||
|
path: parts[1],
|
||
|
host: parts[2],
|
||
|
port: int.tryParse(parts[3]) ?? 70,
|
||
|
);
|
||
|
|
||
|
String type = menuLine[0];
|
||
|
String name = parts[0].substring(1);
|
||
|
|
||
|
final docType = docMap.containsKey(type) ? docMap[type]! : docMap["?"]!;
|
||
|
return ParsedGopherItem._(uri, name, docType);
|
||
|
}
|
||
|
|
||
|
/// Reads gopher directory/menu from host:port and optionally selector
|
||
|
static Future<LoadedGopherItem> loadMenu(String host, int port,
|
||
|
{String selector = ""}) async {
|
||
|
|
||
|
final Socket sock = await Socket.connect(host, port);
|
||
|
sock.write("$selector\r\n");
|
||
|
await sock.flush();
|
||
|
print("Entered load menu");
|
||
|
final ret = <ParsedGopherItem>[];
|
||
|
final List<Uint8List> rawRet = <Uint8List>[];
|
||
|
final subscription = sock.listen(
|
||
|
(Uint8List data) {
|
||
|
rawRet.add(data);
|
||
|
String rcvd = utf8.decode(data, allowMalformed: true);
|
||
|
|
||
|
ParsedGopherItem? apend;
|
||
|
// TODO: This is flawed, it usually works but it's just a matter of time before it breaks
|
||
|
// If \n is in the next buffer, the part before \n gets cutoff
|
||
|
// resulting in unparsable line
|
||
|
// the substrining must begin outside the loop listen call
|
||
|
// or it must be rewritten using LineSplitter()
|
||
|
int start = 0;
|
||
|
for (int i = 0; i < rcvd.length; i++) {
|
||
|
if (rcvd[i] == "\n") {
|
||
|
var menuLine = rcvd.substring(start, i);
|
||
|
// TODO: Gophernicus sends .\r for some reason which doesnt seem acording to spec
|
||
|
// Maybe ignore when sent from menu=forced?
|
||
|
if (menuLine.startsWith(".")) {
|
||
|
print("Received starting dot, this should be the end I guess");
|
||
|
return;
|
||
|
}
|
||
|
apend = parseMenuLine(menuLine);
|
||
|
if (apend != null) ret.add(apend);
|
||
|
start = i + 1;
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
await subscription.asFuture<void>();
|
||
|
sock.close();
|
||
|
return LoadedGopherItem(
|
||
|
ParsedGopherItem._(Uri.parse("gopher://$host:$port$selector"), selector,
|
||
|
DocumentType.Directory),
|
||
|
asMenu: ret,
|
||
|
data: rawRet,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
Future<LoadedGopherItem> loadItem() async {
|
||
|
final Socket sock = await Socket.connect(url.host, url.port);
|
||
|
|
||
|
print("Sending request to ${url.host}[:${url.port}] => '${url.path}'");
|
||
|
sock.write("${url.path}\r\n");
|
||
|
var data = await sock.toList();
|
||
|
print("Closing: ${sock.close()} as type ${this.documentType}");
|
||
|
|
||
|
return LoadedGopherItem(this, data: data);
|
||
|
}
|
||
|
|
||
|
Future<LoadedGopherItem> load({bool? forceMenu}) async {
|
||
|
if ((documentType == DocumentType.Directory) || (forceMenu ?? false)) {
|
||
|
print("Forcing? ($forceMenu) reload as menu for: $url");
|
||
|
return loadMenu(url.host, url.port, selector: url.path);
|
||
|
}
|
||
|
return loadItem();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class LoadedGopherItem {
|
||
|
final ParsedGopherItem parsed;
|
||
|
final List<Uint8List>? data;
|
||
|
final List<ParsedGopherItem>? asMenu;
|
||
|
|
||
|
LoadedGopherItem(this.parsed, {this.data, this.asMenu});
|
||
|
}
|