From 2dabbc0969f88ebea53ea4c80816658704effc6c Mon Sep 17 00:00:00 2001 From: MatthiasSaihttam Date: Sat, 9 Oct 2021 21:05:56 -0400 Subject: [PATCH] Non gemtext static files * Static file handler now attempts to use "file-type" if it is available to read the magic bytes of a file and get Mime type * Fixed a crash if the client closed the socket prematurely * Move README to gmi --- .gitignore | 1 + GeminiServer.js | 3 + README.md => README.gmi | 4 +- example.js | 4 +- handlers/static.js | 35 ++++- package-lock.json | 275 ++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- static/small.png | Bin 0 -> 3984 bytes 8 files changed, 317 insertions(+), 10 deletions(-) rename README.md => README.gmi (94%) create mode 100644 package-lock.json create mode 100644 static/small.png diff --git a/.gitignore b/.gitignore index cfaad76..8793fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.pem +node_modules diff --git a/GeminiServer.js b/GeminiServer.js index 34eb270..75fe73d 100644 --- a/GeminiServer.js +++ b/GeminiServer.js @@ -220,6 +220,9 @@ export default class GeminiServer { } } }); + + // This can be just ECONNRESET + socket.on("error", console.error); }); server.listen(1965); } diff --git a/README.md b/README.gmi similarity index 94% rename from README.md rename to README.gmi index da6e0ed..73aa324 100644 --- a/README.md +++ b/README.gmi @@ -23,7 +23,7 @@ A `relativePath` is the difference between a `basePath` and a `urlPath`. It's no Sometimes these paths will include their hostnames -A path in the pathRegistry is a `p`, which is an object with a hostname, a basePath, and a handler +A path in the pathRegistry is a `p`, which is an object with a hostname, a basePath, and a handler @@ -44,4 +44,4 @@ openssl x509 -req -in csr.pem -signkey private-key.pem -out public-cert.pem It's possible to imagine a situation where astronomical theater is behind a proxy and the proxy doesn't do address translation, passing the raw gemini request to us. In this case, our behavior is undefined. -Similarly, SNI is not supported with IP addresses. +Similarly, SNI is not supported with IP addresses. diff --git a/example.js b/example.js index 4b88371..3e283c5 100644 --- a/example.js +++ b/example.js @@ -57,8 +57,8 @@ server.registerPath("localhost/allFiles", new ExampleHandler()); // We will provide some convenient handlers for static, CGI, and reverse proxy //if the passed file is a single file, it's a file. If it's a directory, all sub-files are auto-included -server.registerPath("localhost/static/about.gmi", new StaticHandler("/tmp/content/about.gmi" /*, {options}*/)); -server.registerPath("localhost/static", new StaticHandler("/tmp/content/" /*, {options}*/)); +server.registerPath("localhost/static/about.gmi", new StaticHandler("README.gmi" /*, {options}*/)); +server.registerPath("localhost/static", new StaticHandler("static/" /*, {options}*/)); // server.registerPath("localhost/static", new StaticHandler("/tmp/yourmom/" /*, {options}*/)); //The file passed to CGI handler must exist and be executable at run time diff --git a/handlers/static.js b/handlers/static.js index 226f3a5..00989a6 100644 --- a/handlers/static.js +++ b/handlers/static.js @@ -3,6 +3,9 @@ import * as fs from "fs"; import DefaultHandler from "./default.js"; +let fileTypeFromFile; +import("file-type").then(ft => fileTypeFromFile = ft.default.fromFile).catch(console.error); + export default class StaticHandler extends DefaultHandler { constructor(basePath, directoryList = false) { super(); @@ -18,7 +21,7 @@ export default class StaticHandler extends DefaultHandler { // This is the handler // url is the URL object, with url.pathname being the path. p is the 'client-side' basepath - handle (url, p) { + async handle (url, p, socket) { const relativePath = path.relative(p, url.pathname); //Concat and normalize the passed URL as being relative to the base path const toServe = path.join(this.basePath, relativePath); @@ -32,7 +35,7 @@ export default class StaticHandler extends DefaultHandler { if (fs.statSync(toServe).isDirectory()) { console.log(`File is a directory and directoryList is ${this.directoryList}.`); if (this.directoryList) { - + //TODO: Static content directory listing } else { return "51 Not Found\r\n"; } @@ -43,9 +46,31 @@ export default class StaticHandler extends DefaultHandler { return "51 Not Found\r\n"; } - //TODO: import mmmagic + let mimeType = "text/gemini"; + + if (fileTypeFromFile) { + const type = await fileTypeFromFile(toServe); + // `type` will be undefined if the file is plaintext + // In which case we want to fall down to serving plaintext + if (type) { + mimeType = type.mime; + } + } + + //TODO: some file-ending based logic, please + //So we can serve md and txt + + //TODO: convert line endings - const data = fs.readFileSync(toServe); - return "20 text/gemini\r\n" + data; + // I read somewhere that `readFileSync` on every request is a bad idea, but I don't care + socket.write(`20 ${mimeType}\r\n`); + const fileReader = fs.createReadStream(toServe); + //This function (handle) needs to return after we're done writing the file to the stream + //Because the place that called this is going to handle .end() the socket + //So we don't end the socket here, and we make a promise to wait for the reader to end before returning + //It would be great if we could just not return from this function but I don't think that's how this works + fileReader.pipe(socket, {end: false}); + await new Promise((res, rej) => fileReader.on("end", res)); + return ""; } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..40269b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,275 @@ +{ + "name": "astronomical-theater", + "version": "v2.0.5", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "astronomical-theater", + "version": "v2.0.5", + "license": "CC0-1.0", + "optionalDependencies": { + "file-type": "^16.5.3" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "optional": true + }, + "node_modules/file-type": { + "version": "16.5.3", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.3.tgz", + "integrity": "sha512-uVsl7iFhHSOY4bEONLlTK47iAHtNsFHWP5YE4xJfZ4rnX7S1Q3wce09XgqSC7E/xh8Ncv/be1lNoyprlUH/x6A==", + "optional": true, + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "optional": true + }, + "node_modules/peek-readable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.0.1.tgz", + "integrity": "sha512-7qmhptnR0WMSpxT5rMHG9bW/mYSR1uqaPFj2MHvT+y/aOUu6msJijpKt5SkTDKySwg65OWG2JwTMBlgcbwMHrQ==", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "optional": true, + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strtok3": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.2.4.tgz", + "integrity": "sha512-GO8IcFF9GmFDvqduIspUBwCzCbqzegyVKIsSymcMgiZKeCfrN9SowtUoi8+b59WZMAjIzVZic/Ft97+pynR3Iw==", + "optional": true, + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/token-types": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.1.1.tgz", + "integrity": "sha512-hD+QyuUAyI2spzsI0B7gf/jJ2ggR4RjkAo37j3StuePhApJUwcWDjnHDOFdIWYSwNR28H14hpwm4EI+V1Ted1w==", + "optional": true, + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "optional": true + } + }, + "dependencies": { + "@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "optional": true + }, + "file-type": { + "version": "16.5.3", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.3.tgz", + "integrity": "sha512-uVsl7iFhHSOY4bEONLlTK47iAHtNsFHWP5YE4xJfZ4rnX7S1Q3wce09XgqSC7E/xh8Ncv/be1lNoyprlUH/x6A==", + "optional": true, + "requires": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "optional": true + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "optional": true + }, + "peek-readable": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.0.1.tgz", + "integrity": "sha512-7qmhptnR0WMSpxT5rMHG9bW/mYSR1uqaPFj2MHvT+y/aOUu6msJijpKt5SkTDKySwg65OWG2JwTMBlgcbwMHrQ==", + "optional": true + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "optional": true, + "requires": { + "readable-stream": "^3.6.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "optional": true + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "strtok3": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.2.4.tgz", + "integrity": "sha512-GO8IcFF9GmFDvqduIspUBwCzCbqzegyVKIsSymcMgiZKeCfrN9SowtUoi8+b59WZMAjIzVZic/Ft97+pynR3Iw==", + "optional": true, + "requires": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.0.1" + } + }, + "token-types": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.1.1.tgz", + "integrity": "sha512-hD+QyuUAyI2spzsI0B7gf/jJ2ggR4RjkAo37j3StuePhApJUwcWDjnHDOFdIWYSwNR28H14hpwm4EI+V1Ted1w==", + "optional": true, + "requires": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "optional": true + } + } +} diff --git a/package.json b/package.json index 6e7f324..220761d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "astronomical-theater", - "version": "v2.0.5", + "version": "v2.1.0", "author": "Matthias", "license": "CC0-1.0", "type": "module", "main": "main.js", "scripts": { "start": "node main.js" + }, + "optionalDependencies": { + "file-type": "^16.5.3" } } diff --git a/static/small.png b/static/small.png new file mode 100644 index 0000000000000000000000000000000000000000..74b64a154f9f81fa606e16daa046ad1e81a8b5d8 GIT binary patch literal 3984 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 literal 0 HcmV?d00001