├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CHANGELOG.old ├── COPYING ├── Makefile ├── README.md ├── README.windows ├── TODO ├── UPGRADING.md ├── bin └── pygopherd ├── conf ├── local.conf ├── mime.types └── pygopherd.conf ├── doc ├── book.sgml ├── manpage.sgml ├── pygopherd.8 ├── pygopherd.html ├── pygopherd.pdf ├── pygopherd.ps ├── pygopherd.sgml ├── pygopherd.txt ├── quickstart.sgml └── standards │ ├── Gopher+.txt │ ├── gophermap.txt │ └── url.txt ├── examples ├── gophermap ├── pygopherd.service └── talsample.html.tal ├── local.dsl ├── printlocal.dsl ├── pygfarm └── dict.pyg ├── pygopherd ├── GopherExceptions.py ├── __init__.py ├── fileext.py ├── gopherentry.py ├── handlers │ ├── HandlerMultiplexer.py │ ├── UMN.py │ ├── ZIP.py │ ├── __init__.py │ ├── base.py │ ├── dir.py │ ├── file.py │ ├── gophermap.py │ ├── html.py │ ├── mbox.py │ ├── pyg.py │ ├── scriptexec.py │ ├── tal.py │ ├── url.py │ └── virtual.py ├── initialization.py ├── logger.py ├── protocols │ ├── ProtocolMultiplexer.py │ ├── __init__.py │ ├── base.py │ ├── enhanced.py │ ├── gemini.py │ ├── gopherp.py │ ├── http.py │ ├── rfc1436.py │ ├── spartan.py │ └── wap.py ├── server.py ├── sighandlers.py └── testutil.py ├── runtests.py ├── setup.py ├── simpletal ├── FixedHTMLParser.py ├── LICENSE.txt ├── __init__.py ├── sgmlentitynames.py ├── simpleTAL.py ├── simpleTALES.py └── simpleTALUtils.py ├── testdata ├── .abstract ├── .cap │ └── zzz.txt ├── .linkfile ├── .queryfile ├── README ├── bucktooth │ ├── README │ └── gophermap ├── demo.crt ├── demo.key ├── gopherplus │ ├── README │ ├── README.3d │ └── testfile.txt ├── pygopherd │ ├── cgitest.sh │ ├── pipetest.sh │ ├── pipetestdata │ └── searchtest.sh ├── python-dev.mbox ├── python-dev │ ├── cur │ │ ├── 1606884253.000000.mbox:2, │ │ ├── 1606884253.000001.mbox:2, │ │ ├── 1606884253.000002.mbox:2, │ │ ├── 1606884253.000003.mbox:2, │ │ ├── 1606884253.000004.mbox:2, │ │ └── 1606884253.000005.mbox:2, │ ├── new │ │ └── 1606884253.000000.mbox:2, │ └── tmp │ │ └── 1606884253.000000.mbox:2, ├── symlinktest.zip ├── talsample.html.tal ├── testarchive.tar ├── testarchive.tar.gz ├── testarchive.tgz ├── testdata.zip ├── testdata2.zip ├── testfile.gmi ├── testfile.html ├── testfile.pyg ├── testfile.txt ├── testfile.txt.gz ├── testfile.txt.gz.abstract ├── ziptorture.zip └── zzz.txt └── tests ├── handlers ├── __init__.py ├── test_dir.py ├── test_file.py ├── test_gophermap.py ├── test_html.py ├── test_mbox.py ├── test_pyg.py ├── test_script_exec.py ├── test_tal.py ├── test_umn.py ├── test_url.py └── test_zip.py ├── protocols ├── __init__.py ├── test_base.py ├── test_gemini.py ├── test_gopherp.py ├── test_http.py ├── test_protocol_multiplexer.py ├── test_rfc1436.py ├── test_spartan.py └── test_wap.py ├── test_fileext.py ├── test_gopher_entry.py ├── test_gopher_exceptions.py ├── test_initialization.py ├── test_logger.py ├── test_pipe.py └── test_server.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 16 | os: [ubuntu-latest] 17 | steps: 18 | - name: Check out repository 19 | uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Run tests 25 | run: python runtests.py -v -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | testdata/.cache* 2 | *.cache* 3 | __pycache__ 4 | .idea 5 | .DS_Store 6 | *.egg-info/ 7 | build 8 | dist 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ### Added 11 | 12 | - Added support for python 3.12. 13 | 14 | ## v3.0.1 (2024-02-25) 15 | 16 | ### Added 17 | 18 | - Added support for python 3.10, 3.11. 19 | 20 | ### Fixed 21 | 22 | - Fixed not escaping the URL string when generating HTML responses to hURL: requests. 23 | 24 | ## v3.0.0 (2022-11-25) 25 | 26 | ### Added 27 | 28 | - A new handler to support the spartan:// protocol (https://portal.mozz.us/gemini/spartan.mozz.us/). 29 | 30 | ### Changed 31 | 32 | - Fixed unhandled OS errors when the server is unable to access a file. 33 | - Fixed error in the mailbox handler when a message subject contains invalid bytes. 34 | - Changed the HTTP link for "find gopher browsers" to Wikipedia, which 35 | contains a more complete and up-to-date list. 36 | - Fixed "Numb=" parameter not being respected for real files in .names listings. 37 | - Disabled directory caching for the local pygopherd configuration. 38 | - Removed the directory heading from the top of gemini:// pages. 39 | - Fixed error when serving directories with trailing slashes in gemini and spartan. 40 | - Removed the debian/ directory (it will now be handled in Debian 41 | itself, not here). 42 | - Added an example systemd service file and updated the default 43 | pygopherd.conf accordingly. 44 | 45 | ## v3.0.0b2 (2020-02-12) 46 | 47 | ### Added 48 | 49 | - Support for establishing TLS connections by checking the first byte of the 50 | request for a TLS handshake. This allows for both plaintext and encrypted 51 | communication to be made over the same port. A TLS section has been added to 52 | the default pygopherd configuration file. 53 | - Several protocols which take advantage of the new TLS connections. 54 | - rfc1436.SecureGopherProtocol (gopher + TLS). 55 | - gopherp.SecureGopherPlusProtocol (gopher plus + TLS). 56 | - http.HTTPSProtocol (http + TLS). 57 | - gemini.GeminiProtocol (https://gemini.circumlunar.space/). 58 | - Display server version with ``pygopherd --version``. 59 | 60 | ### Changed 61 | 62 | - Gracefully handle OS errors when calling ``setpgrp()``. 63 | - Refactored the socket server classes and added additional test cases. 64 | 65 | ## v3.0.0b1 (2020-01-18) 66 | 67 | ### Added 68 | 69 | - Support for python 3.7+. 70 | - Additional test coverage and type hints. 71 | - Published package to PyPI (https://pypi.org/project/pygopherd/). 72 | 73 | ### Changed 74 | 75 | - Significant sprucing up of the codebase. 76 | - Numerous minor bugs were discovered and fixed. 77 | 78 | ### Removed 79 | 80 | - Support for python 2. 81 | 82 | ## Previous versions 83 | 84 | See [CHANGELOG.old](CHANGELOG.old) for older versions. 85 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2002, 2003 John Goerzen 2 | # 3 | # 4 | # This program is free software; you can redistribute it and/or modify 5 | # it under the terms of the GNU General Public License as published by 6 | # the Free Software Foundation; version 2 of the License. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software 15 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 16 | clean: 17 | -python2.3 setup.py clean --all 18 | -rm -f `find . -name "*~"` 19 | -rm -f `find . -name "*.pyc"` 20 | -rm -f `find . -name "*.pygc"` 21 | -rm -f `find . -name "*.class"` 22 | -rm -f `find . -name "*.bak"` 23 | -rm -f `find . -name ".cache*"` 24 | -rm -f *.log *.aux *.dvi *.out *.jtex jadetex.cfg 25 | -find . -name auth -exec rm -vf {}/password {}/username \; 26 | -rm -rf build 27 | 28 | changelog: 29 | git log -M -C --find-copies-harder --name-status > ChangeLog 30 | 31 | docs: doc/pygopherd.8 doc/pygopherd.ps \ 32 | doc/pygopherd.pdf doc/pygopherd.txt doc/pygopherd.html 33 | 34 | doc/pygopherd.8: doc/pygopherd.sgml doc/book.sgml 35 | docbook2man doc/book.sgml 36 | docbook2man doc/book.sgml 37 | -rm manpage.links manpage.refs 38 | mv pygopherd.8 doc 39 | 40 | #doc/pygopherd.html: doc/pygopherd.sgml 41 | # docbook2html -u doc/pygopherd.sgml 42 | # mv pygopherd.html doc 43 | 44 | doc/pygopherd.html: doc/pygopherd.sgml doc/book.sgml 45 | docbook2html -u doc/book.sgml 46 | mv book.html doc/pygopherd.html 47 | 48 | #doc/pygopherd.ps: doc/pygopherd.8 49 | # man -t -l doc/pygopherd.8 > doc/pygopherd.ps 50 | 51 | doc/pygopherd.ps: doc/pygopherd.sgml doc/book.sgml doc/manpage.sgml 52 | docbook2ps \ 53 | doc/book.sgml 54 | mv book.ps doc/pygopherd.ps 55 | 56 | doc/pygopherd.pdf: doc/pygopherd.ps 57 | ps2pdf doc/pygopherd.ps 58 | mv pygopherd.pdf doc 59 | 60 | doc/pygopherd.txt: 61 | groff -Tascii -man doc/pygopherd.8 | sed $$'s/.\b//g' > doc/pygopherd.txt 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/michael-lazar/pygopherd/workflows/Test/badge.svg)](https://github.com/michael-lazar/pygopherd/actions) 2 | [![license GPLv2](https://img.shields.io/github/license/michael-lazar/pygopherd)](https://www.gnu.org/licenses/gpl-2.0.en.html) 3 | 4 | # PyGopherd 5 | 6 | PyGopherd is a multiprotocol (gopher, gopher+, http, wap) information server. 7 | 8 | [PyGopherd Online User Manual](https://michael-lazar.github.io/pygopherd/doc/pygopherd.html) 9 | 10 | ## History 11 | 12 | This repo is a fork of [jgoerzen/pygopherd](https://github.com/jgoerzen/pygopherd) 13 | that adds support for Python 3. 14 | 15 | If you're upgrading from an old version of PyGopherd, see the [upgrade notes](UPGRADING.md). 16 | 17 | ## Quickstart 18 | 19 | ### Debian 20 | 21 | Use the .deb: 22 | 23 | ``` 24 | dpkg -i pygopherd.deb 25 | ``` 26 | 27 | or 28 | 29 | ``` 30 | apt-get install pygopherd 31 | ``` 32 | 33 | ### Non-Debian 34 | 35 | First, download and install Python 3.7 or higher. 36 | 37 | You can run pygopherd either in-place (as a regular user account) or 38 | as a system-wide daemon. For running in-place, do this: 39 | 40 | ``` 41 | PYTHONPATH=. bin/pygopherd conf/local.conf 42 | ``` 43 | 44 | For installing, 45 | 46 | ``` 47 | python3 -m pip install . 48 | ``` 49 | 50 | Make sure that the ``/etc/pygopherd/pygopherd.conf`` names valid users 51 | (setuid, setgid) and valid document root (root). 52 | -------------------------------------------------------------------------------- /README.windows: -------------------------------------------------------------------------------- 1 | Submitted by Grant D. Watson: 2 | ---------------------------------------------------------- 3 | Windows Installation: 4 | 5 | Download the tar.gz version of the package from the website. Make sure you have Python 2.2 or above installed; if not, download and install it from http://www.python.org/. Unzip the package in "C:\Program Files" (or another suitable directory). 6 | 7 | In WordPad (_not_ Notepad) open "C:\Program Files\pygopherd\conf\pygopherd.conf". Modify the file as follows: 8 | - Set detach = no 9 | - Comment out the pidfile line 10 | - Set servertype = ThreadingTCPServer 11 | - Set usechroot = no 12 | - Comment out the setuid and setgid lines 13 | - Set root to something appropriate 14 | - Set mimetypes = conf/mime.types 15 | - Set logmethod = none 16 | 17 | To run the server, open a DOS prompt and type the following commands: 18 | c: 19 | cd "\Program Files\pygopherd" 20 | python bin\pygopherd conf\pygopherd.conf 21 | 22 | To end the server you must press Ctrl-Alt-Del and tell Windows to end the task. 23 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Document new .gophermap files 2 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading to PyGopherd v3.0 2 | 3 | Notes for updating an existing PyGopherd server deployment to run on python 3. 4 | 5 | ## Server Version 6 | 7 | Install PyGopherd v3.0+ from [michael-lazar/pygopherd](https://github.com/michael-lazar/pygopherd) 8 | or another dependable source. 9 | 10 | ## Cached Files 11 | 12 | PyGopherd will create cache files that start with ``.cache.pygopherd.*`` in 13 | your gopher root directory. These files are used to index directories and zip 14 | files for faster loading. Before launching the new server, clear out any 15 | existing PyGopherd cache files from your system. 16 | 17 | ``` 18 | find /var/gopher -type f -name '.cache.pygopherd.*' -delete 19 | ``` 20 | 21 | ## /etc/pygopherd/pygopherd.conf 22 | 23 | Because the PyGopherd config file format uses evaluated python code, you will 24 | need to make sure your config file is python 3 compatible. 25 | There is one known spot where the default config file needed to be updated. 26 | 27 | ``` 28 | encoding = mimetypes.encodings_map.items() + \ 29 | {'.bz2' : 'bzip2', 30 | '.tal': 'tal.TALFileHandler' 31 | }.items() 32 | ``` 33 | 34 | must be changed to 35 | 36 | ``` 37 | encoding = list(mimetypes.encodings_map.items()) + \ 38 | list({'.bz2' : 'bzip2', 39 | '.tal': 'tal.TALFileHandler' 40 | }.items()) 41 | ``` 42 | 43 | 44 | 45 | ## PYG files 46 | 47 | PyGopherd supports ``*.pyg`` files which use a special file handler to execute 48 | python code directly. If you have written any custom PYG files for your server, 49 | you will need to make sure that they are compatible with python 3. 50 | 51 | The internal API has not changed, but some methods now expect bytes instead of 52 | strings. An example PYG file is shown [here](testdata/testfile.pyg). 53 | 54 | #### Before 55 | 56 | ``` 57 | def write(self, wfile): 58 | wfile.write(self.definition) 59 | ``` 60 | 61 | #### After 62 | 63 | ``` 64 | def write(self, wfile): 65 | wfile.write(self.definition.encode()) 66 | ``` 67 | 68 | ## Other Handlers 69 | 70 | Some handler classes required major refactoring to support python 3. Notably 71 | the ``mbox`` and ``ZIP`` handlers were significantly changed. Effort was made 72 | to preserve the old behavior as closely as possible. 73 | 74 | If you're using any of these handlers on your server, test them out and report 75 | back with any issues! 76 | -------------------------------------------------------------------------------- /bin/pygopherd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Python-based gopher server 4 | # COPYRIGHT # 5 | # Copyright (C) 2021 Michael Lazar 6 | # Copyright (C) 2002-2019 John Goerzen 7 | # 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation; version 2 of the License. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 20 | # END OF COPYRIGHT # 21 | import argparse 22 | 23 | from pygopherd import initialization, __version__ 24 | 25 | parser = argparse.ArgumentParser( 26 | prog="pygopherd", 27 | description="A multiprotocol gopher information server", 28 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 29 | ) 30 | parser.add_argument( 31 | "config", 32 | nargs="?", 33 | default="/etc/pygopherd/pygopherd.conf", 34 | help="pygopherd config", 35 | ) 36 | parser.add_argument( 37 | "-V", "--version", action="version", version="pygopherd " + __version__ 38 | ) 39 | 40 | args = parser.parse_args() 41 | 42 | s = initialization.initialize(args.config) 43 | s.serve_forever() 44 | -------------------------------------------------------------------------------- /doc/book.sgml: -------------------------------------------------------------------------------- 1 | PyGopherd"> 3 | 4 | 5 | 6 | ]> 7 | 8 | 9 | PyGopherd Manual</> 10 | </bookinfo> 11 | 12 | &maindoc; 13 | &manpage; 14 | </book> 15 | -------------------------------------------------------------------------------- /doc/pygopherd.8: -------------------------------------------------------------------------------- 1 | .\" This manpage has been automatically generated by docbook2man 2 | .\" from a DocBook document. This tool can be found at: 3 | .\" <http://shell.ipoline.com/~elmert/comp/docbook2X/> 4 | .\" Please send any bug reports, improvements, comments, patches, 5 | .\" etc. to Steve Cheng <steve@ggi-project.org>. 6 | .TH "PYGOPHERD" "8" "26 November 2022" "John Goerzen" "PyGopherd Manpage" 7 | 8 | .SH NAME 9 | PyGopherd \- Multiprotocol Information Server 10 | .SH SYNOPSIS 11 | 12 | \fBpygopherd\fR [ \fB\fIconfigfile\fB\fR ] 13 | 14 | .SH "DESCRIPTION" 15 | .PP 16 | Welcome to \fBPyGopherd\fR\&. In a nutshell, \fBPyGopherd\fR 17 | is a modern dynamic 18 | multi-protocol hierarchical information server with a pluggable 19 | modularized extension system, 20 | full flexible caching, virtual files and 21 | folders, and autodetection of file types -- all with support for 22 | standardized yet extensible per-document metadata. Whew! Read on for 23 | information on this what all these buzzwords mean. 24 | .SH "QUICK START" 25 | .PP 26 | If you have already installed \fBPyGopherd\fR system-wide, or your 27 | administrator has done that for you, your task for setting up 28 | \fBPyGopherd\fR for the first time is quite simple. You just need 29 | to set up your configuration file, make your folder directory, 30 | and run it! 31 | .PP 32 | You can quickly set up your configuration file. The 33 | distribution includes two files of interest: 34 | \fIconf/pygopherd.conf\fR and 35 | \fIconf/mime.types\fR\&. Debian users will find 36 | the configuration file pre-installed in 37 | \fI/etc/pygopherd/pygopherd.conf\fR and the 38 | \fImime.types\fR file provided by the system 39 | already. 40 | .PP 41 | Open up \fIpygopherd.conf\fR in your editor and 42 | adjust to suit. The file is heavily commented and you can 43 | refer to it for detailed information. Some settings to take a 44 | look at include: \fIdetach\fR, 45 | \fIpidfile\fR, \fIport\fR, 46 | \fIusechroot\fR, \fIsetuid\fR, 47 | \fIsetgid\fR, and \fIroot\fR\&. 48 | These may or may not work at their defaults for you. The 49 | remaining ones should be fine for a basic setup. 50 | .PP 51 | Invoke \fBPyGopherd\fR with \fBpygopherd 52 | path/to/configfile\fR (or 53 | \fB/etc/init.d/pygopherd start\fR on Debian). 54 | Place some files in the location specified by the 55 | \fIroot\fR directive in the config file and 56 | you're ready to run! 57 | .SH "OPTIONS" 58 | .PP 59 | All \fBPyGopherd\fR configuratoin is done via the configuration 60 | file. Therefore, the program has only one command-line 61 | option: 62 | .TP 63 | \fB\fIconfigfile\fB\fR 64 | This option argument specifies the location 65 | of the configuration file that \fBPyGopherd\fR is to use. 66 | .SH "CONFORMING TO" 67 | .TP 0.2i 68 | \(bu 69 | The Internet Gopher Protocol as specified in RFC1436 70 | .TP 0.2i 71 | \(bu 72 | The Gopher+ upward-compatible enhancements to the Internet Gopher 73 | Protocol from the University of Minnesota as laid out at 74 | <URL:gopher://gopher.quux.org/0/Archives/mirrors/boombox.micro.umn.edu/pub/gopher/gopher_protocol/Gopher+/Gopher+.txt>\&. 75 | .TP 0.2i 76 | \(bu 77 | The gophermap file format as originally implemented in the 78 | Bucktooth gopher server and described at 79 | <URL:gopher://gopher.floodgap.com/0/buck/dbrowse%3Ffaquse%201>\&. 80 | .TP 0.2i 81 | \(bu 82 | The Links to URL specification as laid out by John Goerzen 83 | at 84 | <URL:gopher://gopher.quux.org/0/Archives/Mailing%20Lists/gopher/gopher.2002-02%3f/MBOX-MESSAGE/34>\&. 85 | .TP 0.2i 86 | \(bu 87 | The UMN format for specifying object attributes and links 88 | with .cap, .Links, .abstract, and similar files as specified elsewhere 89 | in this document and implemented by UMN gopherd. 90 | .TP 0.2i 91 | \(bu 92 | The PYG format for extensible Python gopher objects as created for 93 | \fBPyGopherd\fR\&. 94 | .TP 0.2i 95 | \(bu 96 | Hypertext Transfer Protocol HTTP/1.0 as specified in 97 | RFC1945 98 | .TP 0.2i 99 | \(bu 100 | Hypertext Markup Language (HTML) 3.2 and 4.0 101 | Transitional as specified in RFC1866 and RFC2854. 102 | .TP 0.2i 103 | \(bu 104 | Maildir as specified in 105 | <URL:http://www.qmail.org/qmail-manual-html/man5/maildir.html> and 106 | <URL:http://cr.yp.to/proto/maildir.html>\&. 107 | .TP 0.2i 108 | \(bu 109 | The mbox mail storage format as specified in 110 | <URL:http://www.qmail.org/qmail-manual-html/man5/mbox.html>\&. 111 | .TP 0.2i 112 | \(bu 113 | Registered MIME media types as specified in RFC2048. 114 | .TP 0.2i 115 | \(bu 116 | Script execution conforming to both UMN standards as laid out in UMN 117 | gopherd(1) and Bucktooth standards as specified at 118 | <URL:gopher://gopher.floodgap.com:70/0/buck/dbrowse%3ffaquse%202>, 119 | so far as each can be implemented consistent with secure 120 | design principles. 121 | .TP 0.2i 122 | \(bu 123 | Standard Python 2.2.1 or above as implemented on 124 | POSIX-compliant systems. 125 | .TP 0.2i 126 | \(bu 127 | WAP/WML as defined by the WAP Forum. 128 | .SH "BUGS" 129 | .PP 130 | Reports of bugs should be sent via e-mail to the \fBPyGopherd\fR issue tracker 131 | at <URL:https://github.com/michael-lazar/pygopherd/issues>\&. 132 | .PP 133 | The Web site also lists all current bugs, where you can check their 134 | status or contribute to fixing them. 135 | .SH "COPYRIGHT" 136 | .PP 137 | \fBPyGopherd\fR is Copyright (C) 2002-2019 John Goerzen, 2021 Michael Lazar. 138 | .PP 139 | This program is free software; you can redistribute it and/or 140 | modify it under the terms of the GNU General Public License as 141 | published by the Free Software Foundation; version 2 of the 142 | License. 143 | .PP 144 | This program is distributed in the hope that it will be useful, 145 | but WITHOUT ANY WARRANTY; without even the implied warranty of 146 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 147 | GNU General Public License for more details. 148 | .PP 149 | You should have received a copy of the GNU General Public License 150 | along with this program; if not, write to: 151 | 152 | .nf 153 | Free Software Foundation, Inc. 154 | 59 Temple Place 155 | Suite 330 156 | Boston, MA 02111-1307 157 | USA 158 | 159 | .fi 160 | .SH "AUTHOR" 161 | .PP 162 | \fBPyGopherd\fR, its libraries, documentation, and all included 163 | files (except where noted) was written by John Goerzen 164 | <jgoerzen@complete.org> 165 | and copyright is held as stated in the 166 | Copyright section. 167 | .PP 168 | Portions of this manual (specifically relating to certian UMN gopherd 169 | features and characteristics that PyGopherd emulates) are modified 170 | versions of the original 171 | gopherd(1) manpage accompanying the UMN gopher distribution. That 172 | document is distributed under the same terms as this, and 173 | bears the following copyright notices: 174 | 175 | .nf 176 | Copyright (C) 1991-2000 University of Minnesota 177 | Copyright (C) 2000-2002 John Goerzen and other developers 178 | 179 | .fi 180 | .PP 181 | \fBPyGopherd\fR may be downloaded, and information found, from its 182 | homepage: 183 | .PP 184 | <URL:https://github.com/michael-lazar/pygopherd> 185 | .PP 186 | .SH "SEE ALSO" 187 | .PP 188 | python (1). 189 | -------------------------------------------------------------------------------- /doc/pygopherd.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/doc/pygopherd.pdf -------------------------------------------------------------------------------- /doc/quickstart.sgml: -------------------------------------------------------------------------------- 1 | 2 | <para> 3 | If you have already installed &PyGopherd; system-wide, or your 4 | administrator has done that for you, your task for setting up 5 | &PyGopherd; for the first time is quite simple. You just need 6 | to set up your configuration file, make your folder directory, 7 | and run it! 8 | </para> 9 | 10 | <para> 11 | You can quickly set up your configuration file. The 12 | distribution includes two files of interest: 13 | <filename>conf/pygopherd.conf</filename> and 14 | <filename>conf/mime.types</filename>. Debian users will find 15 | the configuration file pre-installed in 16 | <filename>/etc/pygopherd/pygopherd.conf</filename> and the 17 | <filename>mime.types</filename> file provided by the system 18 | already. 19 | </para> 20 | 21 | <para> 22 | Open up <filename>pygopherd.conf</filename> in your editor and 23 | adjust to suit. The file is heavily commented and you can 24 | refer to it for detailed information. Some settings to take a 25 | look at include: <property>detach</property>, 26 | <property>pidfile</property>, <property>port</property>, 27 | <property>usechroot</property>, <property>setuid</property>, 28 | <property>setgid</property>, and <property>root</property>. 29 | These may or may not work at their defaults for you. The 30 | remaining ones should be fine for a basic setup. 31 | </para> 32 | 33 | <para> 34 | Invoke &PyGopherd; with <command>pygopherd 35 | path/to/configfile</command> (or 36 | <command>/etc/init.d/pygopherd start</command> on Debian). 37 | Place some files in the location specified by the 38 | <property>root</property> directive in the config file and 39 | you're ready to run! 40 | </para> 41 | -------------------------------------------------------------------------------- /doc/standards/gophermap.txt: -------------------------------------------------------------------------------- 1 | Serving files and the gophermap file 2 | ------------------------------------ 3 | 4 | The gophermap file is responsible for the look of a gopher menu. 5 | 6 | Unlike the UMN gopherd-style map files, which are somewhat cumbersome and 7 | can get rather large, Bucktooth encourages a slimline approach, or you can 8 | have none at all. This is not too secure since it will happily serve any and 9 | every file in its mountpoint to a greedy user, but if that's really what you 10 | want, congratulations. You can stop reading this now, since that's exactly 11 | what it will do when you install it with no gophermap files. Only gophermap, 12 | ., and .. are not served to the user. 13 | 14 | Assuming you want to do a little more customisation than that, you can 15 | edit the gophermap file (one per directory) with any text editor and follow a 16 | few simple rules to gopher goodness. (A sample file is in stuff/ for your 17 | enjoyment.) 18 | 19 | Bucktooth sends any RFC-1436 compliant line to the client. In other words, 20 | 21 | 1gopher.ptloma.edu home<TAB><TAB>gopher.ptloma.edu<TAB>70 22 | 23 | where <TAB>, is of course, the tab (CTRL-I, 0x09) character, generates a 24 | link to "null" selector on gopher.ptloma.edu 70 with an itemtype of 1 and 25 | a display string of "gopher.ptloma.edu home". You don't even have to enter 26 | valid selectors, although this will not endear you much to your users. 27 | 28 | If you are not well-versed in RFC-1436, it breaks down to the first character 29 | being the itemtype (0 = text, 1 = gopher menu, 5 = zip file, 9 = generic 30 | binary, 7 = search server, I = generic image, g = gif image; others are 31 | also supported by some clients), then the string shown by the client up to 32 | the first tab ("display string"); then the full path to the resource 33 | ("selector"); the hostname of the server; and the port. 34 | 35 | Since this would be a drag to always have to type things out in full, 36 | Bucktooth allows the following shortcuts: 37 | 38 | * If you don't specify a port, Bucktooth provides the one your server is 39 | using (almost always 70). 40 | 41 | * If you don't specify a host, Bucktooth provides your server's hostname. 42 | 43 | * If you only specify a relative selector and not an absolute path, Bucktooth 44 | sticks on the path they're browsing. 45 | 46 | So, if your server is gopher.somenetwork.com and your server's port is 7070, 47 | and this gophermap is inside of /lotsa, then 48 | 49 | 1Lots of stuff<TAB>stuff 50 | 51 | is expanded out to 52 | 53 | 1Lots of stuff<TAB>/lotsa/stuff<TAB>gopher.somenetwork.com<TAB>7070 54 | 55 | If you don't specify a selector, two things can happen. Putting a <TAB> at 56 | the end, like 57 | 58 | 1src<TAB> 59 | 60 | explicitly tells Bucktooth you aren't specifying a selector, so Bucktooth 61 | uses your display string as the selector, adds on the host and port, and 62 | gives the client that. 63 | 64 | Otherwise, Bucktooth sees it as a description, and has the client display it 65 | as text. This allows you to add text descriptions to your menus. However, 66 | don't use the <TAB> character anywhere in your text description or Bucktooth 67 | will try to interpret it as an RFC-1436 resource, which will yield possibly 68 | hilarious and definitely erroneous results. 69 | 70 | One last warning: keep display strings at 67 characters or less -- some 71 | clients may abnormally wrap them or display them in a way you didn't intend. 72 | 73 | 74 | . 75 | 76 | -------------------------------------------------------------------------------- /examples/gophermap: -------------------------------------------------------------------------------- 1 | Welcome to Pygopherd! You can place your documents 2 | in /var/gopher for future use. You can remove the gophermap 3 | file there to get rid of this message, or you can edit it to 4 | use other things. (You'll need to do at least one of these 5 | two things in order to get your own data to show up!) 6 | 7 | Some links to get you started: 8 | 9 | 1Pygopherd Home /devel/gopher/pygopherd gopher.quux.org 70 10 | 1Quux.Org Mega Server / gopher.quux.org 70 11 | 1The Gopher Project /Software/Gopher gopher.quux.org 70 12 | 1Traditional UMN Home Gopher / gopher.tc.umn.edu 70 13 | 14 | Welcome to the world of Gopher and enjoy! 15 | -------------------------------------------------------------------------------- /examples/pygopherd.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Pygopherd Server 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | User=gopher 8 | Group=gopher 9 | ProtectHome=true 10 | ProtectSystem=strict 11 | NoNewPrivileges=true 12 | RuntimeDirectory=pygopherd 13 | ReadWritePaths=/var/run/pygopherd /run/pygopherd /var/gopher 14 | SyslogIdentifier=pygopherd 15 | CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_SYS_CHROOT 16 | AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_SYS_CHROOT 17 | ExecStart=/usr/sbin/pygopherd 18 | Restart=always 19 | TimeoutStopSec=5 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | 24 | -------------------------------------------------------------------------------- /examples/talsample.html.tal: -------------------------------------------------------------------------------- 1 | <html> 2 | <head> 3 | <title>TAL Test 4 | 5 | 6 | My selector is: selector
7 | My MIME type is: foo/bar
8 | Another way of getting that is: foo/bar
9 | Gopher type is: X
10 | My handler is: handlername
11 | My protocol is: protocol
12 | Python path enabling status: 123
13 | My vfs is: vfs
14 | Math: 5 15 | 16 | 17 | -------------------------------------------------------------------------------- /local.dsl: -------------------------------------------------------------------------------- 1 | 3 | ]> 4 | 5 | 6 | 7 | 8 | 9 | (define (toc-depth nd) 10 | (if (string=? (gi nd) (normalize "book")) 11 | 1 12 | 1)) 13 | (define %generate-article-toc% #t) 14 | 15 | ;; Don't split up the doc as much. 16 | (define (chunk-element-list) 17 | (list (normalize "preface") 18 | (normalize "chapter") 19 | (normalize "appendix") 20 | (normalize "article") 21 | (normalize "glossary") 22 | (normalize "bibliography") 23 | (normalize "index") 24 | (normalize "colophon") 25 | (normalize "setindex") 26 | (normalize "reference") 27 | (normalize "refentry") 28 | (normalize "part") 29 | (normalize "book") ;; just in case nothing else matches... 30 | (normalize "set") ;; sets are definitely chunks... 31 | )) 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /printlocal.dsl: -------------------------------------------------------------------------------- 1 | 3 | ]> 4 | 5 | 6 | 7 | 8 | 9 | (define tex-backend #t) 10 | (define bop-footnotes #t) 11 | (define %two-side% #t) 12 | (define %footnote-ulinks% #t) 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /pygopherd/GopherExceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from pygopherd import logger 6 | 7 | if typing.TYPE_CHECKING: 8 | from pygopherd.handlers.base import BaseHandler 9 | from pygopherd.protocols.base import BaseGopherProtocol 10 | 11 | 12 | tracebacks = 0 13 | 14 | 15 | def log( 16 | exception: Exception, 17 | protocol: typing.Optional[BaseGopherProtocol] = None, 18 | handler: typing.Optional[BaseHandler] = None, 19 | ): 20 | """Logs an exception. It will try to generate a nice-looking string 21 | based on the arguments passed in.""" 22 | protostr = "None" 23 | handlerstr = "None" 24 | ipaddr = "unknown-address" 25 | exceptionclass = type(exception).__name__ 26 | if protocol: 27 | protostr = type(protocol).__name__ 28 | ipaddr = protocol.requesthandler.client_address[0] 29 | if handler: 30 | handlerstr = type(handler).__name__ 31 | 32 | logger.log( 33 | "%s [%s/%s] EXCEPTION %s: %s" 34 | % (ipaddr, protostr, handlerstr, exceptionclass, str(exception)) 35 | ) 36 | 37 | 38 | def init(backtraceenabled): 39 | global tracebacks 40 | tracebacks = backtraceenabled 41 | 42 | 43 | class FileNotFound(Exception): 44 | def __init__( 45 | self, 46 | selector: str, 47 | comments: str = "", 48 | protocol: typing.Optional[BaseGopherProtocol] = None, 49 | ): 50 | self.selector = selector 51 | self.comments = comments 52 | self.protocol = protocol 53 | 54 | log(self, self.protocol, None) 55 | 56 | def __str__(self): 57 | retval = "'%s' does not exist" % self.selector 58 | if self.comments: 59 | retval += " (%s)" % self.comments 60 | 61 | return retval 62 | -------------------------------------------------------------------------------- /pygopherd/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.1" 2 | 3 | __all__ = [ 4 | "handlers", 5 | "protocols", 6 | "GopherExceptions", 7 | "gopherentry", 8 | "logger", 9 | "fileext", 10 | "initialization", 11 | "testutil", 12 | "server", 13 | ] 14 | -------------------------------------------------------------------------------- /pygopherd/fileext.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import mimetypes 3 | import typing 4 | 5 | typemap: typing.Dict[str, typing.List[str]] = {} 6 | 7 | 8 | def extcmp(x, y): 9 | if x.count(".") > y.count("."): 10 | return 1 11 | if x.count(".") < y.count("."): 12 | return -1 13 | if len(x) > len(y): 14 | return 1 15 | if len(x) < len(y): 16 | return -1 17 | return (x > y) - (y < x) 18 | 19 | 20 | extkey = functools.cmp_to_key(extcmp) 21 | 22 | 23 | def extstrip(file, filetype): 24 | """Strips off the extension from file given type and returns the result. 25 | Returns file unmodified if no action is possible.""" 26 | if not (filetype and filetype in typemap): 27 | return file 28 | for possible in typemap[filetype]: 29 | if file.endswith(possible): 30 | extindex = file.rfind(possible) 31 | return file[0:extindex] 32 | return file 33 | 34 | 35 | def init(): 36 | for fileext, filetype in list(mimetypes.types_map.items()): 37 | extlist = [] 38 | if filetype in typemap: 39 | extlist = typemap[filetype] 40 | 41 | baselist = [] 42 | # Add the basic extension. 43 | baselist.append(fileext) 44 | # Add it in all encoding flavors. 45 | baselist.extend([fileext + enc for enc in list(mimetypes.encodings_map.keys())]) 46 | 47 | for shortsuff, longsuff in list(mimetypes.suffix_map.items()): 48 | if longsuff in baselist: 49 | baselist.append(shortsuff) 50 | 51 | extlist.extend(baselist) 52 | extlist.sort(key=extkey) 53 | extlist.reverse() 54 | typemap[filetype] = extlist 55 | -------------------------------------------------------------------------------- /pygopherd/handlers/HandlerMultiplexer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import configparser 4 | import typing 5 | 6 | from pygopherd import GopherExceptions 7 | 8 | # Running eval() when loading the configuration requires all of the handlers to 9 | # be in the module namespace 10 | from pygopherd.handlers import * # noqa 11 | from pygopherd.handlers.base import BaseHandler, VFS_Real 12 | 13 | if typing.TYPE_CHECKING: 14 | from pygopherd.protocols.base import BaseGopherProtocol 15 | 16 | handlers: typing.Optional[typing.List[BaseHandler]] = None 17 | rootpath: typing.Optional[str] = None 18 | 19 | 20 | def init_default_handlers(config: configparser.ConfigParser) -> None: 21 | global handlers, rootpath 22 | if not handlers: 23 | handlers = eval(config.get("handlers.HandlerMultiplexer", "handlers")) 24 | rootpath = config.get("pygopherd", "root") 25 | 26 | 27 | def getHandler( 28 | selector: str, 29 | searchrequest: str, 30 | protocol: BaseGopherProtocol, 31 | config: configparser.ConfigParser, 32 | handlerlist: typing.Optional[typing.List[BaseHandler]] = None, 33 | vfs: typing.Optional[VFS_Real] = None, 34 | ): 35 | """Called without handlerlist specified, uses the default as listed 36 | in config.""" 37 | global handlers, rootpath 38 | init_default_handlers(config) 39 | 40 | if vfs is None: 41 | vfs = VFS_Real(config) 42 | 43 | if handlerlist is None: 44 | handlerlist = handlers 45 | 46 | typing.cast(handlers, typing.List[BaseHandler]) 47 | typing.cast(rootpath, str) 48 | 49 | # SECURITY: assert that our absolute path is within the absolute 50 | # path of the site root. 51 | 52 | # if not os.path.abspath(rootpath + '/' + selector). \ 53 | # startswith(os.path.abspath(rootpath)): 54 | # raise GopherExceptions.FileNotFound, \ 55 | # [selector, "Requested document is outside the server root", 56 | # protocol] 57 | 58 | statresult = None 59 | try: 60 | statresult = vfs.stat(selector) 61 | except OSError: 62 | pass 63 | for handler in handlerlist: 64 | htry = handler(selector, searchrequest, protocol, config, statresult, vfs) 65 | if htry.isrequestforme(): 66 | return htry.gethandler() 67 | 68 | raise GopherExceptions.FileNotFound(selector, "no handler found", protocol) 69 | -------------------------------------------------------------------------------- /pygopherd/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "base", 3 | "dir", 4 | "file", 5 | "url", 6 | "gophermap", 7 | "UMN", 8 | "ZIP", 9 | "html", 10 | "mbox", 11 | "virtual", 12 | "pyg", 13 | "scriptexec", 14 | "tal", 15 | ] 16 | -------------------------------------------------------------------------------- /pygopherd/handlers/dir.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import re 3 | import stat 4 | import time 5 | import typing 6 | 7 | from pygopherd import gopherentry, handlers 8 | from pygopherd.handlers.base import BaseHandler 9 | 10 | 11 | class DirHandler(BaseHandler): 12 | cachetime: int 13 | cachefile: str 14 | cachename: str 15 | fromcache: bool 16 | files: typing.List[str] 17 | fileentries: typing.List[gopherentry.GopherEntry] 18 | selectorbase: str 19 | 20 | def canhandlerequest(self) -> bool: 21 | """We can handle the request if it's for a directory.""" 22 | return self.statresult and stat.S_ISDIR(self.statresult[stat.ST_MODE]) 23 | 24 | def getentry(self) -> gopherentry.GopherEntry: 25 | if not self.entry: 26 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 27 | self.entry.populatefromfs(self.getselector(), self.statresult, vfs=self.vfs) 28 | return self.entry 29 | 30 | def prep_initfiles(self) -> None: 31 | """Initialize the list of files. Ignore the files we're suppoed to.""" 32 | self.files = [] 33 | dirfiles = self.vfs.listdir(self.getselector()) 34 | ignorepatt = self.config.get("handlers.dir.DirHandler", "ignorepatt") 35 | for file in dirfiles: 36 | if self.prep_initfiles_canaddfile( 37 | ignorepatt, self.selectorbase + "/" + file, file 38 | ): 39 | self.files.append(file) 40 | 41 | def prep_initfiles_canaddfile( 42 | self, ignorepatt: str, pattern: str, file: str 43 | ) -> bool: 44 | return not re.search(ignorepatt, pattern) 45 | 46 | def prep_entries(self) -> None: 47 | """Generate entries from the list.""" 48 | self.fileentries = [] 49 | for file in self.files: 50 | # We look up the appropriate handler for this object, and ask 51 | # it to give us an entry object. 52 | handler = handlers.HandlerMultiplexer.getHandler( 53 | self.selectorbase + "/" + file, 54 | self.searchrequest, 55 | self.protocol, 56 | self.config, 57 | vfs=self.vfs, 58 | ) 59 | fileentry = handler.getentry() 60 | self.prep_entriesappend(file, handler, fileentry) 61 | 62 | def prep_entriesappend( 63 | self, file: str, handler: BaseHandler, fileentry: gopherentry.GopherEntry 64 | ): 65 | """Subclasses can override to do post-processing on the entry while 66 | we still have the filename around. 67 | IE, for .cap files.""" 68 | self.fileentries.append(fileentry) 69 | 70 | def prepare(self) -> bool: 71 | # Initialize some variables. 72 | 73 | self.selectorbase = self.selector 74 | if self.selectorbase == "/": 75 | self.selectorbase = "" # Avoid dup slashes 76 | 77 | if self.loadcache(): 78 | # No need to do anything else. 79 | return False # Did nothing. 80 | 81 | self.prep_initfiles() 82 | 83 | # Sort the list. 84 | self.files.sort() 85 | 86 | self.prep_entries() 87 | return True # Did something. 88 | 89 | def isdir(self) -> bool: 90 | return True 91 | 92 | def getdirlist(self): 93 | self.savecache() 94 | return self.fileentries 95 | 96 | def loadcache(self) -> bool: 97 | self.fromcache = False 98 | if not hasattr(self, "cachetime"): 99 | self.cachetime = self.config.getint("handlers.dir.DirHandler", "cachetime") 100 | self.cachefile = self.config.get("handlers.dir.DirHandler", "cachefile") 101 | self.cachename = self.selector + "/" + self.cachefile 102 | 103 | if not self.vfs.iswritable(self.cachename): 104 | return False 105 | 106 | try: 107 | statval = self.vfs.stat(self.cachename) 108 | except OSError: 109 | return False 110 | 111 | if time.time() - statval[stat.ST_MTIME] < self.cachetime: 112 | with self.vfs.open(self.cachename, "rb") as fp: 113 | self.fileentries = pickle.load(fp) 114 | self.fromcache = True 115 | return True 116 | return False 117 | 118 | def savecache(self) -> None: 119 | if self.fromcache: 120 | # Don't resave the cache. 121 | return 122 | if not self.vfs.iswritable(self.cachename): 123 | return 124 | try: 125 | with self.vfs.open(self.cachename, "wb") as fp: 126 | pickle.dump(self.fileentries, fp, 1) 127 | except IOError: 128 | pass 129 | -------------------------------------------------------------------------------- /pygopherd/handlers/file.py: -------------------------------------------------------------------------------- 1 | import re 2 | import stat 3 | import subprocess 4 | import typing 5 | 6 | from pygopherd import gopherentry 7 | from pygopherd.handlers.base import BaseHandler 8 | 9 | 10 | class CompressedGopherEntry(gopherentry.GopherEntry): 11 | """ 12 | Using an abstract class because we attach extra variables to the gopher entry. 13 | """ 14 | 15 | realencoding: str 16 | 17 | 18 | class FileHandler(BaseHandler): 19 | def canhandlerequest(self): 20 | """We can handle the request if it's for a file.""" 21 | return self.statresult and stat.S_ISREG(self.statresult[stat.ST_MODE]) 22 | 23 | def getentry(self): 24 | if not self.entry: 25 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 26 | self.entry.populatefromfs(self.getselector(), self.statresult, vfs=self.vfs) 27 | return self.entry 28 | 29 | def write(self, wfile): 30 | self.vfs.copyto(self.getselector(), wfile) 31 | 32 | 33 | class CompressedFileHandler(FileHandler): 34 | decompressors: typing.Dict[str, str] 35 | decompresspatt: str 36 | entry: typing.Optional[CompressedGopherEntry] 37 | 38 | def canhandlerequest(self): 39 | self.initdecompressors() 40 | 41 | # It's OK to call just canhandlerequest() since we're not 42 | # overriding the security or isrequestforme functions. 43 | 44 | return ( 45 | super().canhandlerequest() 46 | and self.getentry().realencoding 47 | and self.getentry().realencoding in self.decompressors 48 | and re.search(self.decompresspatt, self.selector) 49 | ) 50 | 51 | def getentry(self) -> CompressedGopherEntry: 52 | if not self.entry: 53 | self.entry = typing.cast(CompressedGopherEntry, super().getentry()) 54 | 55 | self.entry.realencoding = None 56 | if ( 57 | self.entry.getencoding() 58 | and self.entry.getencoding() in self.decompressors 59 | and self.entry.getencodedmimetype() 60 | ): 61 | # When the client gets it, there will not be 62 | # encoding. Therefore, we remove the encoding and switch 63 | # to the real MIME type. 64 | self.entry.mimetype = self.entry.getencodedmimetype() 65 | self.entry.encodedmimetype = None 66 | self.entry.realencoding = self.entry.encoding 67 | self.entry.encoding = None 68 | self.entry.type = self.entry.guesstype() 69 | return self.entry 70 | 71 | def initdecompressors(self) -> None: 72 | if not hasattr(self, "decompressors"): 73 | self.decompressors = eval( 74 | self.config.get("handlers.file.CompressedFileHandler", "decompressors") 75 | ) 76 | self.decompresspatt = self.config.get( 77 | "handlers.file.CompressedFileHandler", "decompresspatt" 78 | ) 79 | 80 | def write(self, wfile): 81 | decompprog = self.decompressors[self.getentry().realencoding] 82 | with self.vfs.open(self.getselector(), "rb") as fp: 83 | subprocess.run([decompprog], stdin=fp, stdout=wfile) 84 | -------------------------------------------------------------------------------- /pygopherd/handlers/gophermap.py: -------------------------------------------------------------------------------- 1 | import re 2 | import stat 3 | 4 | from pygopherd import gopherentry 5 | from pygopherd.handlers.base import BaseHandler 6 | 7 | 8 | class BuckGophermapHandler(BaseHandler): 9 | """Bucktooth selector handler. Adheres to the specification 10 | at gopher://gopher.floodgap.com:70/0/buck/dbrowse%3Ffaquse%201""" 11 | 12 | def canhandlerequest(self): 13 | """We can handle the request if it's for a directory AND 14 | the directory has a gophermap file.""" 15 | return self.statresult and ( 16 | ( 17 | stat.S_ISDIR(self.statresult[stat.ST_MODE]) 18 | and self.vfs.isfile(self.getselector() + "/gophermap") 19 | ) 20 | or ( 21 | stat.S_ISREG(self.statresult[stat.ST_MODE]) 22 | and self.getselector().endswith(".gophermap") 23 | ) 24 | ) 25 | 26 | def getentry(self): 27 | if not self.entry: 28 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 29 | if ( 30 | self.statresult 31 | and stat.S_ISREG(self.statresult[stat.ST_MODE]) 32 | and self.getselector().endswith(".gophermap") 33 | ): 34 | self.entry.populatefromvfs(self.vfs, self.getselector()) 35 | else: 36 | self.entry.populatefromfs( 37 | self.getselector(), self.statresult, vfs=self.vfs 38 | ) 39 | 40 | return self.entry 41 | 42 | def prepare(self): 43 | self.selectorbase = self.selector 44 | if self.selectorbase == "/": 45 | self.selectorbase = "" # Avoid dup slashes 46 | 47 | if ( 48 | self.getselector().endswith(".gophermap") 49 | and self.statresult 50 | and stat.S_ISREG(self.statresult[stat.ST_MODE]) 51 | ): 52 | selector = self.getselector() 53 | else: 54 | selector = self.selectorbase + "/gophermap" 55 | 56 | self.entries = [] 57 | 58 | selectorbase = self.selectorbase 59 | 60 | with self.vfs.open(selector, "rb") as rfile: 61 | while True: 62 | line = rfile.readline().decode(errors="surrogateescape") 63 | if not line: 64 | break 65 | if re.search("\t", line): # gophermap link 66 | args = [arg.strip() for arg in line.split("\t")] 67 | 68 | if len(args) < 2 or not len(args[1]): 69 | args[1] = args[0][1:] # Copy display string to selector 70 | 71 | selector = args[1] 72 | if selector[0] != "/" and selector[0:4] != "URL:": # Relative link 73 | selector = selectorbase + "/" + selector 74 | 75 | entry = gopherentry.GopherEntry(selector, self.config) 76 | entry.type = args[0][0] 77 | entry.name = args[0][1:] 78 | 79 | if len(args) >= 3 and len(args[2]): 80 | entry.host = args[2] 81 | 82 | if len(args) >= 4 and len(args[3]): 83 | entry.port = int(args[3]) 84 | 85 | if entry.gethost() is None and entry.getport() is None: 86 | # If we're using links on THIS server, try to fill 87 | # it in for gopher+. 88 | if self.vfs.exists(selector): 89 | entry.populatefromvfs(self.vfs, selector) 90 | self.entries.append(entry) 91 | else: # Info line 92 | line = line.strip() 93 | self.entries.append(gopherentry.getinfoentry(line, self.config)) 94 | 95 | def isdir(self): 96 | return True 97 | 98 | def getdirlist(self): 99 | return self.entries 100 | -------------------------------------------------------------------------------- /pygopherd/handlers/html.py: -------------------------------------------------------------------------------- 1 | import html.entities 2 | import html.parser 3 | import mimetypes 4 | import re 5 | 6 | from pygopherd.handlers.file import FileHandler 7 | 8 | 9 | class HTMLTitleParser(html.parser.HTMLParser): 10 | def __init__(self): 11 | super().__init__() 12 | self.titlestr = "" 13 | self.readingtitle = 0 14 | self.gotcompletetitle = 0 15 | 16 | def handle_starttag(self, tag, attrs): 17 | if tag == "title": 18 | self.readingtitle = 1 19 | 20 | def handle_endtag(self, tag): 21 | if tag == "title": 22 | self.gotcompletetitle = 1 23 | self.readingtitle = 0 24 | 25 | def handle_data(self, data): 26 | if self.readingtitle: 27 | self.titlestr += data 28 | 29 | def handle_entityref(self, name): 30 | """Handle things like & or > or <. If it's not in 31 | the dictionary, ignore it.""" 32 | if self.readingtitle and name in html.entities.entitydefs: 33 | self.titlestr += html.entities.entitydefs[name] 34 | 35 | 36 | class HTMLFileTitleHandler(FileHandler): 37 | """This class will set the title of a HTML document based on the 38 | HTML title. It is a clone of the UMN gsfindhtmltitle function.""" 39 | 40 | def canhandlerequest(self): 41 | if FileHandler.canhandlerequest(self): 42 | mimetype, encoding = mimetypes.guess_type(self.selector) 43 | return mimetype == "text/html" 44 | else: 45 | return False 46 | 47 | def getentry(self): 48 | # Start with the entry from the parent. 49 | entry = FileHandler.getentry(self) 50 | parser = HTMLTitleParser() 51 | 52 | with self.vfs.open(self.getselector(), "rb") as fp: 53 | while not parser.gotcompletetitle: 54 | line = fp.readline() 55 | if not line: 56 | break 57 | # The PY3 HTML parser doesn't handle surrogateescape 58 | parser.feed(line.decode(errors="replace")) 59 | parser.close() 60 | 61 | # OK, we've parsed the file and exited because of either an EOF 62 | # or a complete title (or error). Now, figure out what happened. 63 | 64 | if parser.gotcompletetitle: 65 | # Convert all whitespace sequences to a single space. 66 | # Removes newlines, tabs, etc. Good for presentation 67 | # and for security. 68 | title = re.sub(r"[\s]+", " ", parser.titlestr) 69 | entry.setname(title) 70 | return entry 71 | -------------------------------------------------------------------------------- /pygopherd/handlers/mbox.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import re 3 | import stat 4 | import typing 5 | from email.header import Header 6 | from mailbox import Maildir, Message, mbox 7 | 8 | from pygopherd import gopherentry 9 | from pygopherd.handlers.base import VFS_Real 10 | from pygopherd.handlers.virtual import Virtual 11 | 12 | 13 | class FolderHandler(Virtual): 14 | 15 | mbox: typing.Union[mbox, Maildir] 16 | entries: typing.List[gopherentry.GopherEntry] 17 | 18 | def getentry(self): 19 | # Return my own entry. 20 | if not self.entry: 21 | self.entry = gopherentry.GopherEntry(self.getselector(), self.config) 22 | self.entry.settype("1") 23 | self.entry.setname(os.path.basename(self.getselector())) 24 | self.entry.setmimetype("application/gopher-menu") 25 | self.entry.setgopherpsupport(0) 26 | return self.entry 27 | 28 | def prepare(self): 29 | self.entries = [] 30 | 31 | for index, message in enumerate(self.mbox, start=1): 32 | handler = MessageHandler( 33 | self.genargsselector(self.getargflag() + str(index)), 34 | self.searchrequest, 35 | self.protocol, 36 | self.config, 37 | None, 38 | ) 39 | self.entries.append(handler.getentry(message)) 40 | 41 | def isdir(self): 42 | return True 43 | 44 | def getdirlist(self): 45 | return self.entries 46 | 47 | def getargflag(self) -> str: 48 | raise NotImplementedError 49 | 50 | 51 | class MessageHandler(Virtual): 52 | 53 | message_num: int 54 | message: Message 55 | 56 | def canhandlerequest(self): 57 | """We put MBOX-MESSAGE in here so we don't have to re-check 58 | the first line of the mbox file before returning a true or false 59 | result.""" 60 | if not self.selectorargs: 61 | return False 62 | 63 | pattern = "^" + self.getargflag() + r"(\d+)$" 64 | match = re.search(pattern, self.selectorargs) 65 | if match is None: 66 | return False 67 | 68 | message_num = int(match.groups()[0]) 69 | if message_num < 1: 70 | return False 71 | 72 | self.message_num = message_num 73 | return True 74 | 75 | def getentry(self, message=None): 76 | """Set the message if called from, eg, the dir handler. Saves 77 | having to rescan the file. If not set, will figure it out.""" 78 | if not message: 79 | message = self.getmessage() 80 | 81 | if not self.entry: 82 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 83 | self.entry.settype("0") 84 | self.entry.setmimetype("text/plain") 85 | self.entry.setgopherpsupport(0) 86 | 87 | subject = message.get("Subject", "") 88 | if isinstance(subject, Header): 89 | subject = str(subject) 90 | 91 | # Sanitize, esp. for continuations. 92 | subject = re.sub(r"\s+", " ", subject) 93 | if subject: 94 | self.entry.setname(subject) 95 | else: 96 | self.entry.setname("") 97 | return self.entry 98 | 99 | def getmessage(self) -> Message: 100 | if hasattr(self, "message"): 101 | return self.message 102 | 103 | mailbox = iter(self.openmailbox()) 104 | message = None 105 | for _ in range(self.message_num): 106 | message = next(mailbox) 107 | 108 | self.message = message 109 | return self.message 110 | 111 | def prepare(self): 112 | self.canhandlerequest() # Init the vars 113 | 114 | def write(self, wfile): 115 | message = self.getmessage() 116 | wfile.write(message.as_bytes()) 117 | 118 | def getargflag(self) -> str: 119 | raise NotImplementedError 120 | 121 | def openmailbox(self): 122 | raise NotImplementedError 123 | 124 | 125 | class MBoxFolderHandler(FolderHandler): 126 | def canhandlerequest(self): 127 | """Figure out if this is a handleable request.""" 128 | # Must be a real file 129 | if ( 130 | not isinstance(self.vfs, VFS_Real) 131 | or self.selectorargs 132 | or not self.statresult 133 | or not stat.S_ISREG(self.statresult[stat.ST_MODE]) 134 | ): 135 | return False 136 | 137 | try: 138 | with self.vfs.open(self.getselector(), "rb") as fd: 139 | startline = fd.readline() 140 | except IOError: 141 | return False 142 | 143 | # Python 2 had an old "UnixMailbox" class that had more strict 144 | # pattern matching on the message "From:" line. This was deprecated 145 | # as early as python 2.5 and was dropped completely in python 3. 146 | # Since we are using the first line of the file to determine if the 147 | # filetype is a mbox or not, it's safer to be stricter with the 148 | # pattern matching and port over the old UnixMailbox pattern. 149 | fromlinepattern = ( 150 | rb"From \s*[^\s]+\s+\w\w\w\s+\w\w\w\s+\d?\d\s+" 151 | rb"\d?\d:\d\d(:\d\d)?(\s+[^\s]+)?\s+\d\d\d\d\s*" 152 | rb"[^\s]*\s*" 153 | b"$" 154 | ) 155 | return re.match(fromlinepattern, startline) 156 | 157 | def prepare(self): 158 | self.mbox = mbox(self.getfspath(), create=False) 159 | super().prepare() 160 | 161 | def getargflag(self): 162 | return "/MBOX-MESSAGE/" 163 | 164 | 165 | class MBoxMessageHandler(MessageHandler): 166 | def getargflag(self): 167 | return "/MBOX-MESSAGE/" 168 | 169 | def openmailbox(self): 170 | return mbox(self.getfspath(), create=False) 171 | 172 | 173 | class MaildirFolderHandler(FolderHandler): 174 | def canhandlerequest(self): 175 | if not isinstance(self.vfs, VFS_Real): 176 | return 0 177 | if self.selectorargs: 178 | return 0 179 | if not (self.statresult and stat.S_ISDIR(self.statresult[stat.ST_MODE])): 180 | return 0 181 | return self.vfs.isdir(self.getselector() + "/new") and self.vfs.isdir( 182 | self.getselector() + "/cur" 183 | ) 184 | 185 | def prepare(self): 186 | self.mbox = Maildir(self.getfspath()) 187 | super().prepare() 188 | 189 | def getargflag(self): 190 | return "/MAILDIR-MESSAGE/" 191 | 192 | 193 | class MaildirMessageHandler(MessageHandler): 194 | def getargflag(self): 195 | return "/MAILDIR-MESSAGE/" 196 | 197 | def openmailbox(self): 198 | return Maildir(self.getfspath(), create=False) 199 | -------------------------------------------------------------------------------- /pygopherd/handlers/pyg.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | from importlib.machinery import SourceFileLoader 3 | import re 4 | import stat 5 | 6 | from pygopherd.handlers.base import VFS_Real 7 | from pygopherd.handlers.virtual import Virtual 8 | 9 | 10 | class PYGHandler(Virtual): 11 | def canhandlerequest(self) -> bool: 12 | if not isinstance(self.vfs, VFS_Real): 13 | return False 14 | 15 | if not ( 16 | self.statresult 17 | # Is it a regular file? 18 | and stat.S_ISREG(self.statresult[stat.ST_MODE]) 19 | # Is it executable? 20 | and (stat.S_IMODE(self.statresult[stat.ST_MODE]) & stat.S_IXOTH) 21 | and re.search(r"\.pyg$", self.getselector()) 22 | ): 23 | return False 24 | 25 | fspath = self.getfspath() 26 | loader = SourceFileLoader("PYGHandler", fspath) 27 | spec = importlib.util.spec_from_file_location("PYGHandler", fspath, loader=loader) 28 | if spec is None: 29 | return False 30 | 31 | self.module = importlib.util.module_from_spec(spec) 32 | spec.loader.exec_module(self.module) 33 | 34 | self.pygclass = self.module.PYGMain 35 | self.pygobject = self.pygclass( 36 | self.selector, 37 | self.searchrequest, 38 | self.protocol, 39 | self.config, 40 | self.statresult, 41 | ) 42 | return self.pygobject.isrequestforme() 43 | 44 | def prepare(self): 45 | return self.pygobject.prepare() 46 | 47 | def getentry(self): 48 | return self.pygobject.getentry() 49 | 50 | def isdir(self): 51 | return self.pygobject.isdir() 52 | 53 | def getdirlist(self): 54 | return self.pygobject.getdirlist() 55 | 56 | def write(self, wfile): 57 | self.pygobject.write(wfile) 58 | 59 | 60 | class PYGBase(Virtual): 61 | pass 62 | -------------------------------------------------------------------------------- /pygopherd/handlers/scriptexec.py: -------------------------------------------------------------------------------- 1 | import os 2 | import stat 3 | import subprocess 4 | 5 | from pygopherd import gopherentry 6 | from pygopherd.handlers.base import VFS_Real 7 | from pygopherd.handlers.virtual import Virtual 8 | 9 | 10 | class ExecHandler(Virtual): 11 | def canhandlerequest(self): 12 | # We ONLY handle requests from the real filesystem. 13 | return ( 14 | isinstance(self.vfs, VFS_Real) 15 | and self.statresult 16 | and stat.S_ISREG(self.statresult[stat.ST_MODE]) 17 | and (stat.S_IMODE(self.statresult[stat.ST_MODE]) & stat.S_IXOTH) 18 | ) 19 | 20 | def getentry(self): 21 | entry = gopherentry.GopherEntry(self.getselector(), self.config) 22 | entry.settype("0") 23 | entry.setname(os.path.basename(self.getselector())) 24 | entry.setmimetype("text/plain") 25 | entry.setgopherpsupport(0) 26 | return entry 27 | 28 | def write(self, wfile): 29 | newenv = os.environ.copy() 30 | newenv["SERVER_NAME"] = self.protocol.server.server_name 31 | newenv["SERVER_PORT"] = str(self.protocol.server.server_port) 32 | newenv["REMOTE_ADDR"] = self.protocol.requesthandler.client_address[0] 33 | newenv["REMOTE_PORT"] = str(self.protocol.requesthandler.client_address[1]) 34 | newenv["REMOTE_HOST"] = newenv["REMOTE_ADDR"] 35 | newenv["SELECTOR"] = self.selector 36 | newenv["REQUEST"] = self.getselector() 37 | if self.searchrequest: 38 | newenv["SEARCHREQUEST"] = self.searchrequest 39 | wfile.flush() 40 | 41 | args = [self.getfspath()] 42 | if self.selectorargs: 43 | args.extend(self.selectorargs.split(" ")) 44 | 45 | if not self.protocol.check_tls(): 46 | subprocess.run(args, env=newenv, stdout=wfile) 47 | else: 48 | # We can't pass the file handler because it's wrapped in a TLS context. 49 | # So grab the output from the CGI script and send it directly. 50 | resp = subprocess.run(args, env=newenv, capture_output=True) 51 | wfile.write(resp.stdout) 52 | -------------------------------------------------------------------------------- /pygopherd/handlers/tal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing 5 | 6 | from pygopherd import gopherentry 7 | from pygopherd.handlers.base import VFS_Real 8 | from pygopherd.handlers.file import FileHandler 9 | 10 | try: 11 | from simpletal import simpleTAL, simpleTALES 12 | 13 | talavailable = True 14 | except ImportError: 15 | talavailable = False 16 | 17 | 18 | class TALLoader: 19 | def __init__(self, vfs: VFS_Real, path: str): 20 | self.vfs = vfs 21 | self.path = path 22 | 23 | def getpath(self) -> str: 24 | return self.path 25 | 26 | def getparent(self) -> TALLoader: 27 | if self.path == "/": 28 | return self 29 | else: 30 | return self.__class__(self.vfs, os.path.dirname(self.path)) 31 | 32 | def getchildrennames(self) -> typing.List[str]: 33 | return self.vfs.listdir(self.path) 34 | 35 | def __getattr__(self, key): 36 | fq = os.path.join(self.path, key) 37 | if self.vfs.isfile(fq + ".html.tal"): 38 | with self.vfs.open( 39 | fq + ".html.tal", "r", errors="replace" 40 | ) as template_file: 41 | compiled = simpleTAL.compileHTMLTemplate(template_file) 42 | return compiled 43 | elif self.vfs.isdir(fq): 44 | return self.__class__(self.vfs, fq) 45 | else: 46 | raise AttributeError("Key %s not found in %s" % (key, self.path)) 47 | 48 | 49 | class RecursiveTALLoader(TALLoader): 50 | def __getattr__(self, key): 51 | if self.path == "/": 52 | # Already at the top -- can't recurse. 53 | return TALLoader.__getattr__(self, key) 54 | try: 55 | return TALLoader.__getattr__(self, key) 56 | except AttributeError: 57 | return self.getparent().__getattr__(key) 58 | 59 | 60 | class TALFileHandler(FileHandler): 61 | 62 | talbasename: str 63 | allowpythonpath: int 64 | 65 | def canhandlerequest(self): 66 | """We can handle the request if it's for a file ending with .thtml.""" 67 | canhandle = FileHandler.canhandlerequest(self) and self.getselector().endswith( 68 | ".tal" 69 | ) 70 | if not canhandle: 71 | return False 72 | self.talbasename = self.getselector()[:-4] 73 | self.allowpythonpath = 1 74 | if self.config.has_option("handlers.tal.TALFileHandler", "allowpythonpath"): 75 | self.allowpythonpath = self.config.getboolean( 76 | "handlers.tal.TALFileHandler", "allowpythonpath" 77 | ) 78 | return True 79 | 80 | def getentry(self): 81 | if not self.entry: 82 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 83 | self.entry.populatefromfs(self.getselector(), self.statresult, vfs=self.vfs) 84 | assert self.entry.getencoding() == "tal.TALFileHandler" 85 | # Remove the TAL encoding and revert to default. 86 | self.entry.mimetype = self.entry.getencodedmimetype() 87 | self.entry.encodedmimetype = None 88 | self.entry.realencoding = self.entry.encoding 89 | self.entry.encoding = None 90 | self.entry.type = self.entry.guesstype() 91 | 92 | return self.entry 93 | 94 | def write(self, wfile): 95 | context = simpleTALES.Context(allowPythonPath=self.allowpythonpath) 96 | context.addGlobal("selector", self.getselector()) 97 | context.addGlobal("handler", self) 98 | context.addGlobal("entry", self.getentry()) 99 | context.addGlobal("talbasename", self.talbasename) 100 | context.addGlobal("allowpythonpath", self.allowpythonpath) 101 | context.addGlobal("protocol", self.protocol) 102 | context.addGlobal("root", TALLoader(self.vfs, "/")) 103 | context.addGlobal("rroot", RecursiveTALLoader(self.vfs, "/")) 104 | dirname = os.path.dirname(self.getselector()) 105 | context.addGlobal("dir", TALLoader(self.vfs, dirname)) 106 | context.addGlobal("rdir", RecursiveTALLoader(self.vfs, dirname)) 107 | 108 | # SimpleTAL doesn't support reading from binary files 109 | with self.vfs.open(self.getselector(), "r", errors="replace") as rfile: 110 | template = simpleTAL.compileHTMLTemplate(rfile) 111 | template.expand(context, wfile) 112 | -------------------------------------------------------------------------------- /pygopherd/handlers/url.py: -------------------------------------------------------------------------------- 1 | import html 2 | import re 3 | 4 | from pygopherd import gopherentry, handlers 5 | from pygopherd.handlers.base import BaseHandler 6 | 7 | 8 | class HTMLURLHandler(BaseHandler): 9 | """Will take requests for a URL-like selector and generate 10 | a HTML page redirecting people to the actual URL. 11 | 12 | This implementation adheres to the proposal as specified at 13 | http://www.complete.org/mailinglists/archives/gopher-200202/msg00033.html 14 | """ 15 | 16 | def isrequestsecure(self): 17 | """For URLs, it is valid to have .., //, etc in the URLs.""" 18 | return ( 19 | self.canhandlerequest() 20 | and self.selector.find("\0") == -1 21 | and self.selector.find("\n") == -1 22 | and self.selector.find("\t") == -1 23 | and self.selector.find('"') == -1 24 | and self.selector.find("\r") == -1 25 | ) 26 | 27 | def canhandlerequest(self): 28 | """We can handle the request if it's for something that starts 29 | with http or https.""" 30 | return re.search("^(/|)URL:.+://", self.selector) 31 | 32 | def getentry(self): 33 | if not self.entry: 34 | self.entry = gopherentry.GopherEntry(self.selector, self.config) 35 | self.entry.name = self.selector 36 | self.entry.mimetype = "text/html" 37 | self.entry.type = "h" 38 | return self.entry 39 | 40 | # We have nothing to prepare. 41 | 42 | def write(self, wfile): 43 | url = self.selector[4:] # Strip off URL: 44 | if self.selector[0] == "/": 45 | url = self.selector[5:] 46 | 47 | url = html.escape(url) 48 | 49 | outdoc = "\n" 50 | outdoc += '' % url 51 | outdoc += "\n" 52 | outdoc += """ 53 | You are following a link from gopher to a website. You will be 54 | automatically taken to the web site shortly. If you do not get 55 | sent there, please click """ 56 | outdoc += 'here ' % url 57 | outdoc += """to go to the web site. 58 |

59 | The URL linked is: 60 |

""" 61 | outdoc += '%s' % (url, url) 62 | outdoc += """

63 | Thanks for using gopher! 64 |

65 | Document generated by pygopherd handlers.url.HTMLURLHandler 66 | """ 67 | wfile.write(outdoc.encode(errors="surrogateescape")) 68 | 69 | 70 | class URLTypeRewriter(BaseHandler): 71 | """Will take URLs that start with a file type (ie, 72 | /1/devel/offlineimap) and remove the type (/devel/offlineimap). Useful 73 | if you want to make relative links in both gopher and http space in 74 | a single document.""" 75 | 76 | def canhandlerequest(self): 77 | return ( 78 | len(self.selector) >= 3 79 | and self.selector[0] == "/" 80 | and self.selector[2] == "/" 81 | ) 82 | 83 | def gethandler(self): 84 | handlers.HandlerMultiplexer.init_default_handlers(self.config) 85 | handlerlist = [ 86 | x for x in handlers.HandlerMultiplexer.handlers if x != URLTypeRewriter 87 | ] 88 | return handlers.HandlerMultiplexer.getHandler( 89 | self.selector[2:], 90 | self.searchrequest, 91 | self.protocol, 92 | self.config, 93 | handlerlist, 94 | ) 95 | -------------------------------------------------------------------------------- /pygopherd/handlers/virtual.py: -------------------------------------------------------------------------------- 1 | from pygopherd.handlers.base import BaseHandler 2 | 3 | 4 | class Virtual(BaseHandler): 5 | """Implementation of virtual folder support. This class will probably 6 | not be instantiated itself but it is designed to be instantiated by 7 | its children.""" 8 | 9 | selectorreal: str 10 | selectorargs: str 11 | 12 | def __init__(self, selector, searchrequest, protocol, config, statresult, vfs=None): 13 | super().__init__(selector, searchrequest, protocol, config, statresult, vfs) 14 | 15 | # These hold the "real" and the "argument" portion of the selector, 16 | # respectively. 17 | 18 | if self.selector.find("?") != -1 or self.selector.find("|") != -1: 19 | try: 20 | i = self.selector.index("?") 21 | except ValueError: 22 | i = self.selector.index("|") 23 | 24 | self.selectorreal = self.selector[0:i] 25 | self.selectorargs = self.selector[i + 1 :] 26 | # Now, retry the stat with the real selector. 27 | self.statresult = None 28 | try: 29 | self.statresult = self.vfs.stat(self.selectorreal) 30 | except OSError: 31 | pass 32 | else: 33 | # Best guess. 34 | self.selectorreal = self.selector 35 | self.selectorargs = "" 36 | 37 | def genargsselector(self, args: str) -> str: 38 | """Returns a string representing a full selector to this resource, with 39 | the given string of args. This is a selector that can be passed 40 | back to clients.""" 41 | return self.getselector() + "|" + args 42 | 43 | def getselector(self) -> str: 44 | """Overridden to return the 'real' portion of the selector.""" 45 | return self.selectorreal 46 | -------------------------------------------------------------------------------- /pygopherd/initialization.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | import os.path 4 | import ssl 5 | 6 | # Import lots of stuff so it's here before chrooting. 7 | import sys 8 | import typing 9 | from configparser import ConfigParser 10 | 11 | import pygopherd.fileext 12 | import pygopherd.server 13 | from pygopherd import GopherExceptions, logger, sighandlers 14 | from pygopherd.server import GopherRequestHandler 15 | 16 | 17 | def init_config(filename: str) -> ConfigParser: 18 | if not (os.path.isfile(filename) and os.access(filename, os.R_OK)): 19 | raise Exception( 20 | f"Could NOT access config file {filename}\n" 21 | f"Please specify config file as a command-line argument\n" 22 | ) 23 | 24 | config = ConfigParser() 25 | config.read(filename) 26 | return config 27 | 28 | 29 | def init_logger(config: ConfigParser, filename: str) -> None: 30 | logger.init(config) 31 | logger.log(f"Pygopherd starting, using configuration file {filename}") 32 | 33 | 34 | def init_exceptions(config: ConfigParser) -> None: 35 | GopherExceptions.init(config.getboolean("pygopherd", "tracebacks")) 36 | 37 | 38 | def init_mimetypes(config: ConfigParser) -> None: 39 | files = config.get("pygopherd", "mimetypes").split(":") 40 | files = [x for x in files if os.path.isfile(x) and os.access(x, os.R_OK)] 41 | if not files: 42 | errmsg = "Could not find any mimetypes files; check mimetypes option in config." 43 | logger.log(errmsg) 44 | raise Exception(errmsg) 45 | 46 | encoding = eval(config.get("pygopherd", "encoding")) 47 | mimetypes.encodings_map.clear() 48 | for key, value in encoding: 49 | mimetypes.encodings_map[key] = value 50 | 51 | mimetypes.init(files) 52 | logger.log(f"mimetypes initialized with files: {files}") 53 | 54 | # Set up the inverse mapping file. 55 | 56 | pygopherd.fileext.init() 57 | 58 | 59 | def get_server( 60 | config: ConfigParser, context: typing.Optional[ssl.SSLContext] = None 61 | ) -> pygopherd.server.BaseServer: 62 | 63 | # Pick up the server type from the config. 64 | server_class: typing.Type[pygopherd.server.BaseServer] 65 | 66 | server_type = config.get("pygopherd", "servertype") 67 | if server_type == "ForkingTCPServer": 68 | server_class = pygopherd.server.ForkingTCPServer 69 | elif server_type == "ThreadingTCPServer": 70 | server_class = pygopherd.server.ThreadingTCPServer 71 | else: 72 | raise RuntimeError(f"Invalid servertype option: {server_type}") 73 | 74 | # Instantiate a server. Has to be done before the security so we can 75 | # get a privileged port if necessary. 76 | interface = "" 77 | if config.has_option("pygopherd", "interface"): 78 | interface = config.get("pygopherd", "interface") 79 | 80 | port = config.getint("pygopherd", "port") 81 | address = (interface, port) 82 | 83 | try: 84 | server = server_class(config, address, GopherRequestHandler, context=context) 85 | except Exception as e: 86 | GopherExceptions.log(e, None, None) 87 | logger.log("Application startup NOT successful!") 88 | raise 89 | 90 | return server 91 | 92 | 93 | def init_security(config: ConfigParser) -> None: 94 | uid = None 95 | gid = None 96 | 97 | if config.has_option("pygopherd", "setuid"): 98 | import pwd 99 | 100 | uid = pwd.getpwnam(config.get("pygopherd", "setuid"))[2] 101 | 102 | if config.has_option("pygopherd", "setgid"): 103 | import grp 104 | 105 | gid = grp.getgrnam(config.get("pygopherd", "setgid"))[2] 106 | 107 | if config.getboolean("pygopherd", "usechroot"): 108 | chroot_user = config.get("pygopherd", "root") 109 | os.chroot(chroot_user) 110 | logger.log(f"Chrooted to {chroot_user}") 111 | config.set("pygopherd", "root", "/") 112 | 113 | if uid is not None or gid is not None: 114 | os.setgroups(()) 115 | logger.log("Supplemental group list cleared.") 116 | 117 | if gid is not None: 118 | os.setregid(gid, gid) 119 | logger.log(f"Switched to group {gid}") 120 | 121 | if uid is not None: 122 | os.setreuid(uid, uid) 123 | logger.log(f"Switched to uid {uid}") 124 | 125 | 126 | def init_conditional_detach(config: ConfigParser) -> None: 127 | if config.getboolean("pygopherd", "detach"): 128 | pid = os.fork() 129 | if pid: 130 | logger.log("Parent process detaching; child is %d" % pid) 131 | sys.exit(0) 132 | 133 | 134 | def init_pidfile(config: ConfigParser) -> None: 135 | if config.has_option("pygopherd", "pidfile"): 136 | pidfile = config.get("pygopherd", "pidfile") 137 | 138 | with open(pidfile, "w") as fd: 139 | fd.write("%d\n" % os.getpid()) 140 | 141 | 142 | def init_process_group(config: ConfigParser) -> None: 143 | try: 144 | os.setpgrp() 145 | process_group = os.getpgrp() 146 | except OSError as e: 147 | logger.log(f"setpgrp() failed with {e}") 148 | except AttributeError: 149 | logger.log("setpgrp() unavailable; not initializing process group") 150 | else: 151 | logger.log(f"Process group is {process_group}") 152 | 153 | 154 | def init_signal_handlers() -> None: 155 | sighandlers.setsighuphandler() 156 | sighandlers.setsigtermhandler() 157 | 158 | 159 | def init_ssl_context(config: ConfigParser) -> typing.Optional[ssl.SSLContext]: 160 | if config.has_option("pygopherd", "enable_tls"): 161 | if config.getboolean("pygopherd", "enable_tls"): 162 | certfile = config.get("pygopherd", "tls_certfile") 163 | keyfile = config.get("pygopherd", "tls_keyfile") 164 | 165 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 166 | context.load_cert_chain(certfile, keyfile) 167 | return context 168 | 169 | 170 | def initialize(filename: str) -> pygopherd.server.BaseServer: 171 | config = init_config(filename) 172 | 173 | init_logger(config, filename) 174 | init_exceptions(config) 175 | init_mimetypes(config) 176 | context = init_ssl_context(config) 177 | 178 | server = get_server(config, context=context) 179 | init_conditional_detach(config) 180 | init_pidfile(config) 181 | init_process_group(config) 182 | init_signal_handlers() 183 | init_security(config) 184 | 185 | root = config.get("pygopherd", "root") 186 | logger.log(f"Running. Root is '{root}'") 187 | return server 188 | -------------------------------------------------------------------------------- /pygopherd/logger.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import typing 3 | 4 | log: typing.Callable[[str], None] 5 | syslogfunc: typing.Callable[[int, str], None] 6 | priority: int 7 | facility: int 8 | 9 | 10 | def log_file(message: str) -> None: 11 | sys.stdout.buffer.write((message + "\n").encode(errors="surrogateescape")) 12 | sys.stdout.buffer.flush() 13 | 14 | 15 | def log_syslog(message: str) -> None: 16 | # Python's syslog forces UTF-8 and doesn't allow surrogate escapes. 17 | # Even though RFC 5424 clearly states the syslog encoding is optional. 18 | # 19 | # > The character set used in MSG SHOULD be UNICODE, encoded using UTF-8 20 | # > as specified in [RFC3629]. If the syslog application cannot encode 21 | # > the MSG in Unicode, it MAY use any other encoding. 22 | # 23 | # Come on python 24 | message_bytes = message.encode(errors="surrogateescape") 25 | message = message_bytes.decode("utf-8", errors="backslashreplace") 26 | syslogfunc(priority, message) 27 | 28 | 29 | def log_none(message: str): 30 | pass 31 | 32 | 33 | def init(config): 34 | global log, priority, facility, syslogfunc 35 | logmethod = config.get("logger", "logmethod") 36 | if logmethod == "syslog": 37 | import syslog 38 | 39 | priority = eval("syslog." + config.get("logger", "priority")) 40 | facility = eval("syslog." + config.get("logger", "facility")) 41 | syslog.openlog("pygopherd", syslog.LOG_PID, facility) 42 | syslogfunc = syslog.syslog 43 | log = log_syslog 44 | elif logmethod == "file": 45 | log = log_file 46 | else: 47 | log = log_none 48 | -------------------------------------------------------------------------------- /pygopherd/protocols/ProtocolMultiplexer.py: -------------------------------------------------------------------------------- 1 | # Running eval() when loading the configuration requires all of the protocols to 2 | # be in the module namespace 3 | from pygopherd.protocols import * # noqa 4 | from pygopherd.protocols.base import BaseGopherProtocol 5 | 6 | 7 | def getProtocol( 8 | request, server, requesthandler, rfile, wfile, config 9 | ) -> BaseGopherProtocol: 10 | p = eval(config.get("protocols.ProtocolMultiplexer", "protocols")) 11 | 12 | for protocol in p: 13 | ptry = protocol(request, server, requesthandler, rfile, wfile, config) 14 | if ptry.canhandlerequest(): 15 | return ptry 16 | -------------------------------------------------------------------------------- /pygopherd/protocols/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["base", "enhanced", "gemini", "gopherp", "rfc1436", "http", "spartan", "wap"] 2 | -------------------------------------------------------------------------------- /pygopherd/protocols/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import configparser 4 | import io 5 | import ssl 6 | import typing 7 | 8 | from pygopherd import GopherExceptions, gopherentry, logger 9 | from pygopherd.handlers import HandlerMultiplexer 10 | 11 | if typing.TYPE_CHECKING: 12 | from pygopherd.gopherentry import GopherEntry 13 | from pygopherd.handlers.base import BaseHandler 14 | from pygopherd.server import BaseServer, GopherRequestHandler 15 | 16 | 17 | class BaseGopherProtocol: 18 | """Skeleton protocol -- includes commonly-used routines.""" 19 | 20 | secure = False 21 | 22 | entry: GopherEntry 23 | 24 | def __init__( 25 | self, 26 | request: str, 27 | server: BaseServer, 28 | requesthandler: GopherRequestHandler, 29 | rfile: io.BufferedIOBase, 30 | wfile: io.BufferedIOBase, 31 | config: configparser.ConfigParser, 32 | ): 33 | """Parameters are: 34 | request -- the raw request string. 35 | 36 | server -- a SocketServer object. 37 | 38 | rfile -- input file. The first line will already have been read. 39 | 40 | wfile -- output file. Where the output should be sent. 41 | 42 | config -- a ConfigParser object.""" 43 | 44 | self.request = request 45 | requestparts = [arg.strip() for arg in request.split("\t")] 46 | self.rfile = rfile 47 | self.wfile = wfile 48 | self.config = config 49 | self.server = server 50 | self.requesthandler = requesthandler 51 | self.requestlist = requestparts 52 | self.searchrequest = None 53 | self.handler = None 54 | 55 | selector = requestparts[0] 56 | selector = self.slashnormalize(selector) 57 | 58 | self.selector = selector 59 | 60 | def slashnormalize(self, selector: str) -> str: 61 | """Normalize slashes in the selector. Make sure it starts 62 | with a slash and does not end with one. If it is a root directory 63 | request, make sure it is exactly '/'. Returns result.""" 64 | if len(selector) and selector[-1] == "/": 65 | selector = selector[0:-1] 66 | if len(selector) == 0 or selector[0] != "/": 67 | selector = "/" + selector 68 | return selector 69 | 70 | def canhandlerequest(self) -> bool: 71 | """Decides whether or not a given request is valid for this 72 | protocol. Should be overridden by all subclasses.""" 73 | return False 74 | 75 | def log(self, handler: BaseHandler) -> None: 76 | """Log a handled request.""" 77 | logger.log( 78 | "%s [%s/%s]: %s" 79 | % ( 80 | self.requesthandler.client_address[0], 81 | type(self).__name__, 82 | type(handler).__name__, 83 | self.selector, 84 | ) 85 | ) 86 | 87 | def handle(self) -> None: 88 | """Handles the request.""" 89 | try: 90 | handler = self.gethandler() 91 | self.log(handler) 92 | self.entry = handler.getentry() 93 | handler.prepare() 94 | if handler.isdir(): 95 | self.writedir(self.entry, handler.getdirlist()) 96 | else: 97 | handler.write(self.wfile) 98 | except GopherExceptions.FileNotFound as e: 99 | self.filenotfound(str(e)) 100 | except IOError as e: 101 | GopherExceptions.log(e, self, None) 102 | self.filenotfound(e.strerror) 103 | 104 | def filenotfound(self, msg: str): 105 | self.wfile.write( 106 | f"3{msg}\t\terror.host\t1\r\n".encode(errors="surrogateescape") 107 | ) 108 | 109 | def gethandler(self) -> BaseHandler: 110 | """Gets the handler for this object's selector.""" 111 | if not self.handler: 112 | self.handler = HandlerMultiplexer.getHandler( 113 | self.selector, self.searchrequest, self, self.config 114 | ) 115 | return self.handler 116 | 117 | def writedir( 118 | self, entry: GopherEntry, dirlist: typing.Iterable[GopherEntry] 119 | ) -> None: 120 | """Called to render a directory. Generally called by self.handle()""" 121 | 122 | startstr = self.renderdirstart(entry) 123 | if startstr is not None: 124 | self.wfile.write(startstr.encode(errors="surrogateescape")) 125 | 126 | abstractopt = self.config.get("pygopherd", "abstract_entries") 127 | doabstracts = abstractopt == "always" or ( 128 | abstractopt == "unsupported" and not self.groksabstract() 129 | ) 130 | 131 | if self.config.getboolean("pygopherd", "abstract_headers"): 132 | self.wfile.write( 133 | self.renderabstract(entry.getea("ABSTRACT", "")).encode( 134 | errors="surrogateescape" 135 | ) 136 | ) 137 | 138 | for direntry in dirlist: 139 | self.wfile.write( 140 | self.renderobjinfo(direntry).encode(errors="surrogateescape") 141 | ) 142 | if doabstracts: 143 | abstract = self.renderabstract(direntry.getea("ABSTRACT")) 144 | if abstract: 145 | self.wfile.write(abstract.encode(errors="surrogateescape")) 146 | 147 | endstr = self.renderdirend(entry) 148 | if endstr is not None: 149 | self.wfile.write(endstr.encode(errors="surrogateescape")) 150 | 151 | def renderabstract(self, abstractstring: str) -> str: 152 | if not abstractstring: 153 | return "" 154 | retval = "" 155 | for line in abstractstring.splitlines(): 156 | absentry = gopherentry.getinfoentry(line, self.config) 157 | retval += self.renderobjinfo(absentry) 158 | return retval 159 | 160 | def renderdirstart(self, entry: GopherEntry) -> typing.Optional[str]: 161 | """Renders the start of a directory. Most protocols will not need 162 | this. Exception might be HTML. Returns None if not needed. 163 | Argument should be the entry corresponding to the dir itself.""" 164 | return None 165 | 166 | def renderdirend(self, entry: GopherEntry) -> typing.Optional[str]: 167 | """Likewise for the end of a directory.""" 168 | return None 169 | 170 | def renderobjinfo(self, entry: GopherEntry) -> typing.Optional[str]: 171 | """Renders an object's info according to the protocol. Returns 172 | a string. A gopher0 server, for instance, would return a dir line. 173 | MUST BE OVERRIDDEN.""" 174 | return None 175 | 176 | def groksabstract(self) -> bool: 177 | """Returns true if this protocol understands abstracts natively; 178 | false otherwise.""" 179 | return False 180 | 181 | def check_tls(self) -> bool: 182 | """ 183 | Returns true if the connection was established over TLS. 184 | """ 185 | return isinstance(self.requesthandler.request, ssl.SSLSocket) 186 | -------------------------------------------------------------------------------- /pygopherd/protocols/enhanced.py: -------------------------------------------------------------------------------- 1 | from pygopherd.protocols import rfc1436 2 | 3 | 4 | class EnhancedGopherProtocol(rfc1436.GopherProtocol): 5 | def renderobjinfo(self, entry): 6 | return ( 7 | entry.gettype() 8 | + entry.getname() 9 | + "\t" 10 | + entry.getselector() 11 | + "\t" 12 | + entry.gethost(default=self.server.server_name) 13 | + "\t" 14 | + str(entry.getport(default=self.server.server_port)) 15 | + "\t" 16 | + str(entry.getsize()) 17 | + "\t" 18 | + entry.getmimetype() 19 | + "\t" 20 | + entry.getencoding() 21 | + "\t" 22 | + entry.getlanguage() 23 | ) 24 | -------------------------------------------------------------------------------- /pygopherd/protocols/gemini.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | import urllib.error 4 | import urllib.parse 5 | import urllib.request 6 | 7 | from pygopherd import GopherExceptions 8 | from pygopherd.protocols.base import BaseGopherProtocol 9 | 10 | 11 | class GeminiProtocol(BaseGopherProtocol): 12 | secure = True 13 | 14 | query_prefix = "/GEMINI-QUERY" 15 | 16 | def __init__(self, *args: typing.Any, **kwargs: typing.Any): 17 | super().__init__(*args, **kwargs) 18 | 19 | def canhandlerequest(self): 20 | # Even though gemini can accept proxy URLs with different hostnames 21 | # and ports, we're pretending that every request starting with 22 | # gemini:// is meant for this server. 23 | return self.check_tls() and self.request.startswith("gemini://") 24 | 25 | def handle(self): 26 | # Be overly permissive here and ignore most request validation like 27 | # checking for a strict or denying requests over 1024 bytes. 28 | url_parts = urllib.parse.urlparse(self.request.strip()) 29 | 30 | selector = url_parts.path 31 | searchrequest = url_parts.query 32 | 33 | if selector.startswith(self.query_prefix): 34 | self.handle_input(selector, searchrequest) 35 | return 36 | 37 | self.selector = urllib.parse.unquote(selector, errors="surrogateescape") 38 | self.selector = self.slashnormalize(self.selector) 39 | self.searchrequest = urllib.parse.unquote( 40 | searchrequest, errors="surrogateescape" 41 | ) 42 | 43 | try: 44 | handler = self.gethandler() 45 | self.log(handler) 46 | self.entry = handler.getentry() 47 | handler.prepare() 48 | except GopherExceptions.FileNotFound as e: 49 | self.write_status(51, str(e)) 50 | return 51 | except IOError as e: 52 | GopherExceptions.log(e, self, None) 53 | self.write_status(51, e.args[1]) 54 | return 55 | 56 | if handler.isdir(): 57 | self.write_status(20, "text/gemini") 58 | self.writedir(self.entry, handler.getdirlist()) 59 | else: 60 | mimetype = self.adjust_mimetype(self.entry.getmimetype()) 61 | self.write_status(20, mimetype) 62 | self.handler.write(self.wfile) 63 | 64 | def handle_input(self, selector: str, searchrequest: str) -> None: 65 | """ 66 | Gemini is reversed from gopher in that it can't specify a 67 | search link inside a directory listing. So instead, we add 68 | a special prefix to search URLs in order to tell the server 69 | to prompt for input. After input has been submitted, we 70 | redirect back to the original selector. 71 | 72 | The selector and searchrequest arguments should already be 73 | URL-quoted. 74 | """ 75 | if not searchrequest: 76 | self.write_status(10, "Enter input") 77 | else: 78 | selector = selector[len(self.query_prefix) :] 79 | self.write_status(30, f"{selector}?{searchrequest}") 80 | 81 | def write_status(self, code: int, meta: str) -> None: 82 | self.wfile.write(f"{code} {meta}\r\n".encode(errors="backslashreplace")) 83 | 84 | def adjust_mimetype(self, mimetype: typing.Optional[str]) -> str: 85 | if mimetype is None: 86 | return "text/plain" 87 | if mimetype == "application/gopher-menu": 88 | return "text/gemini" 89 | return mimetype 90 | 91 | def renderobjinfo(self, entry): 92 | if re.match("(/|)URL:", entry.getselector()): 93 | # It's a plain URL. Make it that. 94 | url = re.match("(/|)URL:(.+)$", entry.getselector()).group(2) 95 | elif (not entry.gethost()) and (not entry.getport()): 96 | # It's a link to our own server. Make it as such. (relative) 97 | selector = entry.getselector().encode(errors="surrogateescape") 98 | url = urllib.parse.quote(selector) 99 | url = url or "/" # Use "/" for relative links to the root URL 100 | if entry.gettype() == "7": 101 | url = self.query_prefix + url 102 | else: 103 | # Link to a different server. Make it a gopher URL. 104 | url = entry.geturl(self.server.server_name, 70) 105 | 106 | description = entry.getname() or "" 107 | 108 | # text/gemini is expected to be UTF-8, so replace any stray bytes 109 | description_bytes = description.encode(errors="surrogateescape") 110 | description = description_bytes.decode(errors="backslashreplace") 111 | 112 | if entry.gettype() == "i": 113 | return f"{description}\n" 114 | else: 115 | return f"=> {url} {description}\n" 116 | 117 | def renderdirend(self, entry): 118 | if self.config.has_option("protocols.gemini.GeminiProtocol", "footer"): 119 | text = self.config.get("protocols.gemini.GeminiProtocol", "footer") 120 | return f"\n{text}\n" 121 | -------------------------------------------------------------------------------- /pygopherd/protocols/gopherp.py: -------------------------------------------------------------------------------- 1 | import time 2 | import typing 3 | 4 | from pygopherd import GopherExceptions 5 | from pygopherd.protocols.rfc1436 import GopherProtocol 6 | 7 | 8 | class GopherPlusProtocol(GopherProtocol): 9 | """Implementation of Gopher+ protocol. Will handle Gopher+ 10 | queries ONLY.""" 11 | 12 | gopherpstring: str 13 | handlemethod: typing.Optional[str] 14 | 15 | def canhandlerequest(self): 16 | """We can handle the request IF: 17 | * It has more than one parameter in the request list 18 | * The second parameter is ! or starts with + or $""" 19 | if self.secure != self.check_tls(): 20 | return False 21 | 22 | if len(self.requestlist) < 2: 23 | return False 24 | if len(self.requestlist) == 2: 25 | self.gopherpstring = self.requestlist[1] 26 | elif len(self.requestlist) == 3: 27 | self.gopherpstring = self.requestlist[2] 28 | self.searchrequest = self.requestlist[1] 29 | else: 30 | return False # Too many params. 31 | 32 | return ( 33 | self.gopherpstring[0] == "+" 34 | or self.gopherpstring == "!" 35 | or self.gopherpstring[0] == "$" 36 | ) 37 | 38 | def handle(self): 39 | """Handle Gopher+ request.""" 40 | self.handlemethod = None 41 | if self.gopherpstring[0] == "+": 42 | self.handlemethod = "documentonly" 43 | elif self.gopherpstring == "!": 44 | self.handlemethod = "infoonly" 45 | elif self.gopherpstring[0] == "$": 46 | self.handlemethod = "gopherplusdir" 47 | 48 | try: 49 | handler = self.gethandler() 50 | self.log(handler) 51 | self.entry = handler.getentry() 52 | 53 | if self.handlemethod == "infoonly": 54 | self.wfile.write(b"+-2\r\n") 55 | self.wfile.write( 56 | self.renderobjinfo(self.entry).encode(errors="surrogateescape") 57 | ) 58 | else: 59 | handler.prepare() 60 | self.wfile.write(f"+{self.entry.getsize(-2)}\r\n".encode()) 61 | if handler.isdir(): 62 | self.writedir(self.entry, handler.getdirlist()) 63 | else: 64 | handler.write(self.wfile) 65 | except GopherExceptions.FileNotFound as e: 66 | self.filenotfound(str(e)) 67 | except IOError as e: 68 | GopherExceptions.log(e, self, None) 69 | self.filenotfound(e.args[1]) 70 | 71 | def getsupportedblocknames(self, entry): 72 | # Return the always-supported values PLUS any extra ones for 73 | # this particular entry. 74 | return ["+INFO", "+ADMIN", "+VIEWS"] + [ 75 | "+" + x for x in list(entry.geteadict().keys()) 76 | ] 77 | 78 | def getallblocks(self, entry): 79 | retstr = "" 80 | for block in self.getsupportedblocknames(entry): 81 | retstr += self.getblock(block, entry) 82 | return retstr 83 | 84 | def getblock(self, block, entry): 85 | # If the entry has the block in its eadict, return that. 86 | # Otherwise, do our own thing. 87 | # Incoming block: +VIEWS 88 | blockname = block[1:].lower() 89 | 90 | if blockname.upper() in entry.geteadict(): 91 | return ( 92 | "+" 93 | + blockname.upper() 94 | + ":\r\n" 95 | + "".join( 96 | [ 97 | " " + x + "\r\n" 98 | for x in entry.getea(blockname.upper()).splitlines() 99 | ] 100 | ) 101 | ) 102 | 103 | # Not in there -- look up a custom function. 104 | 105 | # Name: views 106 | funcname = "get" + blockname + "block" 107 | # Funcname: getviewsblock 108 | func = getattr(self, funcname) 109 | return func(entry) 110 | 111 | def getinfoblock(self, entry): 112 | return "+INFO: " + GopherProtocol.renderobjinfo(self, entry) 113 | 114 | def getadminblock(self, entry): 115 | retstr = "+ADMIN:\r\n" 116 | retstr += " Admin: " 117 | retstr += self.config.get("protocols.gopherp.GopherPlusProtocol", "admin") 118 | retstr += "\r\n" 119 | if entry.getmtime(): 120 | retstr += " Mod-Date: " 121 | retstr += time.ctime(entry.getmtime()) 122 | m = time.localtime(entry.getmtime()) 123 | retstr += " <%04d%02d%02d%02d%02d%02d>\r\n" % ( 124 | m[0], 125 | m[1], 126 | m[2], 127 | m[3], 128 | m[4], 129 | m[5], 130 | ) 131 | return retstr 132 | 133 | def getviewsblock(self, entry): 134 | retstr = "" 135 | if entry.getmimetype(): 136 | retstr += "+VIEWS:\r\n " + entry.getmimetype() 137 | if entry.getlanguage(): 138 | retstr += " " + entry.getlanguage() 139 | retstr += ":" 140 | if entry.getsize() is not None: 141 | retstr += " <%dk>" % (entry.getsize() // 1024) 142 | retstr += "\r\n" 143 | return retstr 144 | 145 | def renderobjinfo(self, entry): 146 | if ( 147 | entry.getmimetype("FAKE") == "application/gopher-menu" 148 | and entry.getgopherpsupport() 149 | ): 150 | entry.mimetype = "application/gopher+-menu" 151 | if self.handlemethod == "documentonly": 152 | # It's a Gopher+ request for a gopher0 menu entry. 153 | retstr = GopherProtocol.renderobjinfo(self, entry) 154 | return retstr 155 | else: 156 | return self.getallblocks(entry) 157 | 158 | def filenotfound(self, msg: str) -> None: 159 | self.wfile.write(b"--2\r\n") 160 | self.wfile.write(b"1 ") 161 | self.wfile.write( 162 | self.config.get("protocols.gopherp.GopherPlusProtocol", "admin").encode() 163 | ) 164 | self.wfile.write(f"\r\n{msg}\r\n".encode(errors="surrogateescape")) 165 | 166 | def groksabstract(self) -> bool: 167 | return True 168 | 169 | 170 | class SecureGopherPlusProtocol(GopherPlusProtocol): 171 | secure = True 172 | 173 | 174 | class URLGopherPlus(GopherPlusProtocol): 175 | def getsupportedblocknames(self, entry): 176 | return GopherPlusProtocol.getsupportedblocknames(self, entry) + ["+URL"] 177 | 178 | def geturlblock(self, entry): 179 | return "+URL: %s\r\n" % entry.geturl( 180 | self.server.server_name, self.server.server_port 181 | ) 182 | -------------------------------------------------------------------------------- /pygopherd/protocols/rfc1436.py: -------------------------------------------------------------------------------- 1 | from pygopherd.protocols.base import BaseGopherProtocol 2 | 3 | 4 | class GopherProtocol(BaseGopherProtocol): 5 | """Implementation of basic protocol. Will handle every query.""" 6 | 7 | def canhandlerequest(self): 8 | if self.secure != self.check_tls(): 9 | return False 10 | 11 | if len(self.requestlist) > 1: 12 | self.searchrequest = self.requestlist[1] 13 | return True 14 | 15 | def renderobjinfo(self, entry): 16 | retval = ( 17 | entry.gettype("0") 18 | + entry.getname() 19 | + "\t" 20 | + entry.getselector() 21 | + "\t" 22 | + entry.gethost(default=self.server.server_name) 23 | + "\t" 24 | + str(entry.getport(default=self.server.server_port)) 25 | ) 26 | if entry.getgopherpsupport(): 27 | return retval + "\t+\r\n" 28 | else: 29 | return retval + "\r\n" 30 | 31 | 32 | class SecureGopherProtocol(GopherProtocol): 33 | secure = True 34 | -------------------------------------------------------------------------------- /pygopherd/protocols/spartan.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | import urllib.error 4 | import urllib.parse 5 | import urllib.request 6 | 7 | from pygopherd import GopherExceptions 8 | from pygopherd.protocols.base import BaseGopherProtocol 9 | 10 | 11 | class SpartanProtocol(BaseGopherProtocol): 12 | """ 13 | Spartan is a simple, niche TCP protocol developed by yours truly :) 14 | 15 | This protocol class borrows components from both HTTP and Gemini. 16 | 17 | Reference: gemini://spartan.mozz.us/specification.gmi 18 | """ 19 | 20 | def canhandlerequest(self): 21 | if self.check_tls(): 22 | return False 23 | 24 | # The request line must be ASCII encoded 25 | try: 26 | self.request.encode("ascii") 27 | except UnicodeEncodeError: 28 | return False 29 | 30 | # Three non-empty parts, with the third part being an integer >= 0. 31 | parts = self.request.strip().split(" ") 32 | return len(parts) == 3 and all(parts) and parts[2].isdigit() 33 | 34 | def handle(self): 35 | host, path, content_length = self.request.strip().split(" ") 36 | 37 | self.selector = urllib.parse.unquote(path, errors="surrogateescape") 38 | self.selector = self.slashnormalize(self.selector) 39 | 40 | content_length = int(content_length) 41 | if content_length: 42 | data = self.rfile.read(content_length) 43 | self.searchrequest = data.decode(errors="surrogateescape") 44 | 45 | try: 46 | handler = self.gethandler() 47 | self.log(handler) 48 | self.entry = handler.getentry() 49 | handler.prepare() 50 | except GopherExceptions.FileNotFound as e: 51 | self.write_status(4, str(e)) 52 | return 53 | except IOError as e: 54 | GopherExceptions.log(e, self, None) 55 | self.write_status(5, e.args[1]) 56 | return 57 | 58 | if handler.isdir(): 59 | self.write_status(2, "text/gemini") 60 | self.writedir(self.entry, handler.getdirlist()) 61 | else: 62 | mimetype = self.adjust_mimetype(self.entry.getmimetype()) 63 | self.write_status(2, mimetype) 64 | self.handler.write(self.wfile) 65 | 66 | def write_status(self, code: int, meta: str) -> None: 67 | self.wfile.write(f"{code} {meta}\r\n".encode(errors="backslashreplace")) 68 | 69 | def adjust_mimetype(self, mimetype: typing.Optional[str]) -> str: 70 | if mimetype is None: 71 | return "text/plain" 72 | if mimetype == "application/gopher-menu": 73 | return "text/gemini" 74 | return mimetype 75 | 76 | def renderobjinfo(self, entry): 77 | if re.match("(/|)URL:", entry.getselector()): 78 | # It's a plain URL. Make it that. 79 | url = re.match("(/|)URL:(.+)$", entry.getselector()).group(2) 80 | elif (not entry.gethost()) and (not entry.getport()): 81 | # It's a link to our own server. Make it as such. (relative) 82 | selector = entry.getselector().encode(errors="surrogateescape") 83 | url = urllib.parse.quote(selector) 84 | url = url or "/" # Use "/" for relative links to the root URL 85 | else: 86 | # Link to a different server. Make it a gopher URL. 87 | url = entry.geturl(self.server.server_name, 70) 88 | 89 | description = entry.getname() or "" 90 | 91 | # text/gemini is expected to be UTF-8, so replace any stray bytes 92 | description_bytes = description.encode(errors="surrogateescape") 93 | description = description_bytes.decode(errors="backslashreplace") 94 | 95 | if entry.gettype() == "i": 96 | return f"{description}\n" 97 | elif entry.gettype() == "7": 98 | return f"=: {url} {description}\n" 99 | else: 100 | return f"=> {url} {description}\n" 101 | 102 | def renderdirend(self, entry): 103 | if self.config.has_option("protocols.gemini.SpartanProtocol", "footer"): 104 | text = self.config.get("protocols.gemini.SpartanProtocol", "footer") 105 | return f"\n{text}\n" 106 | -------------------------------------------------------------------------------- /pygopherd/protocols/wap.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import io 5 | import re 6 | import typing 7 | 8 | from pygopherd.protocols.http import HTTPProtocol 9 | 10 | if typing.TYPE_CHECKING: 11 | from pygopherd.protocols.base import GopherEntry 12 | 13 | 14 | accesskeys = "1234567890#*" 15 | wmlheader = """ 16 | 18 | 19 | """ 20 | 21 | 22 | class WAPProtocol(HTTPProtocol): 23 | def canhandlerequest(self) -> bool: 24 | ishttp = HTTPProtocol.canhandlerequest(self) 25 | if not ishttp: 26 | return False 27 | 28 | waptop = self.config.get("protocols.wap.WAPProtocol", "waptop") 29 | self.waptop = waptop 30 | if self.requestparts[1].startswith(waptop): 31 | # If it starts with waptop, *guaranteed* to be wap. 32 | self.requestparts[1] = self.requestparts[1][len(waptop) :] 33 | return True 34 | 35 | self.headerslurp() 36 | 37 | # See if we can auto-detect a WAP browser. 38 | if "accept" not in self.httpheaders: 39 | return False 40 | 41 | if not re.search("[, ]text/vnd.wap.wml", self.httpheaders["accept"]): 42 | return False 43 | 44 | # By now, we know that it lists WML in accept. Let's try a few 45 | # more things. 46 | 47 | for tryitem in ["x-wap-profile", "x-up-devcap-max-pdu"]: 48 | if tryitem in self.httpheaders: 49 | return True 50 | 51 | return False 52 | 53 | def adjustmimetype(self, mimetype: typing.Optional[str]) -> str: 54 | self.needsconversion = 0 55 | if mimetype is None or mimetype == "text/plain": 56 | self.needsconversion = 1 57 | return "text/vnd.wap.wml" 58 | if mimetype == "application/gopher-menu": 59 | return "text/vnd.wap.wml" 60 | return mimetype 61 | 62 | def getrenderstr(self, entry: GopherEntry, url: str) -> str: 63 | if url.startswith("/"): 64 | url = self.waptop + url 65 | retstr = "" 66 | if not entry.gettype() in ["i", "7"]: 67 | if self.accesskeyidx < len(accesskeys): 68 | retstr += '%s ' % ( 69 | accesskeys[self.accesskeyidx], 70 | accesskeys[self.accesskeyidx], 71 | url, 72 | ) 73 | self.accesskeyidx += 1 74 | else: 75 | retstr += '' % url 76 | entry_name = entry.getname() 77 | if entry_name is not None: 78 | thisname = html.escape(entry_name) 79 | else: 80 | thisname = html.escape(entry.getselector()) 81 | retstr += thisname 82 | if not entry.gettype() in ["i", "7"]: 83 | retstr += "" 84 | if entry.gettype() == "7": 85 | retstr += "
\n" 86 | retstr += ' \n' % self.postfieldidx 87 | retstr += "Go\n" 88 | # retstr += '\n' 89 | retstr += ' \n' % url # .replace('%', '%25') 90 | retstr += ( 91 | ' \n' 92 | % self.postfieldidx 93 | ) 94 | # retstr += ' \n' 95 | retstr += " \n" 96 | # retstr += '\n' 97 | retstr += "\n" 98 | retstr += "
\n" 99 | self.postfieldidx += 1 100 | return retstr 101 | 102 | def renderdirstart(self, entry: GopherEntry) -> str: 103 | self.accesskeyidx = 0 104 | self.postfieldidx = 0 105 | retval = wmlheader 106 | title = "Gopher" 107 | if self.entry.getname(): 108 | title = html.escape(self.entry.getname()) 109 | retval += '' % html.escape(title) 110 | 111 | retval += "\n

\n" 112 | retval += "%s
\n" % html.escape(title) 113 | return retval 114 | 115 | def renderdirend(self, entry: GopherEntry) -> str: 116 | return "

\n\n\n" 117 | 118 | def handlerwrite(self, wfile: typing.BinaryIO) -> None: 119 | if not self.needsconversion: 120 | self.handler.write(wfile) 121 | return 122 | 123 | fakefile = io.BytesIO() 124 | self.handler.write(fakefile) 125 | fakefile.seek(0) 126 | wfile.write(wmlheader.encode()) 127 | wfile.write(b'\n') 128 | wfile.write(b"

\n") 129 | while 1: 130 | line = fakefile.readline().decode(errors="surrogateescape") 131 | if not len(line): 132 | break 133 | line = line.rstrip() 134 | if len(line): 135 | wfile.write(html.escape(line).encode(errors="surrogateescape") + b"\n") 136 | else: 137 | wfile.write(b"

\n

") 138 | wfile.write(b"

\n
\n\n") 139 | 140 | def filenotfound(self, msg): 141 | wfile = self.wfile 142 | wfile.write(b"HTTP/1.0 200 Not Found\r\n") 143 | wfile.write(b"Content-Type: text/vnd.wap.wml\r\n\r\n") 144 | wfile.write(wmlheader.encode()) 145 | wfile.write(b'\n') 146 | wfile.write(b"

Gopher Error

\n") 147 | wfile.write(html.escape(msg).encode(errors="surrogateescape") + b"\n") 148 | wfile.write(b"

\n
\n\n") 149 | -------------------------------------------------------------------------------- /pygopherd/server.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import errno 3 | import io 4 | import os 5 | import socket 6 | import socketserver 7 | import ssl 8 | import struct 9 | import traceback 10 | import typing 11 | 12 | from pygopherd import GopherExceptions 13 | from pygopherd.protocols import ProtocolMultiplexer 14 | 15 | 16 | class BaseServer(socketserver.BaseServer): 17 | server_name: str 18 | server_port: int 19 | 20 | allow_reuse_address: bool = True 21 | 22 | def __init__( 23 | self, 24 | config: configparser.ConfigParser, 25 | *args: typing.Any, 26 | context: typing.Optional[ssl.SSLContext] = None, 27 | **kwargs: typing.Any 28 | ): 29 | self.config = config 30 | self.context = context 31 | super().__init__(*args, **kwargs) 32 | 33 | def server_bind(self) -> None: 34 | super().server_bind() 35 | 36 | if self.config.has_option("pygopherd", "timeout"): 37 | timeout = struct.pack("ll", int(self.config.get("pygopherd", "timeout")), 0) 38 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, timeout) 39 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDTIMEO, timeout) 40 | 41 | host, port = self.socket.getsockname() 42 | if self.config.has_option("pygopherd", "servername"): 43 | self.server_name = self.config.get("pygopherd", "servername") 44 | else: 45 | self.server_name = socket.getfqdn(host) 46 | 47 | if self.config.has_option("pygopherd", "advertisedport"): 48 | self.server_port = self.config.getint("pygopherd", "advertisedport") 49 | else: 50 | self.server_port = port 51 | 52 | def wrap_socket(self, sock: socket.SocketType) -> socket.SocketType: 53 | """ 54 | Check the first byte of the TCP request for a TLS handshake by looking 55 | for the SYN (\x16) and wrap the connection in a TLS context. 56 | 57 | This will wait for the first byte to be received through the socket, 58 | therefore it should be treated as *blocking* and should only be invoked 59 | from inside of the handler thread/forked process. 60 | """ 61 | if self.context: 62 | if sock.recv(1, socket.MSG_PEEK) == b"\x16": 63 | return self.context.wrap_socket(sock, server_side=True) 64 | return sock 65 | 66 | 67 | class ForkingTCPServer(BaseServer, socketserver.ForkingTCPServer): 68 | def process_request( 69 | self, request: socket.SocketType, client_address: typing.Tuple[str, int] 70 | ) -> None: 71 | """ 72 | Copied directly from the parent class with the addition of the call to 73 | self.wrap_socket() inside of the child process. 74 | """ 75 | pid = os.fork() 76 | if pid: 77 | # Parent process 78 | if self.active_children is None: 79 | self.active_children = set() 80 | self.active_children.add(pid) 81 | self.close_request(request) 82 | return 83 | else: 84 | status = 1 85 | try: 86 | request = self.wrap_socket(request) 87 | self.finish_request(request, client_address) 88 | status = 0 89 | except Exception: 90 | self.handle_error(request, client_address) 91 | finally: 92 | try: 93 | self.shutdown_request(request) 94 | finally: 95 | os._exit(status) 96 | 97 | 98 | class ThreadingTCPServer(BaseServer, socketserver.ThreadingTCPServer): 99 | def process_request_thread( 100 | self, request: socket.SocketType, client_address: typing.Tuple[str, int] 101 | ) -> None: 102 | """ 103 | Copied directly from the parent class with the addition of the call to 104 | self.wrap_socket() inside of the child thread. 105 | """ 106 | try: 107 | request = self.wrap_socket(request) 108 | self.finish_request(request, client_address) 109 | except Exception: 110 | self.handle_error(request, client_address) 111 | finally: 112 | self.shutdown_request(request) 113 | 114 | 115 | class GopherRequestHandler(socketserver.StreamRequestHandler): 116 | 117 | rfile: io.BytesIO 118 | wfile: io.BytesIO 119 | server: BaseServer 120 | 121 | def handle(self) -> None: 122 | request = self.rfile.readline().decode(errors="surrogateescape") 123 | 124 | protohandler = ProtocolMultiplexer.getProtocol( 125 | request, self.server, self, self.rfile, self.wfile, self.server.config 126 | ) 127 | try: 128 | protohandler.handle() 129 | except IOError as e: 130 | if not (e.errno in [errno.ECONNRESET, errno.EPIPE]): 131 | traceback.print_exc() 132 | GopherExceptions.log(e, protohandler, None) 133 | except Exception as e: 134 | if GopherExceptions.tracebacks: 135 | # Yes, this may be invalid. Not much else we can do. 136 | # traceback.print_exc(file = self.wfile) 137 | traceback.print_exc() 138 | GopherExceptions.log(e, protohandler, None) 139 | -------------------------------------------------------------------------------- /pygopherd/sighandlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import sys 4 | 5 | from pygopherd import logger 6 | 7 | pid = None 8 | 9 | 10 | def huphandler(signum, frame): 11 | logger.log("SIGHUP (%d) received; terminating process" % signum) 12 | os._exit(5) # So we don't raise SystemExit 13 | 14 | 15 | def termhandler(signum, frame): 16 | if os.getpid() == pid: # Master killed; kill children. 17 | logger.log("SIGTERM (%d) received in master; doing orderly shutdown" % signum) 18 | logger.log("Terminating all of process group with SIGHUP") 19 | # Ignore this signal so that our own process won't get it again. 20 | signal.signal(signal.SIGHUP, signal.SIG_IGN) 21 | os.kill(0, signal.SIGHUP) 22 | logger.log("Master application process now exiting. Goodbye.") 23 | sys.exit(6) 24 | else: # Shouldn't need this -- just in case. 25 | logger.log("SIGTERM (%d) received in child; terminating this process" % signum) 26 | os._exit(7) # So we don't raise SystemExit 27 | 28 | 29 | def setsighuphandler(): 30 | if "SIGHUP" in signal.__dict__: 31 | signal.signal(signal.SIGHUP, huphandler) 32 | 33 | 34 | def setsigtermhandler(): 35 | global pid 36 | pid = os.getpid() 37 | signal.signal(signal.SIGTERM, termhandler) 38 | -------------------------------------------------------------------------------- /pygopherd/testutil.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import socket 4 | import ssl 5 | import typing 6 | from io import BytesIO, StringIO 7 | 8 | from pygopherd import initialization, logger 9 | from pygopherd.protocols import ProtocolMultiplexer 10 | from pygopherd.protocols.base import BaseGopherProtocol 11 | from pygopherd.server import BaseServer, GopherRequestHandler 12 | 13 | TEST_DATA = os.path.join(os.path.dirname(__file__), "..", "testdata") 14 | 15 | 16 | def get_config() -> configparser.ConfigParser: 17 | config = initialization.init_config("conf/pygopherd.conf") 18 | config.set("pygopherd", "root", TEST_DATA) 19 | return config 20 | 21 | 22 | def get_string_logger() -> StringIO: 23 | config = get_config() 24 | config.set("logger", "logmethod", "file") 25 | logger.init(config) 26 | fp = StringIO() 27 | 28 | def log(message: str) -> None: 29 | fp.write(message + "\n") 30 | 31 | logger.log = log 32 | return fp 33 | 34 | 35 | def get_testing_server( 36 | config: typing.Optional[configparser.ConfigParser] = None, 37 | ) -> BaseServer: 38 | config = config or get_config() 39 | config.set("pygopherd", "port", "64777") 40 | s = initialization.get_server(config) 41 | s.server_close() 42 | return s 43 | 44 | 45 | class MockRequest(socket.SocketType): 46 | def __init__(self, rfile: BytesIO, wfile: BytesIO): 47 | self.rfile = rfile 48 | self.wfile = wfile 49 | 50 | def makefile(self, mode: str, *_) -> BytesIO: 51 | if mode[0] == "r": 52 | return self.rfile 53 | return self.wfile 54 | 55 | 56 | class MockSSLRequest(MockRequest, ssl.SSLSocket): 57 | pass 58 | 59 | 60 | class MockRequestHandler(GopherRequestHandler): 61 | 62 | # Enable buffering (required to make the HandlerClass invoke RequestClass.makefile()) 63 | rbufsize = -1 64 | wbufsize = -1 65 | 66 | def __init__( # noqa 67 | self, 68 | request: MockRequest, 69 | client_address, 70 | server: BaseServer, 71 | ): 72 | self.request = request 73 | self.client_address = client_address 74 | self.server = server 75 | self.setup() 76 | # This does everything in the base class up to handle() 77 | 78 | def handle(self): 79 | # Normally finish() gets called in the __init__, but because we are 80 | # doing this roundabout method of calling handle() from inside of unit 81 | # tests, we want to make sure that the server cleans up after itself. 82 | try: 83 | super().handle() 84 | finally: 85 | self.finish() 86 | 87 | 88 | def get_testing_handler( 89 | rfile: BytesIO, 90 | wfile: BytesIO, 91 | config: typing.Optional[configparser.ConfigParser] = None, 92 | use_tls: bool = False, 93 | ) -> GopherRequestHandler: 94 | """Creates a testing handler with input from rfile. Fills in 95 | other stuff with fake values.""" 96 | 97 | config = config or get_config() 98 | server = get_testing_server(config) 99 | if use_tls: 100 | request = MockSSLRequest(rfile, wfile) 101 | else: 102 | request = MockRequest(rfile, wfile) 103 | address = ("10.77.77.77", "7777") 104 | return MockRequestHandler(request, address, server) 105 | 106 | 107 | def get_testing_protocol( 108 | request: str, 109 | config: typing.Optional[configparser.ConfigParser] = None, 110 | use_tls: bool = False, 111 | ) -> BaseGopherProtocol: 112 | 113 | config = config or get_config() 114 | 115 | rfile = BytesIO(request.encode(errors="surrogateescape")) 116 | # Pass fake rfile, wfile to get_testing_handler -- they'll be closed before 117 | # we can get the info, and some protocols need to read more from them. 118 | 119 | handler = get_testing_handler(BytesIO(), BytesIO(), config, use_tls=use_tls) 120 | # Now override. 121 | handler.rfile = rfile 122 | return ProtocolMultiplexer.getProtocol( 123 | rfile.readline().decode(errors="surrogateescape"), 124 | handler.server, 125 | handler, 126 | handler.rfile, 127 | handler.wfile, 128 | config, 129 | ) 130 | 131 | 132 | def supports_non_utf8_filenames() -> bool: 133 | """ 134 | Test non-utf8 filenames only if the host operating system supports them. 135 | 136 | For example, MacOS HFS+ does not and will raise an OSError. These files 137 | are also a pain to work with in git which is why I'm creating it on the 138 | fly instead of committing it to the git repo. 139 | """ 140 | try: 141 | # \xAE is the ® symbol in the ISO 8859-1 charset 142 | filename = os.path.join(TEST_DATA.encode(), b"\xAE.txt") 143 | with open(filename, "wb") as fp: 144 | fp.write(b"Hello, \xAE!") 145 | except OSError: 146 | return False 147 | else: 148 | return True 149 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import tracemalloc 4 | import unittest 5 | 6 | if __name__ == "__main__": 7 | tracemalloc.start() 8 | 9 | suite = unittest.defaultTestLoader.discover(start_dir="tests/") 10 | runner = unittest.TextTestRunner(verbosity=2) 11 | sys.exit(not runner.run(suite).wasSuccessful()) 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import setuptools 3 | 4 | setuptools.setup( 5 | name="pygopherd", 6 | version="3.0.1", 7 | description="Multiprotocol Internet Gopher Information Server", 8 | author="Michael Lazar", 9 | author_email="lazar.michael22@gmail.com", 10 | python_requires=">=3.7", 11 | url="https://www.github.com/michael-lazar/pygopherd", 12 | packages=["pygopherd", "pygopherd.handlers", "pygopherd.protocols"], 13 | scripts=["bin/pygopherd"], 14 | # Commenting out to prevent overwriting system files when upgrading package 15 | # data_files=[("/etc/pygopherd", ["conf/pygopherd.conf", "conf/mime.types"])], 16 | test_suite="pygopherd", 17 | license="GPLv2", 18 | ) 19 | -------------------------------------------------------------------------------- /simpletal/FixedHTMLParser.py: -------------------------------------------------------------------------------- 1 | """ Fixed up HTMLParser 2 | 3 | Copyright (c) 2009 Colin Stewart (http://www.owlfish.com/) 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions 8 | are met: 9 | 1. Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 3. The name of the author may not be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 18 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 22 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | If you make any bug fixes or feature enhancements please let me know! 29 | 30 | 31 | The classes in this module implement the TAL language, expanding 32 | both XML and HTML templates. 33 | 34 | Module Dependencies: logging, simpleTALES, simpleTALTemplates 35 | """ 36 | 37 | import simpletal 38 | import html.parser 39 | 40 | 41 | class HTMLParser (html.parser.HTMLParser): 42 | def unescape(self, s): 43 | # Just return the data - we don't partially unescaped data! 44 | return s 45 | 46 | -------------------------------------------------------------------------------- /simpletal/LICENSE.txt: -------------------------------------------------------------------------------- 1 | SimpleTAL 5.2 2 | -------------------------------------------------------------------- 3 | Copyright (c) 2015 Colin Stewart (http://www.owlfish.com/) 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions 8 | are met: 9 | 1. Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 3. The name of the author may not be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 18 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 22 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /simpletal/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "5.2" 2 | -------------------------------------------------------------------------------- /testdata/.abstract: -------------------------------------------------------------------------------- 1 | This is the abstract for the testdata directory. -------------------------------------------------------------------------------- /testdata/.cap/zzz.txt: -------------------------------------------------------------------------------- 1 | Name=New Long Cool Name 2 | Numb=1 -------------------------------------------------------------------------------- /testdata/.linkfile: -------------------------------------------------------------------------------- 1 | Name=Cheese Ball Recipes 2 | Numb=2 3 | Type=1 4 | Port=150 5 | Path=1/Moo/Cheesy 6 | Host=zippy.micro.umn.edu -------------------------------------------------------------------------------- /testdata/.queryfile: -------------------------------------------------------------------------------- 1 | Name=Enter a query 2 | Type=7 3 | Path=/ 4 | -------------------------------------------------------------------------------- /testdata/README: -------------------------------------------------------------------------------- 1 | This directory contains data for the unit tests. 2 | 3 | Some tests are dependant upon the precise length of files; those files are 4 | added with -kb. 5 | 6 | -------------------------------------------------------------------------------- /testdata/bucktooth/README: -------------------------------------------------------------------------------- 1 | This directory contains data for the unit tests. 2 | 3 | Some tests are dependant upon the precise length of files; those files are 4 | added with -kb. 5 | 6 | -------------------------------------------------------------------------------- /testdata/bucktooth/gophermap: -------------------------------------------------------------------------------- 1 | hello world 2 | 1filename 3 | 1filename README 4 | 1filename selector hostname 5 | 1filename selector hostname 69 6 | -------------------------------------------------------------------------------- /testdata/demo.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICpDCCAYwCCQDzjfBwAuUNqDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls 3 | b2NhbGhvc3QwHhcNMjEwMTIzMDM1NzM0WhcNMjEwMjIyMDM1NzM0WjAUMRIwEAYD 4 | VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA 5 | 8TO7cBA3wYu14mrS8gjAUxdzxPXxzixVuPSNLMZsTKa0Z/Gr6LmV6T6+Ez80VQw4 6 | MfnkPSxj6apudFic4tSIqlNJ7pEk0bq2oKD8YgRui9hV/g2l3ty4uG9KW2lgO9F6 7 | drUevOEzu1NSPIcMngBW6kz+p5elz+NUVJivZiy8ob0WB+ZudxYBCyBW78EktYZQ 8 | tg/KrWXvx0wT5ra9ByLIlo4X7c3mAgnH0UNx+GIcFFM8KEJ/ntlPNcW2W4B/XcAO 9 | eljvhrDCgUO+VMSJAv3gfSMpXdffLS4zpetIW4RxFZuNjX4Z88c6gtUiFFIsW1J+ 10 | hZoa/fHvwXvQ6ENzU7n5AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAL2ZWGjQoz2M 11 | SxwG0bA7TcTs20j2UiHDSAdq7zVWiLZCO3tEL7fCxgKCBP9+rz+LtaPFm/SEEyhR 12 | HcdPBDpkkvw3mv1Wj7BGsNRlURJ0Q5kMsEXUMPNROFE0x8NPPYk4qVLs2HS8DUH9 13 | N9RKMcbnm7ISOKDfRSlsz0YdR2Vx5UQotSX76sOSh+YxYKNqfb0IduxQHOmFZ0QO 14 | ClRXr3eaISJQGcRfOTHduhtCpF+VBflLCX0hzRtUXGGOq9A0K1nYpTqaiwjF2J6+ 15 | 793hUyf+UIOQxjXj3vnLzliB118doj4K2HVh/Upyn1BHt5LtWPfyNZzMcm+p/jJM 16 | /9QVSsdevUc= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /testdata/demo.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDA8TO7cBA3wYu1 3 | 4mrS8gjAUxdzxPXxzixVuPSNLMZsTKa0Z/Gr6LmV6T6+Ez80VQw4MfnkPSxj6apu 4 | dFic4tSIqlNJ7pEk0bq2oKD8YgRui9hV/g2l3ty4uG9KW2lgO9F6drUevOEzu1NS 5 | PIcMngBW6kz+p5elz+NUVJivZiy8ob0WB+ZudxYBCyBW78EktYZQtg/KrWXvx0wT 6 | 5ra9ByLIlo4X7c3mAgnH0UNx+GIcFFM8KEJ/ntlPNcW2W4B/XcAOeljvhrDCgUO+ 7 | VMSJAv3gfSMpXdffLS4zpetIW4RxFZuNjX4Z88c6gtUiFFIsW1J+hZoa/fHvwXvQ 8 | 6ENzU7n5AgMBAAECggEAWSUYidnVJG3AZ2EdiilZ8cJya4LtP7PKuDCkjTXK1+7y 9 | dxgviQYV+TWzT48E/ODurGgq1VGOWPt1S2NmLdZ/7EUGBnq7hQ+B/S9qMjH3ajwi 10 | 0Fh5ZdH6mT5d7TUfEt+QgcynEnSieIxsiup8W1AFSCMpP9+fKXVLy4LqqN/Ee8JP 11 | zCBywKVQjw6MVgUqzDTOQ8NiEIdkOrSLAYYC2molwS3jhPgEzKZeKBlDfv1vVXKr 12 | VyyL5N7fzCLRTw5z9ZgfWDzqbxDZV6hS8aSde4bV+6rpBoG9zPKWpM1AwkYCHjqe 13 | Gjenyz43tnQJ+pNJd1XqL+Yild0wmWXzySWKO93mIQKBgQD86gvYhr1NAgBhMQIi 14 | OaFO/DqdI7l6Tpa4EgMw15VnBu1pnlYUJFlhMw3FMb7zqfyhgt1kZ6WETm8F6uh4 15 | RH7hN/m2ewjfSMf8rB6lQVI8HUepdhY3faMLK0mehV/gQCruKMjCVCpT7xRnsV1X 16 | v2JCIy8Hk4GZ1vI3D6NOSx+s8wKBgQDDS9a6owHxIn2QGlmPWbvdCCzxB5dsL9OH 17 | /BBCuw/uYySsMe/W730lLbEzgvj9MxtoUuS2T+KRLE+7hJHH393QbbMAGK63F+ir 18 | BQa3mAE68aq3Kz3ZvzYacrfUx8umlxLiPl7biWfChhtvadbM4odJzDgMPTlUl4DE 19 | hp5GgrLIYwKBgErkx9M7uyzlrdUaHSajaDgqivTjklY8lXc2pkk9XdmffIhtQdI/ 20 | HVSiOK6vV3tyWAQ/622DH8l5LHlVIbgTmHr7B3BZKLxuIgKZuY14NXDlvsXY2SVf 21 | h/uTuv49QrH2boAOBb0+DTbDsoguRpTocKFjJ9cXgCZdN2bEs7hImL2vAoGAFpEr 22 | 5fMyJUAcDEvPL45p8/ee4ddDux+nrN4Grv9Yru5L7Y3zrf2Mk4A9Krums/N05lA1 23 | 149Rmf7p07xU8CjBQ/V5Krivb77WhvSUuyBYfAwy8umPQxsiUFoTPgY8VSq95uDY 24 | KzwsfkDq6KvtQ02l3nQ3wcpNVqYPHiaEIZe2uwsCgYEAqLJ/G9qMPyfRYgBhFIew 25 | jDKNTVNMCw4fhN+zN7V6CmxCv2UVBjUDyJslddzEOJPQpS7811Ojc5K4dXreLJ7G 26 | qql3LV2iJv3ViuWJO1YVSYaKs7WjBvpP1n2IrBvFBg6257GxrgSDwU7GtZkP7rev 27 | 9D+aCUpiTqG68RFd9fRIdC0= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /testdata/gopherplus/README: -------------------------------------------------------------------------------- 1 | Hello world! -------------------------------------------------------------------------------- /testdata/gopherplus/README.3d: -------------------------------------------------------------------------------- 1 | this is a gopher+ info attribute -------------------------------------------------------------------------------- /testdata/gopherplus/testfile.txt: -------------------------------------------------------------------------------- 1 | I am a simple test file -------------------------------------------------------------------------------- /testdata/pygopherd/cgitest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Simple script to test CGI environment variables 3 | 4 | echo "$1 from $REQUEST" -------------------------------------------------------------------------------- /testdata/pygopherd/pipetest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Simple script to echo back what came in. 3 | 4 | echo "Starting" 5 | read DATALINE 6 | while test -n "$DATALINE" ; do 7 | echo "Got [$DATALINE]" 8 | read DATALINE 9 | done 10 | echo "Ending" 11 | -------------------------------------------------------------------------------- /testdata/pygopherd/pipetestdata: -------------------------------------------------------------------------------- 1 | Word1 2 | Word2 3 | Word3 4 | -------------------------------------------------------------------------------- /testdata/pygopherd/searchtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Request: $REQUEST" 4 | echo "Search: $SEARCHREQUEST" 5 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000000.mbox:2,: -------------------------------------------------------------------------------- 1 | From: David Ascher 2 | To: python-dev@python.org 3 | Subject: [Python-Dev] Pickling w/ low overhead 4 | Date: Mon, 02 Aug 1999 16:01:26 -0700 5 | Message-ID: 6 | MIME-Version: 1.0 7 | Content-Type: multipart/mixed; boundary="===============7807071734274799233==" 8 | 9 | --===============7807071734274799233== 10 | Content-Type: text/plain; charset="utf-8" 11 | Content-Transfer-Encoding: 7bit 12 | 13 | An issue which has dogged the NumPy project is that there is (to my 14 | knowledge) no way to pickle very large arrays without creating strings 15 | which contain all of the data. This can be a problem given that NumPy 16 | arrays tend to be very large -- often several megabytes, sometimes much 17 | bigger. This slows things down, sometimes a lot, depending on the 18 | platform. It seems that it should be possible to do something more 19 | efficient. 20 | 21 | Two alternatives come to mind: 22 | 23 | -- define a new pickling protocol which passes a file-like object to the 24 | instance and have the instance write itself to that file, being as 25 | efficient or inefficient as it cares to. This protocol is used only 26 | if the instance/type defines the appropriate slot. Alternatively, 27 | enrich the semantics of the getstate interaction, so that an object 28 | can return partial data and tell the pickling mechanism to come back 29 | for more. 30 | 31 | -- make pickling of objects which support the buffer interface use that 32 | inteface's notion of segments and use that 'chunk' size to do 33 | something more efficient if not necessarily most efficient. (oh, and 34 | make NumPy arrays support the buffer interface =). This is simple 35 | for NumPy arrays since we want to pickle "everything", but may not be 36 | what other buffer-supporting objects want. 37 | 38 | Thoughts? Alternatives? 39 | 40 | --david 41 | 42 | 43 | 44 | --===============7807071734274799233==-- 45 | 46 | 47 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000001.mbox:2,: -------------------------------------------------------------------------------- 1 | From: Mark Hammond 2 | To: python-dev@python.org 3 | Subject: [Python-Dev] Buffer interface in abstract.c? 4 | Date: Tue, 03 Aug 1999 10:41:23 +1000 5 | Message-ID: <001001bedd48$ea796280$1101a8c0@bobcat> 6 | MIME-Version: 1.0 7 | Content-Type: multipart/mixed; boundary="===============6301840427696015601==" 8 | 9 | --===============6301840427696015601== 10 | Content-Type: text/plain; charset="utf-8" 11 | Content-Transfer-Encoding: 7bit 12 | 13 | Hi all, 14 | Im trying to slowly wean myself over to the buffer interfaces. 15 | 16 | My exploration so far indicates that, for most cases, simply replacing 17 | "PyString_FromStringAndSize" with "PyBuffer_FromMemory" handles the vast 18 | majority of cases, and is preferred when the data contains arbitary bytes. 19 | PyArg_ParseTuple("s#", ...) still works correctly as we would hope. 20 | 21 | However, performing this explicitly is a pain. Looking at getargs.c, the 22 | code to achieve this is a little too convoluted to cut-and-paste each time. 23 | 24 | Therefore, I would like to propose these functions to be added to 25 | abstract.c: 26 | 27 | int PyObject_GetBufferSize(); 28 | void *PyObject_GetReadWriteBuffer(); /* or "char *"? */ 29 | const void *PyObject_GetReadOnlyBuffer(); 30 | 31 | Although equivalent functions exist for the buffer object, I can't see the 32 | equivalent abstract implementations - ie, that work with any object 33 | supporting the protocol. 34 | 35 | Im willing to provide a patch if there is agreement a) the general idea is 36 | good, and b) my specific spelling of the idea is OK (less likely - 37 | PyBuffer_* seems better, but loses any implication of being abstract?). 38 | 39 | Thoughts? 40 | 41 | Mark. 42 | 43 | 44 | 45 | --===============6301840427696015601==-- 46 | 47 | 48 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000002.mbox:2,: -------------------------------------------------------------------------------- 1 | From: Greg Stein 2 | To: python-dev@python.org 3 | Subject: Re: [Python-Dev] Buffer interface in abstract.c? 4 | Date: Mon, 02 Aug 1999 18:51:43 -0700 5 | Message-ID: <37A64B2F.3386F0A9@lyra.org> 6 | In-Reply-To: <001001bedd48$ea796280$1101a8c0@bobcat> 7 | MIME-Version: 1.0 8 | Content-Type: multipart/mixed; boundary="===============3766712876154848986==" 9 | 10 | --===============3766712876154848986== 11 | Content-Type: text/plain; charset="utf-8" 12 | Content-Transfer-Encoding: 7bit 13 | 14 | Mark Hammond wrote: 15 | > ... 16 | > Therefore, I would like to propose these functions to be added to 17 | > abstract.c: 18 | > 19 | > int PyObject_GetBufferSize(); 20 | > void *PyObject_GetReadWriteBuffer(); /* or "char *"? */ 21 | > const void *PyObject_GetReadOnlyBuffer(); 22 | > 23 | > Although equivalent functions exist for the buffer object, I can't see the 24 | > equivalent abstract implementations - ie, that work with any object 25 | > supporting the protocol. 26 | > 27 | > Im willing to provide a patch if there is agreement a) the general idea is 28 | > good, and b) my specific spelling of the idea is OK (less likely - 29 | > PyBuffer_* seems better, but loses any implication of being abstract?). 30 | 31 | Marc-Andre proposed exactly the same thing back at the end of March (to 32 | me and Guido). The two of us hashed out some of the stuff and M.A. came 33 | up with a full patch for the stuff. Guido was relatively non-committal 34 | at the point one way or another, but said they seemed fine. It appears 35 | the stuff never made it into source control. 36 | 37 | If Marc-Andre can resurface the final proposal/patch, then we'd be set. 38 | 39 | Until then: use the bufferprocs :-) 40 | 41 | Cheers, 42 | -g 43 | 44 | -- 45 | Greg Stein, http://www.lyra.org/ 46 | 47 | 48 | --===============3766712876154848986==-- 49 | 50 | 51 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000003.mbox:2,: -------------------------------------------------------------------------------- 1 | From: "M.-A. Lemburg" 2 | To: python-dev@python.org 3 | Subject: Re: [Python-Dev] Buffer interface in abstract.c? 4 | Date: Tue, 03 Aug 1999 09:50:33 +0200 5 | Message-ID: <37A69F49.3575AE85@lemburg.com> 6 | In-Reply-To: <37A64B2F.3386F0A9@lyra.org> 7 | MIME-Version: 1.0 8 | Content-Type: multipart/mixed; boundary="===============3450783038406365800==" 9 | 10 | --===============3450783038406365800== 11 | Content-Type: text/plain; charset="utf-8" 12 | Content-Transfer-Encoding: 7bit 13 | 14 | Greg Stein wrote: 15 | > 16 | > Mark Hammond wrote: 17 | > > ... 18 | > > Therefore, I would like to propose these functions to be added to 19 | > > abstract.c: 20 | > > 21 | > > int PyObject_GetBufferSize(); 22 | > > void *PyObject_GetReadWriteBuffer(); /* or "char *"? */ 23 | > > const void *PyObject_GetReadOnlyBuffer(); 24 | > > 25 | > > Although equivalent functions exist for the buffer object, I can't see the 26 | > > equivalent abstract implementations - ie, that work with any object 27 | > > supporting the protocol. 28 | > > 29 | > > Im willing to provide a patch if there is agreement a) the general idea is 30 | > > good, and b) my specific spelling of the idea is OK (less likely - 31 | > > PyBuffer_* seems better, but loses any implication of being abstract?). 32 | > 33 | > Marc-Andre proposed exactly the same thing back at the end of March (to 34 | > me and Guido). The two of us hashed out some of the stuff and M.A. came 35 | > up with a full patch for the stuff. Guido was relatively non-committal 36 | > at the point one way or another, but said they seemed fine. It appears 37 | > the stuff never made it into source control. 38 | > 39 | > If Marc-Andre can resurface the final proposal/patch, then we'd be set. 40 | 41 | Below is the code I currently use. I don't really remember if this 42 | is what Greg and I discussed a while back, but I'm sure he'll 43 | correct me ;-) Note that you the buffer length is implicitly 44 | returned by these APIs. 45 | 46 | /* Takes an arbitrary object which must support the character (single 47 | segment) buffer interface and returns a pointer to a read-only 48 | memory location useable as character based input for subsequent 49 | processing. 50 | 51 | buffer and buffer_len are only set in case no error 52 | occurrs. Otherwise, -1 is returned and an exception set. 53 | 54 | */ 55 | 56 | static 57 | int PyObject_AsCharBuffer(PyObject *obj, 58 | const char **buffer, 59 | int *buffer_len) 60 | { 61 | PyBufferProcs *pb = obj->ob_type->tp_as_buffer; 62 | const char *pp; 63 | int len; 64 | 65 | if ( pb == NULL || 66 | pb->bf_getcharbuffer == NULL || 67 | pb->bf_getsegcount == NULL ) { 68 | PyErr_SetString(PyExc_TypeError, 69 | "expected a character buffer object"); 70 | goto onError; 71 | } 72 | if ( (*pb->bf_getsegcount)(obj,NULL) != 1 ) { 73 | PyErr_SetString(PyExc_TypeError, 74 | "expected a single-segment buffer object"); 75 | goto onError; 76 | } 77 | len = (*pb->bf_getcharbuffer)(obj,0,&pp); 78 | if (len < 0) 79 | goto onError; 80 | *buffer = pp; 81 | *buffer_len = len; 82 | return 0; 83 | 84 | onError: 85 | return -1; 86 | } 87 | 88 | /* Same as PyObject_AsCharBuffer() except that this API expects a 89 | readable (single segment) buffer interface and returns a pointer 90 | to a read-only memory location which can contain arbitrary data. 91 | 92 | buffer and buffer_len are only set in case no error 93 | occurrs. Otherwise, -1 is returned and an exception set. 94 | 95 | */ 96 | 97 | static 98 | int PyObject_AsReadBuffer(PyObject *obj, 99 | const void **buffer, 100 | int *buffer_len) 101 | { 102 | PyBufferProcs *pb = obj->ob_type->tp_as_buffer; 103 | void *pp; 104 | int len; 105 | 106 | if ( pb == NULL || 107 | pb->bf_getreadbuffer == NULL || 108 | pb->bf_getsegcount == NULL ) { 109 | PyErr_SetString(PyExc_TypeError, 110 | "expected a readable buffer object"); 111 | goto onError; 112 | } 113 | if ( (*pb->bf_getsegcount)(obj,NULL) != 1 ) { 114 | PyErr_SetString(PyExc_TypeError, 115 | "expected a single-segment buffer object"); 116 | goto onError; 117 | } 118 | len = (*pb->bf_getreadbuffer)(obj,0,&pp); 119 | if (len < 0) 120 | goto onError; 121 | *buffer = pp; 122 | *buffer_len = len; 123 | return 0; 124 | 125 | onError: 126 | return -1; 127 | } 128 | 129 | /* Takes an arbitrary object which must support the writeable (single 130 | segment) buffer interface and returns a pointer to a writeable 131 | memory location in buffer of size buffer_len. 132 | 133 | buffer and buffer_len are only set in case no error 134 | occurrs. Otherwise, -1 is returned and an exception set. 135 | 136 | */ 137 | 138 | static 139 | int PyObject_AsWriteBuffer(PyObject *obj, 140 | void **buffer, 141 | int *buffer_len) 142 | { 143 | PyBufferProcs *pb = obj->ob_type->tp_as_buffer; 144 | void*pp; 145 | int len; 146 | 147 | if ( pb == NULL || 148 | pb->bf_getwritebuffer == NULL || 149 | pb->bf_getsegcount == NULL ) { 150 | PyErr_SetString(PyExc_TypeError, 151 | "expected a writeable buffer object"); 152 | goto onError; 153 | } 154 | if ( (*pb->bf_getsegcount)(obj,NULL) != 1 ) { 155 | PyErr_SetString(PyExc_TypeError, 156 | "expected a single-segment buffer object"); 157 | goto onError; 158 | } 159 | len = (*pb->bf_getwritebuffer)(obj,0,&pp); 160 | if (len < 0) 161 | goto onError; 162 | *buffer = pp; 163 | *buffer_len = len; 164 | return 0; 165 | 166 | onError: 167 | return -1; 168 | } 169 | 170 | 171 | -- 172 | Marc-Andre Lemburg 173 | ______________________________________________________________________ 174 | Y2000: 150 days left 175 | Business: http://www.lemburg.com/ 176 | Python Pages: http://www.lemburg.com/python/ 177 | 178 | 179 | 180 | 181 | --===============3450783038406365800==-- 182 | 183 | 184 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000004.mbox:2,: -------------------------------------------------------------------------------- 1 | From: "M.-A. Lemburg" 2 | To: python-dev@python.org 3 | Subject: Re: [Python-Dev] Pickling w/ low overhead 4 | Date: Tue, 03 Aug 1999 11:11:11 +0200 5 | Message-ID: <37A6B22F.7A14BA2C@lemburg.com> 6 | In-Reply-To: 7 | MIME-Version: 1.0 8 | Content-Type: multipart/mixed; boundary="===============1771830407250585468==" 9 | 10 | --===============1771830407250585468== 11 | Content-Type: text/plain; charset="utf-8" 12 | Content-Transfer-Encoding: 7bit 13 | 14 | David Ascher wrote: 15 | > 16 | > An issue which has dogged the NumPy project is that there is (to my 17 | > knowledge) no way to pickle very large arrays without creating strings 18 | > which contain all of the data. This can be a problem given that NumPy 19 | > arrays tend to be very large -- often several megabytes, sometimes much 20 | > bigger. This slows things down, sometimes a lot, depending on the 21 | > platform. It seems that it should be possible to do something more 22 | > efficient. 23 | > 24 | > Two alternatives come to mind: 25 | > 26 | > -- define a new pickling protocol which passes a file-like object to the 27 | > instance and have the instance write itself to that file, being as 28 | > efficient or inefficient as it cares to. This protocol is used only 29 | > if the instance/type defines the appropriate slot. Alternatively, 30 | > enrich the semantics of the getstate interaction, so that an object 31 | > can return partial data and tell the pickling mechanism to come back 32 | > for more. 33 | > 34 | > -- make pickling of objects which support the buffer interface use that 35 | > inteface's notion of segments and use that 'chunk' size to do 36 | > something more efficient if not necessarily most efficient. (oh, and 37 | > make NumPy arrays support the buffer interface =). This is simple 38 | > for NumPy arrays since we want to pickle "everything", but may not be 39 | > what other buffer-supporting objects want. 40 | > 41 | > Thoughts? Alternatives? 42 | 43 | Hmm, types can register their own pickling/unpickling functions 44 | via copy_reg, so they can access the self.write method in pickle.py 45 | to implement the write to file interface. Don't know how this 46 | would be done for cPickle.c though. 47 | 48 | For instances the situation is different since there is no 49 | dispatching done on a per-class basis. I guess an optional argument 50 | could help here. 51 | 52 | Perhaps some lazy pickling wrapper would help fix this in general: 53 | an object which calls back into the to-be-pickled object to 54 | access the data rather than store the data in a huge string. 55 | 56 | Yet another idea would be using memory mapped files instead 57 | of strings as temporary storage (but this is probably hard to implement 58 | right and not as portable). 59 | 60 | Dunno... just some thoughts. 61 | 62 | -- 63 | Marc-Andre Lemburg 64 | ______________________________________________________________________ 65 | Y2000: 150 days left 66 | Business: http://www.lemburg.com/ 67 | Python Pages: http://www.lemburg.com/python/ 68 | 69 | 70 | 71 | --===============1771830407250585468==-- 72 | 73 | 74 | -------------------------------------------------------------------------------- /testdata/python-dev/cur/1606884253.000005.mbox:2,: -------------------------------------------------------------------------------- 1 | From: Jack Jansen 2 | To: python-dev@python.org 3 | Subject: Re: [Python-Dev] Buffer interface in abstract.c? 4 | Date: Tue, 03 Aug 1999 11:53:39 +0200 5 | Message-ID: <19990803095339.E02CE303120@snelboot.oratrix.nl> 6 | In-Reply-To: 7 | MIME-Version: 1.0 8 | Content-Type: multipart/mixed; boundary="===============6337740323128302028==" 9 | 10 | --===============6337740323128302028== 11 | Content-Type: text/plain; charset="utf-8" 12 | Content-Transfer-Encoding: 7bit 13 | 14 | Why not pass the index to the As*Buffer routines as well and make getsegcount 15 | available too? Then you could code things like 16 | for(i=0; i 2 | To: python-dev@python.org 3 | Subject: [Python-Dev] Pickling w/ low overhead 4 | Date: Mon, 02 Aug 1999 16:01:26 -0700 5 | Message-ID: 6 | MIME-Version: 1.0 7 | Content-Type: multipart/mixed; boundary="===============7807071734274799233==" 8 | 9 | --===============7807071734274799233== 10 | Content-Type: text/plain; charset="utf-8" 11 | Content-Transfer-Encoding: 7bit 12 | 13 | An issue which has dogged the NumPy project is that there is (to my 14 | knowledge) no way to pickle very large arrays without creating strings 15 | which contain all of the data. This can be a problem given that NumPy 16 | arrays tend to be very large -- often several megabytes, sometimes much 17 | bigger. This slows things down, sometimes a lot, depending on the 18 | platform. It seems that it should be possible to do something more 19 | efficient. 20 | 21 | Two alternatives come to mind: 22 | 23 | -- define a new pickling protocol which passes a file-like object to the 24 | instance and have the instance write itself to that file, being as 25 | efficient or inefficient as it cares to. This protocol is used only 26 | if the instance/type defines the appropriate slot. Alternatively, 27 | enrich the semantics of the getstate interaction, so that an object 28 | can return partial data and tell the pickling mechanism to come back 29 | for more. 30 | 31 | -- make pickling of objects which support the buffer interface use that 32 | inteface's notion of segments and use that 'chunk' size to do 33 | something more efficient if not necessarily most efficient. (oh, and 34 | make NumPy arrays support the buffer interface =). This is simple 35 | for NumPy arrays since we want to pickle "everything", but may not be 36 | what other buffer-supporting objects want. 37 | 38 | Thoughts? Alternatives? 39 | 40 | --david 41 | 42 | 43 | 44 | --===============7807071734274799233==-- 45 | 46 | 47 | -------------------------------------------------------------------------------- /testdata/python-dev/tmp/1606884253.000000.mbox:2,: -------------------------------------------------------------------------------- 1 | From: David Ascher 2 | To: python-dev@python.org 3 | Subject: [Python-Dev] Pickling w/ low overhead 4 | Date: Mon, 02 Aug 1999 16:01:26 -0700 5 | Message-ID: 6 | MIME-Version: 1.0 7 | Content-Type: multipart/mixed; boundary="===============7807071734274799233==" 8 | 9 | --===============7807071734274799233== 10 | Content-Type: text/plain; charset="utf-8" 11 | Content-Transfer-Encoding: 7bit 12 | 13 | An issue which has dogged the NumPy project is that there is (to my 14 | knowledge) no way to pickle very large arrays without creating strings 15 | which contain all of the data. This can be a problem given that NumPy 16 | arrays tend to be very large -- often several megabytes, sometimes much 17 | bigger. This slows things down, sometimes a lot, depending on the 18 | platform. It seems that it should be possible to do something more 19 | efficient. 20 | 21 | Two alternatives come to mind: 22 | 23 | -- define a new pickling protocol which passes a file-like object to the 24 | instance and have the instance write itself to that file, being as 25 | efficient or inefficient as it cares to. This protocol is used only 26 | if the instance/type defines the appropriate slot. Alternatively, 27 | enrich the semantics of the getstate interaction, so that an object 28 | can return partial data and tell the pickling mechanism to come back 29 | for more. 30 | 31 | -- make pickling of objects which support the buffer interface use that 32 | inteface's notion of segments and use that 'chunk' size to do 33 | something more efficient if not necessarily most efficient. (oh, and 34 | make NumPy arrays support the buffer interface =). This is simple 35 | for NumPy arrays since we want to pickle "everything", but may not be 36 | what other buffer-supporting objects want. 37 | 38 | Thoughts? Alternatives? 39 | 40 | --david 41 | 42 | 43 | 44 | --===============7807071734274799233==-- 45 | 46 | 47 | -------------------------------------------------------------------------------- /testdata/symlinktest.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/symlinktest.zip -------------------------------------------------------------------------------- /testdata/talsample.html.tal: -------------------------------------------------------------------------------- 1 | 2 | 3 | TAL Test 4 | 5 | 6 | My selector is: selector
7 | My MIME type is: foo/bar
8 | Another way of getting that is: foo/bar
9 | Gopher type is: X
10 | My handler is: handlername
11 | My protocol is: protocol
12 | Python path enabling status: 123
13 | My vfs is: vfs
14 | Math: 5 15 | 16 | 17 | -------------------------------------------------------------------------------- /testdata/testarchive.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/testarchive.tar.gz -------------------------------------------------------------------------------- /testdata/testarchive.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/testarchive.tgz -------------------------------------------------------------------------------- /testdata/testdata.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/testdata.zip -------------------------------------------------------------------------------- /testdata/testdata2.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/testdata2.zip -------------------------------------------------------------------------------- /testdata/testfile.gmi: -------------------------------------------------------------------------------- 1 | Hello world! 2 | 3 | => / root directory 4 | => gemini://mozz.us 5 | -------------------------------------------------------------------------------- /testdata/testfile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | <Gopher Rocks> 4 | 5 | 6 |

Hello World!

7 | 8 | ` -------------------------------------------------------------------------------- /testdata/testfile.pyg: -------------------------------------------------------------------------------- 1 | from pygopherd.handlers.pyg import PYGBase 2 | from pygopherd.gopherentry import GopherEntry 3 | 4 | 5 | class PYGMain(PYGBase): 6 | def canhandlerequest(self): 7 | return True 8 | 9 | def isdir(self): 10 | return False 11 | 12 | def getentry(self): 13 | entry = GopherEntry(self.selector, self.config) 14 | entry.type = "0" 15 | entry.mimetype = "text/plain" 16 | entry.name = "My custom .pyg handler!" 17 | return entry 18 | 19 | def write(self, wfile): 20 | wfile.write("hello world!".encode()) 21 | -------------------------------------------------------------------------------- /testdata/testfile.txt: -------------------------------------------------------------------------------- 1 | Test 2 | -------------------------------------------------------------------------------- /testdata/testfile.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/testfile.txt.gz -------------------------------------------------------------------------------- /testdata/testfile.txt.gz.abstract: -------------------------------------------------------------------------------- 1 | This is the abstract 2 | for testfile.txt.gz 3 | -------------------------------------------------------------------------------- /testdata/ziptorture.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/ziptorture.zip -------------------------------------------------------------------------------- /testdata/zzz.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/testdata/zzz.txt -------------------------------------------------------------------------------- /tests/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michael-lazar/pygopherd/edb0787d0d9572bb8da84c1481195ac98d3f297b/tests/handlers/__init__.py -------------------------------------------------------------------------------- /tests/handlers/test_dir.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.dir import DirHandler 7 | 8 | 9 | class TestDirHandler(unittest.TestCase): 10 | def setUp(self): 11 | self.config = testutil.get_config() 12 | self.vfs = VFS_Real(self.config) 13 | self.selector = "/" 14 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 15 | self.stat_result = self.vfs.stat(self.selector) 16 | 17 | # Make sure there's no directory cache file from a previous test run 18 | cachefile = self.config.get("handlers.dir.DirHandler", "cachefile") 19 | try: 20 | os.remove(self.vfs.getfspath(self.selector) + "/" + cachefile) 21 | except OSError: 22 | pass 23 | 24 | def test_dir_handler(self): 25 | handler = DirHandler( 26 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 27 | ) 28 | 29 | self.assertTrue(handler.canhandlerequest()) 30 | self.assertTrue(handler.isdir()) 31 | 32 | handler.prepare() 33 | self.assertFalse(handler.fromcache) 34 | 35 | entry = handler.getentry() 36 | self.assertEqual(entry.mimetype, "application/gopher-menu") 37 | self.assertEqual(entry.type, "1") 38 | 39 | entries = handler.getdirlist() 40 | self.assertTrue(entries) 41 | 42 | # Create a second handler to test that it will load from the cached 43 | # file that the first handler should have created 44 | handler = DirHandler( 45 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 46 | ) 47 | 48 | handler.prepare() 49 | self.assertTrue(handler.fromcache) 50 | 51 | cached_entries = handler.getdirlist() 52 | for a, b in zip(entries, cached_entries): 53 | self.assertEqual(a.selector, b.selector) 54 | -------------------------------------------------------------------------------- /tests/handlers/test_file.py: -------------------------------------------------------------------------------- 1 | import io 2 | import tempfile 3 | import unittest 4 | 5 | from pygopherd import testutil 6 | from pygopherd.handlers.base import VFS_Real 7 | from pygopherd.handlers.file import CompressedFileHandler, FileHandler 8 | 9 | 10 | class TestFileHandler(unittest.TestCase): 11 | def setUp(self): 12 | self.config = testutil.get_config() 13 | self.vfs = VFS_Real(self.config) 14 | self.selector = "/testfile.txt" 15 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 16 | self.stat_result = self.vfs.stat(self.selector) 17 | 18 | def test_file_handler(self): 19 | handler = FileHandler( 20 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 21 | ) 22 | 23 | self.assertTrue(handler.canhandlerequest()) 24 | self.assertFalse(handler.isdir()) 25 | 26 | entry = handler.getentry() 27 | self.assertEqual(entry.mimetype, "text/plain") 28 | self.assertEqual(entry.type, "0") 29 | 30 | wfile = io.BytesIO() 31 | handler.write(wfile) 32 | data = wfile.getvalue().decode() 33 | self.assertEqual(data, "Test\n") 34 | 35 | @unittest.skipUnless( 36 | testutil.supports_non_utf8_filenames(), 37 | reason="Filesystem does not support non-utf8 filenames.", 38 | ) 39 | def test_file_handler_non_utf8(self): 40 | self.selector = b"/\xAE.txt".decode(errors="surrogateescape") 41 | 42 | handler = FileHandler( 43 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 44 | ) 45 | 46 | self.assertTrue(handler.canhandlerequest()) 47 | self.assertFalse(handler.isdir()) 48 | 49 | entry = handler.getentry() 50 | self.assertEqual(entry.mimetype, "text/plain") 51 | self.assertEqual(entry.type, "0") 52 | 53 | wfile = io.BytesIO() 54 | handler.write(wfile) 55 | data = wfile.getvalue() 56 | self.assertEqual(data, b"Hello, \xAE!") 57 | 58 | 59 | class TestCompressedFileHandler(unittest.TestCase): 60 | def setUp(self): 61 | self.config = testutil.get_config() 62 | self.vfs = VFS_Real(self.config) 63 | self.selector = "/testfile.txt.gz" 64 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 65 | self.stat_result = self.vfs.stat(self.selector) 66 | 67 | self.config.set( 68 | "handlers.file.CompressedFileHandler", "decompressors", "{'gzip' : 'zcat'}" 69 | ) 70 | 71 | def test_compressed_file_handler(self): 72 | handler = CompressedFileHandler( 73 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 74 | ) 75 | 76 | self.assertTrue(handler.canhandlerequest()) 77 | self.assertFalse(handler.isdir()) 78 | 79 | entry = handler.getentry() 80 | self.assertEqual(entry.mimetype, "text/plain") 81 | self.assertEqual(entry.type, "0") 82 | 83 | with tempfile.TemporaryFile("rb") as wfile: 84 | handler.write(wfile) 85 | wfile.seek(0) 86 | data = wfile.read() 87 | 88 | self.assertEqual(data, b"Test\n") 89 | -------------------------------------------------------------------------------- /tests/handlers/test_gophermap.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pygopherd import testutil 4 | from pygopherd.handlers.base import VFS_Real 5 | from pygopherd.handlers.gophermap import BuckGophermapHandler 6 | 7 | 8 | class TestBuckGophermapHandler(unittest.TestCase): 9 | def setUp(self): 10 | self.config = testutil.get_config() 11 | self.vfs = VFS_Real(self.config) 12 | self.selector = "/bucktooth" 13 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 14 | self.stat_result = self.vfs.stat(self.selector) 15 | 16 | def test_buck_gophermap_handler(self): 17 | handler = BuckGophermapHandler( 18 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 19 | ) 20 | 21 | self.assertTrue(handler.canhandlerequest()) 22 | self.assertTrue(handler.isdir()) 23 | 24 | handler.prepare() 25 | entry = handler.getentry() 26 | self.assertEqual(entry.mimetype, "application/gopher-menu") 27 | self.assertEqual(entry.type, "1") 28 | 29 | entries = handler.getdirlist() 30 | self.assertTrue(entries) 31 | 32 | expected = [ 33 | ("i", "hello world", "fake", "(NULL)", 0), 34 | ("1", "filename", "/bucktooth/filename", None, None), 35 | ("1", "filename", "/bucktooth/README", None, None), 36 | ("1", "filename", "/bucktooth/selector", "hostname", None), 37 | ("1", "filename", "/bucktooth/selector", "hostname", 69), 38 | ] 39 | for i, entry in enumerate(entries): 40 | self.assertEqual(entry.type, expected[i][0]) 41 | self.assertEqual(entry.name, expected[i][1]) 42 | self.assertEqual(entry.selector, expected[i][2]) 43 | self.assertEqual(entry.host, expected[i][3]) 44 | self.assertEqual(entry.port, expected[i][4]) 45 | -------------------------------------------------------------------------------- /tests/handlers/test_html.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from pygopherd import testutil 4 | from pygopherd.handlers.base import VFS_Real 5 | from pygopherd.handlers.html import HTMLFileTitleHandler 6 | 7 | 8 | class TestHTMLHandler(unittest.TestCase): 9 | def setUp(self): 10 | self.config = testutil.get_config() 11 | self.vfs = VFS_Real(self.config) 12 | self.selector = "/testfile.html" 13 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 14 | self.stat_result = self.vfs.stat(self.selector) 15 | 16 | def test_html_handler(self): 17 | handler = HTMLFileTitleHandler( 18 | "/testfile.html", "", self.protocol, self.config, self.stat_result, self.vfs 19 | ) 20 | 21 | self.assertTrue(handler.canhandlerequest()) 22 | 23 | entry = handler.getentry() 24 | self.assertEqual(entry.name, "") 25 | -------------------------------------------------------------------------------- /tests/handlers/test_mbox.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.mbox import ( 7 | MaildirFolderHandler, 8 | MaildirMessageHandler, 9 | MBoxFolderHandler, 10 | MBoxMessageHandler, 11 | ) 12 | 13 | 14 | class TestMBoxHandler(unittest.TestCase): 15 | def setUp(self): 16 | self.config = testutil.get_config() 17 | self.vfs = VFS_Real(self.config) 18 | self.selector = "/python-dev.mbox" 19 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 20 | self.stat_result = self.vfs.stat(self.selector) 21 | 22 | def test_mbox_folder_handler(self): 23 | handler = MBoxFolderHandler( 24 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 25 | ) 26 | handler.prepare() 27 | 28 | self.assertTrue(handler.canhandlerequest()) 29 | self.assertTrue(handler.isdir()) 30 | 31 | entry = handler.getentry() 32 | self.assertEqual(entry.mimetype, "application/gopher-menu") 33 | self.assertEqual(entry.type, "1") 34 | 35 | messages = handler.getdirlist() 36 | self.assertTrue(len(messages), 6) 37 | self.assertEqual(messages[0].selector, "/python-dev.mbox|/MBOX-MESSAGE/1") 38 | self.assertEqual(messages[0].name, "[Python-Dev] Pickling w/ low overhead") 39 | 40 | def test_mbox_message_handler(self): 41 | """ 42 | Load the third message from the mbox. 43 | """ 44 | handler = MBoxMessageHandler( 45 | "/python-dev.mbox|/MBOX-MESSAGE/3", 46 | "", 47 | self.protocol, 48 | self.config, 49 | self.stat_result, 50 | self.vfs, 51 | ) 52 | handler.prepare() 53 | 54 | self.assertTrue(handler.canhandlerequest()) 55 | self.assertFalse(handler.isdir()) 56 | 57 | entry = handler.getentry() 58 | self.assertEqual(entry.mimetype, "text/plain") 59 | self.assertEqual(entry.name, "Re: [Python-Dev] Buffer interface in abstract.c?") 60 | self.assertEqual(entry.type, "0") 61 | 62 | wfile = io.BytesIO() 63 | handler.write(wfile) 64 | email_text = wfile.getvalue() 65 | assert email_text.startswith(b"From: Greg Stein ") 66 | 67 | 68 | class TestMaildirHandler(unittest.TestCase): 69 | """ 70 | The maildir test data was generated from a mailbox file using this script: 71 | 72 | http://batleth.sapienti-sat.org/projects/mb2md/ 73 | 74 | Important Note: The python implementation uses os.listdir() under the 75 | hood to iterate over the mail files in the directory. The order of 76 | files returned is deterministic but is *not* sorted by filename. This 77 | means that mail files in the generated gopher directory listing may 78 | appear out of order from their "true" ordering in the mail archive. 79 | It also makes writing this test a pain in the ass. 80 | """ 81 | 82 | def setUp(self): 83 | self.config = testutil.get_config() 84 | self.vfs = VFS_Real(self.config) 85 | self.selector = "/python-dev" 86 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 87 | self.stat_result = self.vfs.stat(self.selector) 88 | 89 | def test_maildir_folder_handler(self): 90 | handler = MaildirFolderHandler( 91 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 92 | ) 93 | handler.prepare() 94 | 95 | self.assertTrue(handler.canhandlerequest()) 96 | self.assertTrue(handler.isdir()) 97 | 98 | entry = handler.getentry() 99 | self.assertEqual(entry.mimetype, "application/gopher-menu") 100 | self.assertEqual(entry.type, "1") 101 | 102 | messages = handler.getdirlist() 103 | self.assertTrue(len(messages), 6) 104 | self.assertEqual(messages[0].selector, "/python-dev|/MAILDIR-MESSAGE/1") 105 | self.assertIn("[Python-Dev]", messages[0].name) 106 | 107 | def test_maildir_message_handler(self): 108 | """ 109 | Load the third message from the maildir. 110 | """ 111 | handler = MaildirMessageHandler( 112 | "/python-dev|/MAILDIR-MESSAGE/3", 113 | "", 114 | self.protocol, 115 | self.config, 116 | self.stat_result, 117 | self.vfs, 118 | ) 119 | handler.prepare() 120 | 121 | self.assertTrue(handler.canhandlerequest()) 122 | self.assertFalse(handler.isdir()) 123 | 124 | entry = handler.getentry() 125 | self.assertEqual(entry.mimetype, "text/plain") 126 | self.assertEqual(entry.type, "0") 127 | 128 | wfile = io.BytesIO() 129 | handler.write(wfile) 130 | email_text = wfile.getvalue() 131 | assert email_text.startswith(b"From:") 132 | -------------------------------------------------------------------------------- /tests/handlers/test_pyg.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.pyg import PYGHandler 7 | 8 | 9 | class TestPYGHandler(unittest.TestCase): 10 | def setUp(self): 11 | self.config = testutil.get_config() 12 | self.vfs = VFS_Real(self.config) 13 | self.selector = "/testfile.pyg" 14 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 15 | self.stat_result = self.vfs.stat("/testfile.pyg") 16 | 17 | def test_pyg_handler(self): 18 | handler = PYGHandler( 19 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 20 | ) 21 | 22 | self.assertTrue(handler.canhandlerequest()) 23 | self.assertFalse(handler.isdir()) 24 | 25 | entry = handler.getentry() 26 | self.assertEqual(entry.selector, "/testfile.pyg") 27 | 28 | wfile = io.BytesIO() 29 | handler.write(wfile) 30 | self.assertEqual(wfile.getvalue(), b"hello world!") 31 | -------------------------------------------------------------------------------- /tests/handlers/test_script_exec.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.scriptexec import ExecHandler 7 | 8 | 9 | class TestExecHandler(unittest.TestCase): 10 | def setUp(self): 11 | self.config = testutil.get_config() 12 | self.vfs = VFS_Real(self.config) 13 | # The "hello" will be sent as an additional script argument. Multiple 14 | # query arguments can be provided using " " as the separator. 15 | self.selector = "/pygopherd/cgitest.sh?hello" 16 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 17 | self.stat_result = None 18 | 19 | def test_exec_handler(self): 20 | handler = ExecHandler( 21 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 22 | ) 23 | 24 | self.assertTrue(handler.isrequestforme()) 25 | 26 | entry = handler.getentry() 27 | self.assertEqual(entry.mimetype, "text/plain") 28 | self.assertEqual(entry.type, "0") 29 | self.assertEqual(entry.name, "cgitest.sh") 30 | self.assertEqual(entry.selector, "/pygopherd/cgitest.sh") 31 | 32 | # The test script will print $REQUEST and exit 33 | with tempfile.TemporaryFile(mode="w+") as wfile: 34 | handler.write(wfile) 35 | wfile.seek(0) 36 | output = wfile.read() 37 | self.assertEqual(output, "hello from /pygopherd/cgitest.sh\n") 38 | -------------------------------------------------------------------------------- /tests/handlers/test_tal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import io 5 | import unittest 6 | 7 | from pygopherd import initialization, testutil 8 | from pygopherd.handlers.base import VFS_Real 9 | from pygopherd.handlers.tal import TALFileHandler, talavailable 10 | 11 | TEST_TEMPLATE = """ 12 | 13 | 14 | TAL Test 15 | 16 | 17 | My selector is: {selector}
18 | My MIME type is: text/html
19 | Another way of getting that is: text/html
20 | Gopher type is: h
21 | My handler is: {handler}
22 | My protocol is: {protocol}
23 | Python path enabling status: 1
24 | My vfs is: {vfs}
25 | Math: 4 26 | 27 | 28 | """ 29 | 30 | 31 | class TestTALHandler(unittest.TestCase): 32 | def setUp(self): 33 | self.config = testutil.get_config() 34 | self.vfs = VFS_Real(self.config) 35 | self.selector = "/talsample.html.tal" 36 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 37 | self.stat_result = self.vfs.stat(self.selector) 38 | 39 | # Initialize the custom mimetypes encoding map 40 | initialization.init_logger(self.config, "") 41 | initialization.init_mimetypes(self.config) 42 | 43 | def test_tal_available(self): 44 | self.assertTrue(talavailable) 45 | 46 | def test_tal_handler(self): 47 | handler = TALFileHandler( 48 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 49 | ) 50 | 51 | self.assertTrue(handler.canhandlerequest()) 52 | 53 | entry = handler.getentry() 54 | self.assertEqual(entry.mimetype, "text/html") 55 | self.assertEqual(entry.type, "h") 56 | 57 | wfile = io.BytesIO() 58 | handler.write(wfile) 59 | rendered_data = wfile.getvalue().decode() 60 | 61 | expected_data = TEST_TEMPLATE.format( 62 | selector=handler.selector, 63 | handler=html.escape(str(handler)), 64 | protocol=html.escape(str(self.protocol)), 65 | vfs=html.escape(str(self.vfs)), 66 | ) 67 | 68 | self.assertEqual(rendered_data.strip(), expected_data.strip()) 69 | -------------------------------------------------------------------------------- /tests/handlers/test_umn.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.UMN import UMNDirHandler 7 | 8 | 9 | class TestUMNDirHandler(unittest.TestCase): 10 | def setUp(self): 11 | self.config = testutil.get_config() 12 | self.vfs = VFS_Real(self.config) 13 | self.selector = "/" 14 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 15 | self.stat_result = self.vfs.stat(self.selector) 16 | 17 | # Make sure there's no directory cache file from a previous test run 18 | cachefile = self.config.get("handlers.dir.DirHandler", "cachefile") 19 | try: 20 | os.remove(self.vfs.getfspath(self.selector) + "/" + cachefile) 21 | except OSError: 22 | pass 23 | 24 | def test_dir_handler(self): 25 | handler = UMNDirHandler( 26 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 27 | ) 28 | 29 | self.assertTrue(handler.canhandlerequest()) 30 | self.assertTrue(handler.isdir()) 31 | 32 | handler.prepare() 33 | self.assertFalse(handler.fromcache) 34 | 35 | entry = handler.getentry() 36 | self.assertEqual(entry.mimetype, "application/gopher-menu") 37 | self.assertEqual(entry.type, "1") 38 | 39 | entries = handler.getdirlist() 40 | self.assertTrue(entries) 41 | 42 | # First entry should be the special cap file 43 | self.assertEqual(entries[0].name, "New Long Cool Name") 44 | self.assertEqual(entries[0].selector, "/zzz.txt") 45 | 46 | # Second entry should be the special link file 47 | self.assertEqual(entries[1].name, "Cheese Ball Recipes") 48 | self.assertEqual(entries[1].host, "zippy.micro.umn.edu") 49 | self.assertEqual(entries[1].port, 150) 50 | -------------------------------------------------------------------------------- /tests/handlers/test_url.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | from pygopherd import testutil 5 | from pygopherd.handlers.base import VFS_Real 6 | from pygopherd.handlers.file import FileHandler 7 | from pygopherd.handlers.url import HTMLURLHandler, URLTypeRewriter 8 | 9 | 10 | class TestHTMLURLHandler(unittest.TestCase): 11 | def setUp(self): 12 | self.config = testutil.get_config() 13 | self.vfs = VFS_Real(self.config) 14 | self.selector = "URL:http://gopher.quux.org/" 15 | self.protocol = testutil.get_testing_protocol(self.selector, config=self.config) 16 | self.stat_result = None 17 | 18 | def test_url_rewriter_handler(self): 19 | """ 20 | The URL rewriter should drop the "/0" at the beginning of the selector 21 | and then pass it off to the appropriate handler. 22 | """ 23 | handler = HTMLURLHandler( 24 | self.selector, "", self.protocol, self.config, self.stat_result, self.vfs 25 | ) 26 | 27 | self.assertTrue(handler.isrequestforme()) 28 | 29 | entry = handler.getentry() 30 | self.assertEqual(entry.mimetype, "text/html") 31 | self.assertEqual(entry.type, "h") 32 | 33 | wfile = io.BytesIO() 34 | handler.write(wfile) 35 | 36 | out = wfile.getvalue() 37 | self.assertIn( 38 | b'http://gopher.quux.org/', out 39 | ) 40 | 41 | def test_handler_escape_urls(self): 42 | """ 43 | URLs should be escaped in the generated HTML. 44 | """ 45 | handler = HTMLURLHandler( 46 | 'URL:http://gopher.quux.org/"