├── .cargo └── config ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── doc ├── phd.1 └── phd.1.md ├── header.gph ├── img └── logo.png ├── src ├── color.rs ├── gopher.rs ├── lib.rs ├── main.rs ├── request.rs └── server.rs └── tests └── sort ├── phetch-v0.1.10-linux-armv7.tgz ├── phetch-v0.1.10-linux-x86_64.tgz ├── phetch-v0.1.10-macos.zip ├── phetch-v0.1.11-linux-armv7.tgz ├── phetch-v0.1.11-linux-x86_64.tgz ├── phetch-v0.1.11-macos.zip ├── phetch-v0.1.7-linux-armv7.tar.gz ├── phetch-v0.1.7-linux-x86_64.tar.gz ├── phetch-v0.1.7-macos.zip ├── phetch-v0.1.8-linux-armv7.tar.gz ├── phetch-v0.1.8-linux-x86_64.tar.gz ├── phetch-v0.1.8-macos.zip ├── phetch-v0.1.9-linux-armv7.tar.gz ├── phetch-v0.1.9-linux-x86_64.tar.gz ├── phetch-v0.1.9-macos.zip └── zzz └── .gitignore /.cargo/config: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["-C", "link-arg=-s"] 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | branches: ["*"] 6 | 7 | name: build 8 | 9 | jobs: 10 | test_macos: 11 | name: Run Tests on macOS 12 | runs-on: macos-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Cache cargo registry 16 | uses: actions/cache@v1 17 | with: 18 | path: ~/.cargo/registry 19 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 20 | - name: Cache cargo index 21 | uses: actions/cache@v1 22 | with: 23 | path: ~/.cargo/git 24 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 25 | - name: Cache cargo build 26 | uses: actions/cache@v1 27 | with: 28 | path: target 29 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 30 | - name: Setup toolchain 31 | uses: actions-rs/toolchain@v1 32 | with: 33 | profile: minimal 34 | toolchain: stable 35 | components: clippy 36 | override: true 37 | - name: check 38 | run: cargo check 39 | - name: clippy 40 | run: cargo clippy 41 | - name: test 42 | run: cargo test 43 | - name: build 44 | run: cargo build --release 45 | 46 | test_ubuntu: 47 | name: Run Tests on Ubuntu (x86_64) 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v1 51 | - name: Cache cargo registry 52 | uses: actions/cache@v3 53 | with: 54 | path: ~/.cargo/registry 55 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 56 | - name: Cache cargo index 57 | uses: actions/cache@v3 58 | with: 59 | path: ~/.cargo/git 60 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 61 | - name: Cache cargo build 62 | uses: actions/cache@v3 63 | with: 64 | path: target 65 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 66 | - name: check 67 | run: cargo check 68 | - name: clippy 69 | run: cargo clippy 70 | - name: test 71 | run: cargo test 72 | - name: build 73 | run: cargo build --release 74 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build_armv7: 10 | name: Build for armv7 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | - name: Cache cargo registry 16 | uses: actions/cache@v3 17 | with: 18 | path: ~/.cargo/registry 19 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 20 | - name: Cache cargo index 21 | uses: actions/cache@v3 22 | with: 23 | path: ~/.cargo/git 24 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 25 | - name: Cache cargo build 26 | uses: actions/cache@v3 27 | with: 28 | path: target 29 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 30 | - name: Setup Toolchain 31 | uses: actions-rs/toolchain@v1 32 | with: 33 | toolchain: stable 34 | target: armv7-unknown-linux-gnueabihf 35 | override: true 36 | - name: Build release 37 | uses: actions-rs/cargo@v1 38 | with: 39 | use-cross: true 40 | command: build 41 | args: --release --locked --target armv7-unknown-linux-gnueabihf 42 | - name: Get current version 43 | id: get_version 44 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 45 | - name: Package Binary 46 | run: cp doc/phd.1 target/armv7-unknown-linux-gnueabihf/release && cd target/armv7-unknown-linux-gnueabihf/release && tar zcvf phd-${{ steps.get_version.outputs.VERSION }}-linux-armv7.tgz phd phd.1 47 | - name: Upload Artifact 48 | uses: actions/upload-artifact@v1 49 | with: 50 | name: phd-linux-armv7 51 | path: target/armv7-unknown-linux-gnueabihf/release/phd-${{ steps.get_version.outputs.VERSION }}-linux-armv7.tgz 52 | 53 | build_linux: 54 | name: Build for Linux x86_64 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v2 59 | - name: Cache cargo registry 60 | uses: actions/cache@v3 61 | with: 62 | path: ~/.cargo/registry 63 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 64 | - name: Cache cargo index 65 | uses: actions/cache@v3 66 | with: 67 | path: ~/.cargo/git 68 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 69 | - name: Cache cargo build 70 | uses: actions/cache@v3 71 | with: 72 | path: target 73 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 74 | - name: Build release 75 | run: cargo build --locked --release 76 | - name: Get current version 77 | id: get_version 78 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 79 | - name: Package Binary 80 | run: cp doc/phd.1 target/release && cd target/release && tar zcvf phd-${{ steps.get_version.outputs.VERSION }}-linux-x86_64.tgz phd phd.1 81 | - name: Upload Artifact 82 | uses: actions/upload-artifact@v1 83 | with: 84 | name: phd-linux-x86_64 85 | path: target/release/phd-${{ steps.get_version.outputs.VERSION }}-linux-x86_64.tgz 86 | 87 | build_macos: 88 | name: Build for macOS 89 | runs-on: macos-latest 90 | steps: 91 | - uses: actions/checkout@v1 92 | - name: Cache cargo registry 93 | uses: actions/cache@v3 94 | with: 95 | path: ~/.cargo/registry 96 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 97 | - name: Cache cargo index 98 | uses: actions/cache@v3 99 | with: 100 | path: ~/.cargo/git 101 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 102 | - name: Cache cargo build 103 | uses: actions/cache@v3 104 | with: 105 | path: target 106 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 107 | - name: Setup Toolchain 108 | uses: actions-rs/toolchain@v1 109 | with: 110 | profile: minimal 111 | toolchain: stable 112 | override: true 113 | - name: Build release 114 | uses: actions-rs/cargo@v1 115 | with: 116 | command: build 117 | args: --locked --release 118 | - name: Get current version 119 | id: get_version 120 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 121 | - name: Package Binary 122 | run: cp doc/phd.1 target/release && cd target/release && zip -r phd-${{ steps.get_version.outputs.VERSION }}-macos.zip phd phd.1 123 | - name: Upload Artifact 124 | uses: actions/upload-artifact@v1 125 | with: 126 | name: phd-macos 127 | path: target/release/phd-${{ steps.get_version.outputs.VERSION }}-macos.zip 128 | 129 | create: 130 | name: Create Release 131 | needs: [build_armv7, build_linux, build_macos] 132 | runs-on: ubuntu-latest 133 | steps: 134 | - name: Checkout 135 | uses: actions/checkout@v2 136 | - name: Get current version 137 | id: get_version 138 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 139 | - name: Download macOS artifact 140 | uses: actions/download-artifact@v1 141 | with: 142 | name: phd-macos 143 | - name: Download Linux (x86_64) artifact 144 | uses: actions/download-artifact@v1 145 | with: 146 | name: phd-linux-x86_64 147 | - name: Download Linux (armv7) artifact 148 | uses: actions/download-artifact@v1 149 | with: 150 | name: phd-linux-armv7 151 | - name: Create Release 152 | uses: softprops/action-gh-release@v1 153 | with: 154 | draft: true 155 | prerelease: true 156 | files: | 157 | phd-macos/phd-${{ steps.get_version.outputs.VERSION }}-macos.zip 158 | phd-linux-x86_64/phd-${{ steps.get_version.outputs.VERSION }}-linux-x86_64.tgz 159 | phd-linux-armv7/phd-${{ steps.get_version.outputs.VERSION }}-linux-armv7.tgz 160 | body_path: CHANGELOG.md 161 | env: 162 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 163 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | phd 4 | phd.log 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.1.15 2 | 3 | - Update `alphanumeric-sort` dependency so `cargo install phd` works again. 4 | - Recommend `cargo install phd --locked` in the README 5 | 6 | ## v0.1.14 7 | 8 | - If the `NO_COLOR` env variable is set, colors won't be printed to 9 | the log. Same as starting with `--no-color`. 10 | See https://no-color.org/. 11 | 12 | ## v0.1.13 13 | 14 | - Added `--no-color` command line option to not display color when 15 | logging. 16 | - Slight change to binding behavior: if `-p` is passed without `-b`, 17 | we'll try to bind to that port. To this easier: `phd -p 7777` 18 | - Accept `?` as query string indicator, not just `TAB`. See #3. 19 | 20 | ## v0.1.12 21 | 22 | `phd` now uses `-b` and `--bind` to set the host and port to 23 | bind to. `-p` and `-h` are now strictly for URL generation. 24 | 25 | This should hopefully make it easier to run `phd` behind a 26 | proxy and still generate proper links. 27 | 28 | Thanks to @bradfier for the patch! 29 | 30 | ## v0.1.11 31 | 32 | `phd` now ships with a basic manual! 33 | 34 | It will be installed via homebrew and (eventually) AUR. 35 | 36 | For now you can view it by cloning the repository and running: 37 | 38 | man ./doc/phd.1 39 | 40 | Enjoy! 41 | 42 | ## v0.1.10 43 | 44 | `phd` can now render a single page to stdout, instead of starting 45 | as a server. Those of us in the biz refer to this as "serverless". 46 | 47 | For example, if your Gopher site lives in `/srv/gopher` and you want 48 | to render the main page, just run: 49 | 50 | phd -r / /srv/gopher 51 | 52 | This will print the raw Gopher menu to stdout! 53 | 54 | To view the "/about" page, pass that selector: 55 | 56 | phd -r / /srv/gopher 57 | 58 | Edge computing is now Gopher-scale! Enjoy! 59 | 60 | ## v0.1.9 61 | 62 | Switch to using GitHub Actions for release automation. 63 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "alphanumeric-sort" 7 | version = "1.4.4" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "77e9c9abb82613923ec78d7a461595d52491ba7240f3c64c0bbe0e6d98e0fce0" 10 | 11 | [[package]] 12 | name = "content_inspector" 13 | version = "0.2.4" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" 16 | dependencies = [ 17 | "memchr", 18 | ] 19 | 20 | [[package]] 21 | name = "hermit-abi" 22 | version = "0.1.5" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "f629dc602392d3ec14bfc8a09b5e644d7ffd725102b48b81e59f90f2633621d7" 25 | dependencies = [ 26 | "libc", 27 | ] 28 | 29 | [[package]] 30 | name = "libc" 31 | version = "0.2.66" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" 34 | 35 | [[package]] 36 | name = "memchr" 37 | version = "2.2.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" 40 | 41 | [[package]] 42 | name = "num_cpus" 43 | version = "1.11.1" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "76dac5ed2a876980778b8b85f75a71b6cbf0db0b1232ee12f826bccb00d09d72" 46 | dependencies = [ 47 | "hermit-abi", 48 | "libc", 49 | ] 50 | 51 | [[package]] 52 | name = "phd" 53 | version = "0.1.15" 54 | dependencies = [ 55 | "alphanumeric-sort", 56 | "content_inspector", 57 | "shell-escape", 58 | "threadpool", 59 | ] 60 | 61 | [[package]] 62 | name = "shell-escape" 63 | version = "0.1.4" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "170a13e64f2a51b77a45702ba77287f5c6829375b04a69cf2222acd17d0cfab9" 66 | 67 | [[package]] 68 | name = "threadpool" 69 | version = "1.7.1" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "e2f0c90a5f3459330ac8bc0d2f879c693bb7a2f59689c1083fc4ef83834da865" 72 | dependencies = [ 73 | "num_cpus", 74 | ] 75 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "phd" 3 | version = "0.1.15" 4 | authors = ["chris west "] 5 | license = "MIT" 6 | edition = "2018" 7 | description = "an esoteric gopher server" 8 | readme = "README.md" 9 | repository = "https://github.com/xvxx/phd" 10 | keywords = ["gopher", "server", "daemon"] 11 | exclude = [ 12 | "img/*" 13 | ] 14 | 15 | [profile.release] 16 | lto = true 17 | codegen-units = 1 18 | panic = 'abort' 19 | opt-level = 'z' # Optimize for size. 20 | 21 | [package.metadata.release] 22 | pre-release-replacements = [ 23 | {file="README.md", search="phd-v\\d+\\.\\d+\\.\\d+-", replace="{{crate_name}}-v{{version}}-"}, 24 | {file="README.md", search="/v\\d+\\.\\d+\\.\\d+/", replace="/v{{version}}/"}, 25 | {file="CHANGELOG.md", search="\\d+\\.\\d+\\.\\d+-dev", replace="{{version}}"}, 26 | ] 27 | 28 | [dependencies] 29 | content_inspector = "0.2.4" 30 | threadpool = "1.7.1" 31 | alphanumeric-sort = "1.4" 32 | shell-escape = "0.1.4" 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Authors of phd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | Software), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Simple, stupid makefile to make phd 2 | 3 | TARGET = phd 4 | RELEASE = target/release/$(TARGET) 5 | DEBUG = target/debug/$(TARGET) 6 | SOURCES = $(wildcard src/*.rs src/**/*.rs) 7 | PREFIX = $(DESTDIR)/usr/local 8 | BINDIR = $(PREFIX)/bin 9 | 10 | .PHONY: release debug install uninstall clean 11 | 12 | # Default target. Build release binary. 13 | release: $(RELEASE) 14 | 15 | # Binary with debugging info. 16 | debug: $(DEBUG) 17 | 18 | # Install locally. 19 | install: $(RELEASE) 20 | install $(RELEASE) $(BINDIR)/$(TARGET) 21 | 22 | # Uninstall locally. 23 | uninstall: $(RELEASE) 24 | -rm $(BINDIR)/$(TARGET) 25 | 26 | # Remove build directory. 27 | clean: 28 | -rm -rf target 29 | 30 | # Build the release version 31 | $(RELEASE): $(SOURCES) 32 | cargo build --release 33 | 34 | # Build the debug version 35 | $(DEBUG): $(SOURCES) 36 | cargo build 37 | 38 | # Build manual 39 | .PHONY: manual 40 | manual: doc/phd.1 41 | 42 | doc/phd.1: doc/phd.1.md scdoc 43 | scdoc < doc/phd.1.md > doc/phd.1 44 | 45 | # Must have scdoc installed to build manual. 46 | scdoc: 47 | @which scdoc || (echo "scdoc(1) not found."; \ 48 | echo "please install to build the manual: https://repology.org/project/scdoc"; exit 1) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 8 |

9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |

21 | 22 | --- 23 | 24 | `phd` is a small, easy-to-use gopher server. 25 | 26 | Point it at a directory and it'll serve up all the text files, 27 | sub-directories, and binary files over Gopher. Any `.gph` files will 28 | be served up as [gophermaps][map] and executable `.gph` files will be 29 | run as a program with their output served to the client, like the 30 | glorious cgi-bin days of yore! 31 | 32 | ### ~ special files ~ 33 | 34 | - **`header.gph`**: If it exists in a directory, its content will be 35 | shown above the directory's content. Put ASCII art in it. 36 | - **`footer.gph`**: Same, but will be shown below a directory's content. 37 | - **`index.gph`**: Completely replaces a directory's content with what's 38 | in this file. 39 | - **`??.gph`**: Visiting `gopher://yoursite/1/dog/` will try to render 40 | `dog.gph` from disk. Visiting `/1/dog.gph` will render the raw 41 | content of the .gph file. 42 | - **`.reverse`**: If this exists, the directory contents will be listed 43 | in reverse alphanumeric order. Useful for phloggin', if you date 44 | your posts. 45 | 46 | Any line in a `.gph` file that doesn't contain tabs (`\t`) will get an 47 | `i` automatically prefixed, turning it into a Gopher information item. 48 | 49 | For your convenience, phd supports **[geomyidae][gmi]** syntax for 50 | creating links: 51 | 52 | This is an info line. 53 | [1|This is a link|/help|server|port] 54 | [h|URL Link|URL:https://noogle.com] 55 | 56 | `server` and `port` will get translated into the server and port of 57 | the actively running server, eg `localhost` and `7070`. 58 | 59 | Any line containing a tab character (`\t`) will be sent as-is to the 60 | client, meaning you can write and serve up raw Gophermap files too. 61 | 62 | ### ~ dynamic content ~ 63 | 64 | Any `.gph` file that is marked **executable** with be run as if it 65 | were a standalone program and its output will be sent to the client. 66 | It will be passed three arguments: the query string (if any), the 67 | server's hostname, and the current port. Do with them what you will. 68 | 69 | For example: 70 | 71 | $ cat echo.gph 72 | #!/bin/sh 73 | echo "Hi, world! You said:" $1 74 | echo "1Visit Gopherpedia / gopherpedia.com 70" 75 | 76 | Then: 77 | 78 | $ gopher-client gopher://localhost/1/echo?something 79 | [INFO] Hi, world! You said: something 80 | [LINK] Visit Gopherpedia 81 | 82 | Or more seriously: 83 | 84 | $ cat figlet.gph 85 | #!/bin/sh 86 | figlet $1 87 | 88 | then: 89 | 90 | $ gopher-client gopher://localhost/1/figlet?hi gopher 91 | [INFO] _ _ _ 92 | [INFO] | |__ (_) __ _ ___ _ __ | |__ ___ _ __ 93 | [INFO] | '_ \| | / _` |/ _ \| '_ \| '_ \ / _ \ '__| 94 | [INFO] | | | | | | (_| | (_) | |_) | | | | __/ | 95 | [INFO] |_| |_|_| \__, |\___/| .__/|_| |_|\___|_| 96 | [INFO] |___/ |_| 97 | 98 | ### ~ ruby on rails ~ 99 | 100 | `sh` is fun, but for serious work you need a serious scripting 101 | language like Ruby or PHP or Node.JS: 102 | 103 | $ cat sizes.gph 104 | #!/usr/bin/env ruby 105 | 106 | def filesize(file) 107 | (size=File.size file) > (k=1024) ? "#{size/k}K" : "#{size}B" 108 | end 109 | 110 | puts "~ file sizes ~" 111 | spaces = 20 112 | Dir[__dir__ + "/*"].each do |entry| 113 | name = File.basename entry 114 | puts "#{name}#{' ' * (spaces - name.length)}#{filesize entry}" 115 | end 116 | 117 | Now you can finally share the file sizes of a directory with the world 118 | of Gopher! 119 | 120 | $ phetch -r 0.0.0.0:7070/1/sizes 121 | i~ file sizes ~ (null) 127.0.0.1 7070 122 | iCargo.toml 731B (null) 127.0.0.1 7070 123 | iLICENSE 1K (null) 127.0.0.1 7070 124 | iMakefile 724B (null) 127.0.0.1 7070 125 | itarget 288B (null) 127.0.0.1 7070 126 | iphd 248K (null) 127.0.0.1 7070 127 | iCargo.lock 2K (null) 127.0.0.1 7070 128 | iREADME.md 4K (null) 127.0.0.1 7070 129 | img 96B (null) 127.0.0.1 7070 130 | isizes.gph 276B (null) 127.0.0.1 7070 131 | isrc 224B (null) 127.0.0.1 7070 132 | 133 | ## ~ usage ~ 134 | 135 | Usage: 136 | 137 | phd [options] 138 | 139 | Options: 140 | 141 | -r, --render SELECTOR Render and print SELECTOR to stdout only. 142 | -h, --host HOST Hostname for links. [Default: {host}] 143 | -p, --port PORT Port for links. [Default: {port}] 144 | -b, --bind ADDRESS Socket address to bind to. [Default: {bind}] 145 | --no-color Don't show colors in log messages. 146 | 147 | Other flags: 148 | 149 | -h, --help Print this screen. 150 | -v, --version Print phd version. 151 | 152 | Examples: 153 | 154 | phd ./path/to/site # Serve directory over port 7070. 155 | phd -p 70 docs # Serve 'docs' directory on port 70 156 | phd -h gopher.com # Serve current directory over port 7070 157 | # using hostname 'gopher.com' 158 | phd -r / ./site # Render local gopher site to stdout. 159 | 160 | ## ~ installation ~ 161 | 162 | On macOS you can install with [Homebrew](https://brew.sh/): 163 | 164 | brew install xvxx/code/phd 165 | 166 | Binaries for Linux, Mac, and Raspberry Pi are available at 167 | gopher://phkt.io/1/releases/phd and https://github.com/xvxx/phd/releases: 168 | 169 | - [phd-v0.1.15-linux-x86_64.tar.gz][0] 170 | - [phd-v0.1.15-linux-armv7.tar.gz (Raspberry Pi)][1] 171 | - [phd-v0.1.15-macos.zip][2] 172 | 173 | Just unzip/untar the `phd` program into your `$PATH` and get going! 174 | 175 | If you have **[cargo][rustup]**, you can install the crate directly: 176 | 177 | cargo install phd --locked 178 | 179 | ## ~ development ~ 180 | 181 | cargo run -- ./path/to/gopher/site 182 | 183 | ## ~ resources ~ 184 | 185 | - gopher://bitreich.org/1/scm/geomyidae/files.gph 186 | - https://github.com/gophernicus/gophernicus/blob/master/README.Gophermap 187 | - https://gopher.zone/posts/how-to-gophermap/ 188 | - [rfc 1436](https://tools.ietf.org/html/rfc1436) 189 | 190 | ## ~ todo ~ 191 | 192 | - [ ] systemd config, or something 193 | - [ ] TLS support 194 | - [ ] user input sanitization tests 195 | 196 | ## ~ status ~ 197 | 198 | phd is no longer under active development, but the latest version works great. 199 | 200 | [0]: https://github.com/xvxx/phd/releases/download/v0.1.15/phd-v0.1.15-linux-x86_64.tar.gz 201 | [1]: https://github.com/xvxx/phd/releases/download/v0.1.15/phd-v0.1.15-linux-armv7.tar.gz 202 | [2]: https://github.com/xvxx/phd/releases/download/v0.1.15/phd-v0.1.15-macos.zip 203 | [map]: https://en.wikipedia.org/wiki/Gopher_(protocol)#Source_code_of_a_menu 204 | [gmi]: http://r-36.net/scm/geomyidae/ 205 | [rustup]: https://rustup.rs 206 | -------------------------------------------------------------------------------- /doc/phd.1: -------------------------------------------------------------------------------- 1 | .\" Generated by scdoc 1.11.0 2 | .\" Complete documentation for this program is not available as a GNU info page 3 | .ie \n(.g .ds Aq \(aq 4 | .el .ds Aq ' 5 | .nh 6 | .ad l 7 | .\" Begin generated content: 8 | .TH "PHD" "1" "2020-06-27" 9 | .P 10 | .SH NAME 11 | .P 12 | phd - an estoeric gopher server 13 | .P 14 | .SH SYNOPSIS 15 | .P 16 | \fBphd\fR [\fIOPTIONS\fR] [\fISITE ROOT\fR] 17 | .P 18 | .SH DESCRIPTION 19 | .P 20 | \fBphd\fR is a small, easy-to-use gopher server. 21 | .P 22 | Point it at a directory and it'll serve up all the text files, 23 | sub-directories, and binary files over Gopher. Any \fB.gph\fR files will 24 | be served up as Gophermaps and executable \fB.gph\fR files will be 25 | run as a program with their output served to the client, like the 26 | glorious cgi-bin days of yore! 27 | .P 28 | Usually \fBphd\fR is started with a path to your Gopher site: 29 | .P 30 | .RS 4 31 | phd /srv/gopher 32 | .P 33 | .RE 34 | If no path is given, \fBphd\fR will use the current directory as the root 35 | of your Gopher site. 36 | .P 37 | .SH OPTIONS 38 | .P 39 | \fB-r\fR \fISELECTOR\fR, \fB--render\fR \fISELECTOR\fR 40 | .P 41 | .RS 4 42 | Rather than start as a server, render the \fISELECTOR\fR of the site using the options provided and print the raw response to \fBSTDOUT\fR. 43 | .P 44 | .RE 45 | \fB-b\fR \fIADDRESS\fR, \fB--bind\fR \fIADDRESS\fR 46 | .RS 4 47 | Set the socket address to bind to, e.g. \fB127.0.0.1:7070\fR 48 | .P 49 | .RE 50 | \fB-p\fR \fIPORT\fR, \fB--port\fR \fIPORT\fR 51 | .RS 4 52 | Set the \fIPORT\fR to use when generating Gopher links. 53 | .P 54 | .RE 55 | \fB-h\fR \fIHOST\fR, \fB--host\fR \fIHOST\fR 56 | .RS 4 57 | Set the \fIHOST\fR to use when generating Gopher links. 58 | .P 59 | .RE 60 | \fB-h\fR, \fB--help\fR 61 | .RS 4 62 | Print a help summary and exit. 63 | .P 64 | .RE 65 | \fB-v\fR, \fB--version\fR 66 | .RS 4 67 | Print version information and exit. 68 | .P 69 | .RE 70 | .SH SPECIAL FILES 71 | .P 72 | The following files have special behavior when present in a directory 73 | that \fBphd\fR is tasked with serving: 74 | .P 75 | \fBheader.gph\fR 76 | .RS 4 77 | If it exists in a directory, its content will be shown above the directory's content. Put ASCII art in it. 78 | .P 79 | .RE 80 | \fBfooter.gph\fR 81 | .RS 4 82 | Same, but will be shown below a directory's content. 83 | .P 84 | .RE 85 | \fBindex.gph\fR 86 | .RS 4 87 | Completely replaces a directory's content with what's in this file. 88 | .P 89 | .RE 90 | \fB??.gph\fR 91 | .RS 4 92 | Visiting \fBgopher://yoursite/1/dog/\fR will try to render \fBdog.gph\fR from disk. Visiting \fB/1/dog.gph\fR will render the raw content of the .gph file. 93 | .P 94 | .RE 95 | \fB.reverse\fR 96 | .RS 4 97 | If this exists, the directory contents will be listed in reverse alphanumeric order. Useful for phloggin', if you date your posts. 98 | .P 99 | .RE 100 | .SH GOPHERMAP SYNTAX 101 | .P 102 | Any line in a \fB.gph\fR file that doesn't contain tabs (\fBt\fR) will get an 103 | \fBi\fR automatically prefixed, turning it into a Gopher information item. 104 | .P 105 | For your convenience, phd supports \fBgeomyidae\fR syntax for 106 | creating links: 107 | .P 108 | .nf 109 | .RS 4 110 | This is an info line\&. 111 | [1|This is a link|/help|server|port] 112 | [h|URL Link|URL:https://noogle\&.com] 113 | .fi 114 | .RE 115 | .P 116 | \fBserver\fR and \fBport\fR will get translated into the server and port of 117 | the actively running server, eg \fBlocalhost\fR and \fB7070\fR. 118 | .P 119 | Any line containing a tab character (\fBt\fR) will be sent as-is to the 120 | client, meaning you can write and serve up raw Gophermap files too. 121 | .P 122 | .SH DYNAMIC CONTENT 123 | .P 124 | Any \fB.gph\fR file that is marked \fBexecutable\fR with be run as if it 125 | were a standalone program and its output will be sent to the client. 126 | It will be passed three arguments: the query string (if any), the 127 | server's hostname, and the current port. Do with them what you will. 128 | .P 129 | For example: 130 | .P 131 | .nf 132 | .RS 4 133 | $ cat echo\&.gph 134 | #!/bin/sh 135 | echo "Hi, world! You said:" $1 136 | echo "1Visit Gopherpedia / gopherpedia\&.com 70" 137 | .fi 138 | .RE 139 | .P 140 | Then: 141 | .P 142 | .nf 143 | .RS 4 144 | $ gopher-client gopher://localhost/1/echo?something 145 | [INFO] Hi, world! You said: something 146 | [LINK] Visit Gopherpedia 147 | .fi 148 | .RE 149 | .P 150 | Or more seriously: 151 | .P 152 | .nf 153 | .RS 4 154 | $ cat figlet\&.gph 155 | #!/bin/sh 156 | figlet $1 157 | .fi 158 | .RE 159 | .P 160 | then: 161 | .P 162 | .nf 163 | .RS 4 164 | $ gopher-client gopher://localhost/1/figlet?hi gopher 165 | [INFO] _ _ _ 166 | [INFO] | |__ (_) __ _ ___ _ __ | |__ ___ _ __ 167 | [INFO] | '_ | | / _` |/ _ | '_ | '_ / _ '__| 168 | [INFO] | | | | | | (_| | (_) | |_) | | | | __/ | 169 | [INFO] |_| |_|_| __, |___/| \&.__/|_| |_|___|_| 170 | [INFO] |___/ |_| 171 | .fi 172 | .RE 173 | .P 174 | .SS RESOURCES 175 | .P 176 | geomyidae source code 177 | .RS 4 178 | gopher://bitreich.org/1/scm/geomyidae/files.gph 179 | .P 180 | .RE 181 | Example Gophermap 182 | .RS 4 183 | https://github.com/gophernicus/gophernicus/blob/master/README.Gophermap 184 | .P 185 | .RE 186 | Gophermaps 187 | .RS 4 188 | https://gopher.zone/posts/how-to-gophermap/ 189 | .P 190 | .RE 191 | RFC 1436: 192 | .RS 4 193 | https://tools.ietf.org/html/rfc1436 194 | .P 195 | .RE 196 | .SH ABOUT 197 | .P 198 | \fBphd\fR is maintained by chris west and released under the MIT license. 199 | .P 200 | phd's Gopher hole: 201 | .RS 4 202 | \fIgopher://phkt.io/1/phd\fR 203 | .RE 204 | phd's webpage: 205 | .RS 4 206 | \fIhttps://github.com/xvxx/phd\fR 207 | -------------------------------------------------------------------------------- /doc/phd.1.md: -------------------------------------------------------------------------------- 1 | PHD(1) 2 | 3 | # NAME 4 | 5 | phd - an estoeric gopher server 6 | 7 | # SYNOPSIS 8 | 9 | *phd* [_OPTIONS_] [_SITE ROOT_] 10 | 11 | # DESCRIPTION 12 | 13 | *phd* is a small, easy-to-use gopher server. 14 | 15 | Point it at a directory and it'll serve up all the text files, 16 | sub-directories, and binary files over Gopher. Any *.gph* files will 17 | be served up as Gophermaps and executable *.gph* files will be 18 | run as a program with their output served to the client, like the 19 | glorious cgi-bin days of yore! 20 | 21 | Usually *phd* is started with a path to your Gopher site: 22 | 23 | phd /srv/gopher 24 | 25 | If no path is given, *phd* will use the current directory as the root 26 | of your Gopher site. 27 | 28 | # OPTIONS 29 | 30 | *-r* _SELECTOR_, *--render* _SELECTOR_ 31 | 32 | Rather than start as a server, render the _SELECTOR_ of the site using the options provided and print the raw response to *STDOUT*. 33 | 34 | *-b* _ADDRESS_, *--bind* _ADDRESS_ 35 | Set the socket address to bind to, e.g. *127.0.0.1:7070* 36 | 37 | *-p* _PORT_, *--port* _PORT_ 38 | Set the _PORT_ to use when generating Gopher links. 39 | 40 | *-h* _HOST_, *--host* _HOST_ 41 | Set the _HOST_ to use when generating Gopher links. 42 | 43 | *-h*, *--help* 44 | Print a help summary and exit. 45 | 46 | *-v*, *--version* 47 | Print version information and exit. 48 | 49 | # SPECIAL FILES 50 | 51 | The following files have special behavior when present in a directory 52 | that *phd* is tasked with serving: 53 | 54 | *header.gph* 55 | If it exists in a directory, its content will be shown above the directory's content. Put ASCII art in it. 56 | 57 | *footer.gph* 58 | Same, but will be shown below a directory's content. 59 | 60 | *index.gph* 61 | Completely replaces a directory's content with what's in this file. 62 | 63 | *??.gph* 64 | Visiting *gopher://yoursite/1/dog/* will try to render *dog.gph* from disk. Visiting */1/dog.gph* will render the raw content of the .gph file. 65 | 66 | *.reverse* 67 | If this exists, the directory contents will be listed in reverse alphanumeric order. Useful for phloggin', if you date your posts. 68 | 69 | # GOPHERMAP SYNTAX 70 | 71 | Any line in a *.gph* file that doesn't contain tabs (*\t*) will get an 72 | *i* automatically prefixed, turning it into a Gopher information item. 73 | 74 | For your convenience, phd supports *geomyidae* syntax for 75 | creating links: 76 | 77 | ``` 78 | This is an info line. 79 | [1|This is a link|/help|server|port] 80 | [h|URL Link|URL:https://noogle.com] 81 | ``` 82 | 83 | *server* and *port* will get translated into the server and port of 84 | the actively running server, eg *localhost* and *7070*. 85 | 86 | Any line containing a tab character (*\t*) will be sent as-is to the 87 | client, meaning you can write and serve up raw Gophermap files too. 88 | 89 | # DYNAMIC CONTENT 90 | 91 | Any *.gph* file that is marked *executable* with be run as if it 92 | were a standalone program and its output will be sent to the client. 93 | It will be passed three arguments: the query string (if any), the 94 | server's hostname, and the current port. Do with them what you will. 95 | 96 | For example: 97 | 98 | ``` 99 | $ cat echo.gph 100 | #!/bin/sh 101 | echo "Hi, world! You said:" $1 102 | echo "1Visit Gopherpedia / gopherpedia.com 70" 103 | ``` 104 | 105 | Then: 106 | 107 | ``` 108 | $ gopher-client gopher://localhost/1/echo?something 109 | [INFO] Hi, world! You said: something 110 | [LINK] Visit Gopherpedia 111 | ``` 112 | 113 | Or more seriously: 114 | 115 | ``` 116 | $ cat figlet.gph 117 | #!/bin/sh 118 | figlet $1 119 | ``` 120 | 121 | then: 122 | 123 | ``` 124 | $ gopher-client gopher://localhost/1/figlet?hi gopher 125 | [INFO] _ _ _ 126 | [INFO] | |__ (_) __ _ ___ _ __ | |__ ___ _ __ 127 | [INFO] | '_ \| | / _` |/ _ \| '_ \| '_ \ / _ \ '__| 128 | [INFO] | | | | | | (_| | (_) | |_) | | | | __/ | 129 | [INFO] |_| |_|_| \__, |\___/| .__/|_| |_|\___|_| 130 | [INFO] |___/ |_| 131 | ``` 132 | 133 | ## RESOURCES 134 | 135 | geomyidae source code 136 | gopher://bitreich.org/1/scm/geomyidae/files.gph 137 | 138 | Example Gophermap 139 | https://github.com/gophernicus/gophernicus/blob/master/README.Gophermap 140 | 141 | Gophermaps 142 | https://gopher.zone/posts/how-to-gophermap/ 143 | 144 | RFC 1436: 145 | https://tools.ietf.org/html/rfc1436 146 | 147 | # ABOUT 148 | 149 | *phd* is maintained by chris west and released under the MIT license. 150 | 151 | phd's Gopher hole: 152 | _gopher://phkt.io/1/phd_ 153 | phd's webpage: 154 | _https://github.com/xvxx/phd_ 155 | -------------------------------------------------------------------------------- /header.gph: -------------------------------------------------------------------------------- 1 | 2 | / | 3 | ___ (___ ___| 4 | | )| )| ) 5 | |__/ | / |__/ 6 | | 7 | 8 | ~ browse source ~ 9 | (updated nightly) 10 | 11 | -------------------------------------------------------------------------------- /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/img/logo.png -------------------------------------------------------------------------------- /src/color.rs: -------------------------------------------------------------------------------- 1 | //! Cheesy way to easily wrap text in console colors. 2 | //! Example: 3 | //! ``` 4 | //! use phd::color; 5 | //! println!("{}Error: {}{}", color::Red, "Something broke.", color::Reset); 6 | //! ``` 7 | 8 | use std::{ 9 | fmt, 10 | sync::atomic::{AtomicBool, Ordering as AtomicOrdering}, 11 | }; 12 | 13 | /// Whether to show colors or not. 14 | /// Defaults to true. 15 | static SHOW_COLORS: AtomicBool = AtomicBool::new(true); 16 | 17 | /// Hide colors. 18 | pub fn hide_colors() { 19 | SHOW_COLORS.swap(false, AtomicOrdering::Relaxed); 20 | } 21 | 22 | /// Are we showing colors are not? 23 | pub fn showing_colors() -> bool { 24 | SHOW_COLORS.load(AtomicOrdering::Relaxed) 25 | } 26 | 27 | macro_rules! color { 28 | ($t:ident, $code:expr) => { 29 | #[allow(missing_docs)] 30 | pub struct $t; 31 | impl fmt::Display for $t { 32 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 33 | if showing_colors() { 34 | write!(f, "\x1b[{}m", $code) 35 | } else { 36 | write!(f, "") 37 | } 38 | } 39 | } 40 | }; 41 | } 42 | 43 | color!(Black, 90); 44 | color!(Red, 91); 45 | color!(Green, 92); 46 | color!(Yellow, 93); 47 | color!(Blue, 94); 48 | color!(Magenta, 95); 49 | color!(Cyan, 96); 50 | color!(White, 97); 51 | 52 | color!(DarkBlack, 30); 53 | color!(DarkRed, 31); 54 | color!(DarkGreen, 32); 55 | color!(DarkYellow, 33); 56 | color!(DarkBlue, 34); 57 | color!(DarkMagenta, 35); 58 | color!(DarkCyan, 36); 59 | color!(DarkWhite, 37); 60 | 61 | color!(Reset, 0); 62 | color!(Bold, 1); 63 | color!(Underline, 4); 64 | -------------------------------------------------------------------------------- /src/gopher.rs: -------------------------------------------------------------------------------- 1 | //! Gopher type "borrowed" from phetch. 2 | use std::fmt; 3 | 4 | /// Gopher types are defined according to RFC 1436. 5 | #[allow(missing_docs)] 6 | #[derive(Copy, Clone, PartialEq, Debug)] 7 | pub enum Type { 8 | Text, // 0 9 | Menu, // 1 10 | CSOEntity, // 2 11 | Error, // 3 12 | Binhex, // 4 13 | DOSFile, // 5 14 | UUEncoded, // 6 15 | Search, // 7 16 | Telnet, // 8 17 | Binary, // 9 18 | Mirror, // + 19 | GIF, // g 20 | Telnet3270, // T 21 | HTML, // h 22 | Image, // I 23 | PNG, // p 24 | Info, // i 25 | Sound, // s 26 | Document, // d 27 | } 28 | 29 | impl Type { 30 | /// Is this an info line? 31 | pub fn is_info(self) -> bool { 32 | self == Type::Info 33 | } 34 | 35 | /// Text document? 36 | pub fn is_text(self) -> bool { 37 | self == Type::Text 38 | } 39 | 40 | /// HTML link? 41 | pub fn is_html(self) -> bool { 42 | self == Type::HTML 43 | } 44 | 45 | /// Telnet link? 46 | pub fn is_telnet(self) -> bool { 47 | self == Type::Telnet 48 | } 49 | 50 | /// Is this a link, ie something we can navigate to or open? 51 | pub fn is_link(self) -> bool { 52 | !self.is_info() 53 | } 54 | 55 | /// Is this something we can download? 56 | pub fn is_download(self) -> bool { 57 | match self { 58 | Type::Binhex 59 | | Type::DOSFile 60 | | Type::UUEncoded 61 | | Type::Binary 62 | | Type::GIF 63 | | Type::Image 64 | | Type::PNG 65 | | Type::Sound 66 | | Type::Document => true, 67 | _ => false, 68 | } 69 | } 70 | 71 | /// Gopher Item Type to RFC char. 72 | pub fn to_char(self) -> char { 73 | match self { 74 | Type::Text => '0', 75 | Type::Menu => '1', 76 | Type::CSOEntity => '2', 77 | Type::Error => '3', 78 | Type::Binhex => '4', 79 | Type::DOSFile => '5', 80 | Type::UUEncoded => '6', 81 | Type::Search => '7', 82 | Type::Telnet => '8', 83 | Type::Binary => '9', 84 | Type::Mirror => '+', 85 | Type::GIF => 'g', 86 | Type::Telnet3270 => 'T', 87 | Type::HTML => 'h', 88 | Type::Image => 'I', 89 | Type::PNG => 'p', 90 | Type::Info => 'i', 91 | Type::Sound => 's', 92 | Type::Document => 'd', 93 | } 94 | } 95 | 96 | /// Create a Gopher Item Type from its RFC char code. 97 | pub fn from(c: char) -> Option { 98 | Some(match c { 99 | '0' => Type::Text, 100 | '1' => Type::Menu, 101 | '2' => Type::CSOEntity, 102 | '3' => Type::Error, 103 | '4' => Type::Binhex, 104 | '5' => Type::DOSFile, 105 | '6' => Type::UUEncoded, 106 | '7' => Type::Search, 107 | '8' => Type::Telnet, 108 | '9' => Type::Binary, 109 | '+' => Type::Mirror, 110 | 'g' => Type::GIF, 111 | 'T' => Type::Telnet3270, 112 | 'h' => Type::HTML, 113 | 'I' => Type::Image, 114 | 'p' => Type::PNG, 115 | 'i' => Type::Info, 116 | 's' => Type::Sound, 117 | 'd' => Type::Document, 118 | _ => return None, 119 | }) 120 | } 121 | } 122 | 123 | impl fmt::Display for Type { 124 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 125 | write!(f, "{}", self.to_char()) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! phd is a small, easy-to-use Gopher server that tries to make 2 | //! serving up a Gopher site quick and painless. Best used for local 3 | //! development or low traffic Gopher sites. 4 | 5 | #![allow(unused_must_use)] 6 | #![warn(absolute_paths_not_starting_with_crate)] 7 | #![warn(explicit_outlives_requirements)] 8 | #![warn(unreachable_pub)] 9 | #![warn(deprecated_in_future)] 10 | #![warn(missing_docs)] 11 | #![allow(clippy::while_let_on_iterator)] 12 | 13 | pub mod color; 14 | pub mod gopher; 15 | pub mod request; 16 | pub mod server; 17 | 18 | pub use crate::request::Request; 19 | 20 | /// Alias for a generic Result type. 21 | pub type Result = std::result::Result>; 22 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use phd; 2 | use std::process; 3 | 4 | const DEFAULT_BIND: &str = "[::]:7070"; 5 | const DEFAULT_HOST: &str = "127.0.0.1"; 6 | const DEFAULT_PORT: u16 = 7070; 7 | 8 | fn main() { 9 | let args = std::env::args().skip(1).collect::>(); 10 | let mut args = args.iter(); 11 | let mut root = "."; 12 | let mut addr = DEFAULT_BIND; 13 | let mut host = DEFAULT_HOST; 14 | let mut port = DEFAULT_PORT; 15 | let mut render = ""; 16 | 17 | while let Some(arg) = args.next() { 18 | match arg.as_ref() { 19 | "--version" | "-v" | "-version" => return print_version(), 20 | "--help" | "-help" => return print_help(), 21 | "--no-color" | "-no-color" => phd::color::hide_colors(), 22 | "--render" | "-render" | "-r" => { 23 | if let Some(path) = args.next() { 24 | render = path; 25 | } else { 26 | render = "/"; 27 | } 28 | } 29 | "--bind" | "-b" | "-bind" => { 30 | if let Some(a) = args.next() { 31 | addr = a 32 | } 33 | } 34 | "--port" | "-p" | "-port" => { 35 | if let Some(p) = args.next() { 36 | port = p 37 | .parse() 38 | .map_err(|_| { 39 | eprintln!("bad port: {}", p); 40 | process::exit(1) 41 | }) 42 | .unwrap(); 43 | } 44 | } 45 | "-h" => { 46 | if let Some(h) = args.next() { 47 | host = &h; 48 | } else { 49 | return print_help(); 50 | } 51 | } 52 | "--host" | "-host" => { 53 | if let Some(h) = args.next() { 54 | host = &h; 55 | } 56 | } 57 | _ => { 58 | if let Some('-') = arg.chars().nth(0) { 59 | eprintln!("unknown flag: {}", arg); 60 | process::exit(1); 61 | } else { 62 | root = &arg; 63 | } 64 | } 65 | } 66 | } 67 | 68 | // https://no-color.org/ 69 | if std::env::var("NO_COLOR").is_ok() { 70 | phd::color::hide_colors() 71 | } 72 | 73 | // If port was given and socket wasn't, bind to that port. 74 | let bind = if port != DEFAULT_PORT && addr == DEFAULT_BIND { 75 | format!("[::]:{}", port).parse().unwrap() 76 | } else { 77 | addr.parse().unwrap() 78 | }; 79 | 80 | if !render.is_empty() { 81 | return match phd::server::render(host, port, root, &render) { 82 | Ok(out) => print!("{}", out), 83 | Err(e) => eprintln!("{}", e), 84 | }; 85 | } 86 | 87 | if let Err(e) = phd::server::start(bind, host, port, root) { 88 | eprintln!("{}", e); 89 | } 90 | } 91 | 92 | fn print_help() { 93 | println!( 94 | "Usage: 95 | 96 | phd [options] 97 | 98 | Options: 99 | 100 | -r, --render SELECTOR Render and print SELECTOR to stdout only. 101 | -h, --host HOST Hostname for links. [Default: {host}] 102 | -p, --port PORT Port for links. [Default: {port}] 103 | -b, --bind ADDRESS Socket address to bind to. [Default: {bind}] 104 | --no-color Don't show colors in log messages. 105 | 106 | Other flags: 107 | 108 | -h, --help Print this screen. 109 | -v, --version Print phd version. 110 | 111 | Examples: 112 | 113 | phd ./path/to/site # Serve directory over port 7070. 114 | phd -p 70 docs # Serve 'docs' directory on port 70 115 | phd -h gopher.com # Serve current directory over port 7070 116 | # using hostname 'gopher.com' 117 | phd -r / ./site # Render local gopher site to stdout. 118 | ", 119 | host = DEFAULT_HOST, 120 | port = DEFAULT_PORT, 121 | bind = DEFAULT_BIND, 122 | ); 123 | } 124 | 125 | fn print_version() { 126 | println!("phd v{}", env!("CARGO_PKG_VERSION")); 127 | } 128 | -------------------------------------------------------------------------------- /src/request.rs: -------------------------------------------------------------------------------- 1 | //! A Request represents a Gopher request made by a client. phd can 2 | //! serve directory listings as Gopher Menus, plain text files as 3 | //! Text, binary files as downloads, Gophermap files as menus, or 4 | //! executable files as dynamic content. 5 | 6 | use crate::Result; 7 | use std::fs; 8 | 9 | /// This struct represents a single gopher request. 10 | #[derive(Debug, Clone)] 11 | pub struct Request { 12 | /// Gopher selector requested 13 | pub selector: String, 14 | /// Search query string, if any. 15 | pub query: String, 16 | /// Root directory of the server. Can't serve outside of this. 17 | pub root: String, 18 | /// Host of the currently running server. 19 | pub host: String, 20 | /// Port of the currently running server. 21 | pub port: u16, 22 | } 23 | 24 | impl Request { 25 | /// Try to create a new request state object. 26 | pub fn from(host: &str, port: u16, root: &str) -> Result { 27 | Ok(Request { 28 | host: host.into(), 29 | port, 30 | root: fs::canonicalize(root)?.to_string_lossy().into(), 31 | selector: String::new(), 32 | query: String::new(), 33 | }) 34 | } 35 | 36 | /// Path to the target file on disk requested by this request. 37 | pub fn file_path(&self) -> String { 38 | format!( 39 | "{}/{}", 40 | self.root.to_string().trim_end_matches('/'), 41 | self.selector.replace("..", ".").trim_start_matches('/') 42 | ) 43 | } 44 | 45 | /// Path to the target file relative to the server root. 46 | pub fn relative_file_path(&self) -> String { 47 | self.file_path().replace(&self.root, "") 48 | } 49 | 50 | /// Set selector + query based on what the client sent. 51 | pub fn parse_request(&mut self, line: &str) { 52 | self.query.clear(); 53 | self.selector.clear(); 54 | if let Some((i, _)) = line 55 | .chars() 56 | .enumerate() 57 | .find(|&(_, c)| c == '\t' || c == '?') 58 | { 59 | if line.len() > i { 60 | self.query.push_str(&line[i + 1..]); 61 | self.selector.push_str(&line[..i]); 62 | return; 63 | } 64 | } 65 | self.selector.push_str(line); 66 | 67 | // strip trailing / 68 | if let Some(last) = self.selector.chars().last() { 69 | if last == '/' { 70 | self.selector.pop(); 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | //! A simple multi-threaded Gopher server. 2 | 3 | use crate::{color, gopher, Request, Result}; 4 | use std::{ 5 | cmp::Ordering, 6 | fs::{self, DirEntry}, 7 | io::{self, prelude::*, BufReader, Read, Write}, 8 | net::{SocketAddr, TcpListener, TcpStream}, 9 | os::unix::fs::PermissionsExt, 10 | path::Path, 11 | process::Command, 12 | str, 13 | sync::atomic::{AtomicBool, Ordering as AtomicOrdering}, 14 | }; 15 | use threadpool::ThreadPool; 16 | 17 | /// phd tries to be light on resources, so we only allow a low number 18 | /// of simultaneous connections. 19 | const MAX_WORKERS: usize = 10; 20 | 21 | /// how many bytes of a file to read when trying to guess binary vs text? 22 | const MAX_PEEK_SIZE: usize = 1024; 23 | 24 | /// Files not displayed in directory listings. 25 | const IGNORED_FILES: [&str; 3] = ["header.gph", "footer.gph", ".reverse"]; 26 | 27 | /// Whether to print info!() messages to stdout. 28 | /// Defaults to true. 29 | static SHOW_INFO: AtomicBool = AtomicBool::new(true); 30 | 31 | /// Hide info! messages. 32 | fn hide_info() { 33 | SHOW_INFO.swap(false, AtomicOrdering::Relaxed); 34 | } 35 | 36 | /// Print status message to the server's stdout. 37 | macro_rules! info { 38 | ($e:expr) => { 39 | if SHOW_INFO.load(AtomicOrdering::Relaxed) { 40 | println!("{}", $e); 41 | } 42 | }; 43 | ($fmt:expr, $($args:expr),*) => { 44 | info!(format!($fmt, $($args),*)); 45 | }; 46 | ($fmt:expr, $($args:expr,)*) => { 47 | info!(format!($fmt, $($args,)*)); 48 | }; 49 | } 50 | 51 | /// Starts a Gopher server at the specified host, port, and root directory. 52 | pub fn start(bind: SocketAddr, host: &str, port: u16, root: &str) -> Result<()> { 53 | let listener = TcpListener::bind(&bind)?; 54 | let full_root_path = fs::canonicalize(&root)?.to_string_lossy().to_string(); 55 | let pool = ThreadPool::new(MAX_WORKERS); 56 | 57 | info!( 58 | "{}» Listening {}on {}{}{} at {}{}{}", 59 | color::Yellow, 60 | color::Reset, 61 | color::Yellow, 62 | bind, 63 | color::Reset, 64 | color::Blue, 65 | full_root_path, 66 | color::Reset 67 | ); 68 | for stream in listener.incoming() { 69 | let stream = stream?; 70 | info!( 71 | "{}┌ Connection{} from {}{}", 72 | color::Green, 73 | color::Reset, 74 | color::Magenta, 75 | stream.peer_addr()? 76 | ); 77 | let req = Request::from(host, port, root)?; 78 | pool.execute(move || { 79 | if let Err(e) = accept(stream, req) { 80 | info!("{}└ {}{}", color::Red, e, color::Reset); 81 | } 82 | }); 83 | } 84 | Ok(()) 85 | } 86 | 87 | /// Reads from the client and responds. 88 | fn accept(mut stream: TcpStream, mut req: Request) -> Result<()> { 89 | let reader = BufReader::new(&stream); 90 | let mut lines = reader.lines(); 91 | if let Some(Ok(line)) = lines.next() { 92 | info!( 93 | "{}│{} Client sent:\t{}{:?}{}", 94 | color::Green, 95 | color::Reset, 96 | color::Cyan, 97 | line, 98 | color::Reset 99 | ); 100 | req.parse_request(&line); 101 | write_response(&mut stream, req)?; 102 | } 103 | Ok(()) 104 | } 105 | 106 | /// Render a response to a String. 107 | pub fn render(host: &str, port: u16, root: &str, selector: &str) -> Result { 108 | hide_info(); 109 | let mut req = Request::from(host, port, root)?; 110 | req.parse_request(&selector); 111 | let mut out = vec![]; 112 | write_response(&mut out, req)?; 113 | Ok(String::from_utf8_lossy(&out).into()) 114 | } 115 | 116 | /// Writes a response to a client based on a Request. 117 | fn write_response(w: &mut W, mut req: Request) -> Result<()> 118 | where 119 | W: Write, 120 | { 121 | let path = req.file_path(); 122 | 123 | // check for dir.gph if we're looking for dir 124 | let mut gph_file = path.clone(); 125 | gph_file.push_str(".gph"); 126 | if fs_exists(&gph_file) { 127 | req.selector = req.selector.trim_end_matches('/').into(); 128 | req.selector.push_str(".gph"); 129 | return write_gophermap(w, req); 130 | } else { 131 | // check for index.gph if we're looking for dir 132 | let mut index = path.clone(); 133 | index.push_str("/index.gph"); 134 | if fs_exists(&index) { 135 | req.selector.push_str("/index.gph"); 136 | return write_gophermap(w, req); 137 | } 138 | } 139 | 140 | let meta = match fs::metadata(&path) { 141 | Ok(meta) => meta, 142 | Err(_) => return write_not_found(w, req), 143 | }; 144 | 145 | if path.ends_with(".gph") { 146 | write_gophermap(w, req) 147 | } else if meta.is_file() { 148 | write_file(w, req) 149 | } else if meta.is_dir() { 150 | write_dir(w, req) 151 | } else { 152 | Ok(()) 153 | } 154 | } 155 | 156 | /// Send a directory listing (menu) to the client based on a Request. 157 | fn write_dir(w: &mut W, req: Request) -> Result<()> 158 | where 159 | W: Write, 160 | { 161 | let path = req.file_path(); 162 | if !fs_exists(&path) { 163 | return write_not_found(w, req); 164 | } 165 | 166 | let mut header = path.clone(); 167 | header.push_str("/header.gph"); 168 | if fs_exists(&header) { 169 | let mut sel = req.selector.clone(); 170 | sel.push_str("/header.gph"); 171 | write_gophermap( 172 | w, 173 | Request { 174 | selector: sel, 175 | ..req.clone() 176 | }, 177 | )?; 178 | } 179 | 180 | let rel_path = req.relative_file_path(); 181 | 182 | // show directory entries 183 | let reverse = format!("{}/.reverse", path); 184 | let paths = sort_paths(&path, fs_exists(&reverse))?; 185 | for entry in paths { 186 | let file_name = entry.file_name(); 187 | let f = file_name.to_string_lossy().to_string(); 188 | if f.chars().nth(0) == Some('.') || IGNORED_FILES.contains(&f.as_ref()) { 189 | continue; 190 | } 191 | let path = format!( 192 | "{}/{}", 193 | rel_path.trim_end_matches('/'), 194 | file_name.to_string_lossy() 195 | ); 196 | write!( 197 | w, 198 | "{}{}\t{}\t{}\t{}\r\n", 199 | file_type(&entry).to_char(), 200 | &file_name.to_string_lossy(), 201 | &path, 202 | &req.host, 203 | req.port, 204 | )?; 205 | } 206 | 207 | let footer = format!("{}/footer.gph", path.trim_end_matches('/')); 208 | if fs_exists(&footer) { 209 | let sel = format!("{}/footer.gph", req.selector); 210 | write_gophermap( 211 | w, 212 | Request { 213 | selector: sel, 214 | ..req.clone() 215 | }, 216 | )?; 217 | } 218 | 219 | write!(w, ".\r\n"); 220 | 221 | info!( 222 | "{}│{} Server reply:\t{}DIR {}{}{}", 223 | color::Green, 224 | color::Reset, 225 | color::Yellow, 226 | color::Bold, 227 | req.relative_file_path(), 228 | color::Reset, 229 | ); 230 | Ok(()) 231 | } 232 | 233 | /// Send a file to the client based on a Request. 234 | fn write_file(w: &mut W, req: Request) -> Result<()> 235 | where 236 | W: Write, 237 | { 238 | let path = req.file_path(); 239 | let mut f = fs::File::open(&path)?; 240 | io::copy(&mut f, w)?; 241 | info!( 242 | "{}│{} Server reply:\t{}FILE {}{}{}", 243 | color::Green, 244 | color::Reset, 245 | color::Yellow, 246 | color::Bold, 247 | req.relative_file_path(), 248 | color::Reset, 249 | ); 250 | Ok(()) 251 | } 252 | 253 | /// Send a gophermap (menu) to the client based on a Request. 254 | fn write_gophermap(w: &mut W, req: Request) -> Result<()> 255 | where 256 | W: Write, 257 | { 258 | let path = req.file_path(); 259 | 260 | // Run the file and use its output as content if it's executable. 261 | let reader = if is_executable(&path) { 262 | shell(&path, &[&req.query, &req.host, &req.port.to_string()])? 263 | } else { 264 | fs::read_to_string(&path)? 265 | }; 266 | 267 | for line in reader.lines() { 268 | write!(w, "{}", gph_line_to_gopher(line, &req))?; 269 | } 270 | info!( 271 | "{}│{} Server reply:\t{}MAP {}{}{}", 272 | color::Green, 273 | color::Reset, 274 | color::Yellow, 275 | color::Bold, 276 | req.relative_file_path(), 277 | color::Reset, 278 | ); 279 | Ok(()) 280 | } 281 | 282 | /// Given a single line from a .gph file, convert it into a 283 | /// Gopher-format line. Supports a basic format where lines without \t 284 | /// get an `i` prefixed, and the geomyidae format. 285 | fn gph_line_to_gopher(line: &str, req: &Request) -> String { 286 | if line.starts_with('#') { 287 | return "".to_string(); 288 | } 289 | 290 | let mut line = line.trim_end_matches('\r').to_string(); 291 | if line.starts_with('[') && line.ends_with(']') && line.contains('|') { 292 | // [1|name|sel|server|port] 293 | let port = req.port.to_string(); 294 | line = line 295 | .replacen('|', "", 1) 296 | .trim_start_matches('[') 297 | .trim_end_matches(']') 298 | .replace("\\|", "__P_ESC_PIPE") // cheap hack 299 | .replace('|', "\t") 300 | .replace("__P_ESC_PIPE", "\\|") 301 | .replace("\tserver\t", format!("\t{}\t", req.host).as_ref()) 302 | .replace("\tport", format!("\t{}", port).as_ref()); 303 | let tabs = line.matches('\t').count(); 304 | if tabs < 1 { 305 | line.push('\t'); 306 | line.push_str("(null)"); 307 | } 308 | // if a link is missing host + port, assume it's this server. 309 | // if it's just missing the port, assume port 70 310 | if tabs < 2 { 311 | line.push('\t'); 312 | line.push_str(&req.host); 313 | line.push('\t'); 314 | line.push_str(&port); 315 | } else if tabs < 3 { 316 | line.push('\t'); 317 | line.push_str("70"); 318 | } 319 | } else { 320 | match line.matches('\t').count() { 321 | 0 => { 322 | // Always insert `i` prefix to any lines without tabs. 323 | line.insert(0, 'i'); 324 | line.push_str(&format!("\t(null)\t{}\t{}", req.host, req.port)) 325 | } 326 | // Auto-add host and port to lines with just a selector. 327 | 1 => line.push_str(&format!("\t{}\t{}", req.host, req.port)), 328 | 2 => line.push_str(&format!("\t{}", req.port)), 329 | _ => {} 330 | } 331 | } 332 | line.push_str("\r\n"); 333 | line 334 | } 335 | 336 | fn write_not_found(w: &mut W, req: Request) -> Result<()> 337 | where 338 | W: Write, 339 | { 340 | let line = format!("3Not Found: {}\t/\tnone\t70\r\n", req.selector); 341 | info!( 342 | "{}│ Not found: {}{}{}", 343 | color::Red, 344 | color::Cyan, 345 | req.relative_file_path(), 346 | color::Reset, 347 | ); 348 | write!(w, "{}", line)?; 349 | Ok(()) 350 | } 351 | 352 | /// Determine the gopher type for a DirEntry on disk. 353 | fn file_type(dir: &fs::DirEntry) -> gopher::Type { 354 | let metadata = match dir.metadata() { 355 | Err(_) => return gopher::Type::Error, 356 | Ok(md) => md, 357 | }; 358 | 359 | if metadata.is_file() { 360 | if let Ok(file) = fs::File::open(&dir.path()) { 361 | let mut buffer: Vec = vec![]; 362 | let _ = file.take(MAX_PEEK_SIZE as u64).read_to_end(&mut buffer); 363 | if content_inspector::inspect(&buffer).is_binary() { 364 | gopher::Type::Binary 365 | } else { 366 | gopher::Type::Text 367 | } 368 | } else { 369 | gopher::Type::Error 370 | } 371 | } else if metadata.is_dir() { 372 | gopher::Type::Menu 373 | } else { 374 | gopher::Type::Error 375 | } 376 | } 377 | 378 | /// Does the file exist? Y'know. 379 | fn fs_exists(path: &str) -> bool { 380 | Path::new(path).exists() 381 | } 382 | 383 | /// Is the file at the given path executable? 384 | fn is_executable(path: &str) -> bool { 385 | if let Ok(meta) = fs::metadata(path) { 386 | meta.permissions().mode() & 0o111 != 0 387 | } else { 388 | false 389 | } 390 | } 391 | 392 | /// Run a script and return its output. 393 | fn shell(path: &str, args: &[&str]) -> Result { 394 | let output = Command::new(path).args(args).output()?; 395 | if output.status.success() { 396 | Ok(str::from_utf8(&output.stdout)?.to_string()) 397 | } else { 398 | Ok(str::from_utf8(&output.stderr)?.to_string()) 399 | } 400 | } 401 | 402 | /// Sort directory paths: dirs first, files 2nd, version #s respected. 403 | fn sort_paths(dir_path: &str, reverse: bool) -> Result> { 404 | let mut paths: Vec<_> = fs::read_dir(dir_path)?.filter_map(|r| r.ok()).collect(); 405 | let is_dir = |entry: &fs::DirEntry| match entry.file_type() { 406 | Ok(t) => t.is_dir(), 407 | _ => false, 408 | }; 409 | paths.sort_by(|a, b| { 410 | let a_is_dir = is_dir(a); 411 | let b_is_dir = is_dir(b); 412 | if a_is_dir && b_is_dir || !a_is_dir && !b_is_dir { 413 | let ord = alphanumeric_sort::compare_os_str::<&Path, &Path>( 414 | a.path().as_ref(), 415 | b.path().as_ref(), 416 | ); 417 | if reverse { 418 | ord.reverse() 419 | } else { 420 | ord 421 | } 422 | } else if is_dir(a) { 423 | Ordering::Less 424 | } else if is_dir(b) { 425 | Ordering::Greater 426 | } else { 427 | Ordering::Equal // what 428 | } 429 | }); 430 | Ok(paths) 431 | } 432 | 433 | #[cfg(test)] 434 | mod tests { 435 | use super::*; 436 | 437 | macro_rules! str_path { 438 | ($e:expr) => { 439 | $e.path() 440 | .to_str() 441 | .unwrap() 442 | .trim_start_matches("tests/sort/") 443 | }; 444 | } 445 | 446 | #[test] 447 | fn test_sort_directory() { 448 | let paths = sort_paths("tests/sort", false).unwrap(); 449 | assert_eq!(str_path!(paths[0]), "zzz"); 450 | assert_eq!(str_path!(paths[1]), "phetch-v0.1.7-linux-armv7.tar.gz"); 451 | assert_eq!( 452 | str_path!(paths[paths.len() - 1]), 453 | "phetch-v0.1.11-macos.zip" 454 | ); 455 | } 456 | 457 | #[test] 458 | fn test_rsort_directory() { 459 | let paths = sort_paths("tests/sort", true).unwrap(); 460 | assert_eq!(str_path!(paths[0]), "zzz"); 461 | assert_eq!(str_path!(paths[1]), "phetch-v0.1.11-macos.zip"); 462 | assert_eq!( 463 | str_path!(paths[paths.len() - 1]), 464 | "phetch-v0.1.7-linux-armv7.tar.gz" 465 | ); 466 | } 467 | 468 | #[test] 469 | fn test_gph_line_to_gopher() { 470 | let req = Request::from("localhost", 70, ".").unwrap(); 471 | 472 | assert_eq!( 473 | gph_line_to_gopher("regular line test", &req), 474 | "iregular line test (null) localhost 70\r\n" 475 | ); 476 | assert_eq!( 477 | gph_line_to_gopher("1link test /test localhost 70", &req), 478 | "1link test /test localhost 70\r\n" 479 | ); 480 | 481 | let line = "0short link test /test"; 482 | assert_eq!( 483 | gph_line_to_gopher(line, &req), 484 | "0short link test /test localhost 70\r\n" 485 | ); 486 | } 487 | 488 | #[test] 489 | fn test_gph_geomyidae() { 490 | let req = Request::from("localhost", 7070, ".").unwrap(); 491 | 492 | assert_eq!( 493 | gph_line_to_gopher("[1|phkt.io|/|phkt.io]", &req), 494 | "1phkt.io / phkt.io 70\r\n" 495 | ); 496 | assert_eq!(gph_line_to_gopher("#[1|phkt.io|/|phkt.io]", &req), ""); 497 | assert_eq!( 498 | gph_line_to_gopher("[1|sdf6000|/not-real|sdf.org|6000]", &req), 499 | "1sdf6000 /not-real sdf.org 6000\r\n" 500 | ); 501 | assert_eq!( 502 | gph_line_to_gopher("[1|R-36|/]", &req), 503 | "1R-36 / localhost 7070\r\n" 504 | ); 505 | assert_eq!( 506 | gph_line_to_gopher("[1|R-36|/|server|port]", &req), 507 | "1R-36 / localhost 7070\r\n" 508 | ); 509 | assert_eq!( 510 | gph_line_to_gopher("[0|file - comment|/file.dat|server|port]", &req), 511 | "0file - comment /file.dat localhost 7070\r\n" 512 | ); 513 | assert_eq!( 514 | gph_line_to_gopher( 515 | "[0|some \\| escape and [ special characters ] test|error|server|port]", 516 | &req 517 | ), 518 | "0some \\| escape and [ special characters ] test error localhost 7070\r\n" 519 | ); 520 | assert_eq!( 521 | gph_line_to_gopher("[|empty type||server|port]", &req), 522 | "empty type\t\tlocalhost\t7070\r\n", 523 | ); 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.10-linux-armv7.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.10-linux-armv7.tgz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.10-linux-x86_64.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.10-linux-x86_64.tgz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.10-macos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.10-macos.zip -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.11-linux-armv7.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.11-linux-armv7.tgz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.11-linux-x86_64.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.11-linux-x86_64.tgz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.11-macos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.11-macos.zip -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.7-linux-armv7.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.7-linux-armv7.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.7-linux-x86_64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.7-linux-x86_64.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.7-macos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.7-macos.zip -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.8-linux-armv7.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.8-linux-armv7.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.8-linux-x86_64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.8-linux-x86_64.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.8-macos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.8-macos.zip -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.9-linux-armv7.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.9-linux-armv7.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.9-linux-x86_64.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.9-linux-x86_64.tar.gz -------------------------------------------------------------------------------- /tests/sort/phetch-v0.1.9-macos.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/phetch-v0.1.9-macos.zip -------------------------------------------------------------------------------- /tests/sort/zzz/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xvxx/phd/5b02ccc1dbf4568994bd6c6c5deffaea82ba47cd/tests/sort/zzz/.gitignore --------------------------------------------------------------------------------