aboutsummaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/libgemini/libgemini.go145
-rw-r--r--pkg/libgopher/libgopher.go312
2 files changed, 457 insertions, 0 deletions
diff --git a/pkg/libgemini/libgemini.go b/pkg/libgemini/libgemini.go
new file mode 100644
index 0000000..303490c
--- /dev/null
+++ b/pkg/libgemini/libgemini.go
@@ -0,0 +1,145 @@
1package libgemini
2
3import (
4 "bufio"
5 "crypto/tls"
6 "errors"
7 "fmt"
8 "io"
9 "mime"
10 "net"
11 "net/url"
12 "regexp"
13 "strconv"
14 "strings"
15)
16
17const (
18 CRLF = "\r\n"
19)
20
21const (
22 STATUS_INPUT = 10
23 STATUS_SUCCESS = 20
24 STATUS_SUCCESS_CERT = 21
25 STATUS_REDIRECT_TEMP = 30
26 STATUS_REDIRECT_PERM = 31
27 STATUS_TEMP_FAILURE = 40
28 STATUS_SERVER_UNAVAILABLE = 41
29 STATUS_CGI_ERROR = 42
30 STATUS_PROXY_ERROR = 43
31 STATUS_SLOW_DOWN = 44
32 STATUS_PERM_FAILURE = 50
33 STATUS_NOT_FOUND = 51
34 STATUS_GONE = 52
35 STATUS_PROXY_REFUSED = 53
36 STATUS_BAD_REQUEST = 59
37 STATUS_CLIENT_CERT_EXPIRED = 60
38 STATUS_TRANSIENT_CERT_REQUEST = 61
39 STATUS_AUTH_CERT_REQUIRED = 62
40 STATUS_CERT_REJECTED = 63
41 STATUS_FUTURE_CERT_REJECTED = 64
42 STATUS_EXPIRED_CERT_REJECTED = 65
43)
44
45const (
46 MIME_GEMINI = "text/gemini"
47 DEFAULT_MIME = MIME_GEMINI
48 DEFAULT_CHARSET = "utf-8"
49)
50
51var (
52 HeaderPattern = regexp.MustCompile("^(\\d\\d)[ \\t]+(.*)$")
53 LinkPattern = regexp.MustCompile("^=>[ \\t]*([^ \\t]+)(?:[ \\t]+(.*))?$")
54)
55
56type Header struct {
57 Status int
58 Meta string
59}
60
61type Response struct {
62 Header *Header
63 Body io.Reader
64}
65
66func Get(uri string) (*Response, error) {
67 u, err := url.Parse(uri)
68 if err != nil {
69 return nil, err
70 }
71
72 if u.Scheme != "gemini" {
73 return nil, errors.New("invalid scheme for uri")
74 }
75
76 host := u.Hostname()
77 port := u.Port()
78
79 if port == "" {
80 port = "1965"
81 }
82
83 conn, err := tls.Dial("tcp", net.JoinHostPort(host, port), &tls.Config{
84 MinVersion: tls.VersionTLS12,
85 InsecureSkipVerify: true,
86 })
87 if err != nil {
88 return nil, err
89 }
90
91 _, err = conn.Write([]byte(u.String() + CRLF))
92 if err != nil {
93 conn.Close()
94 return nil, err
95 }
96
97 reader := bufio.NewReader(conn)
98
99 line, _, err := reader.ReadLine()
100 if err != nil {
101 conn.Close()
102 return nil, err
103 }
104
105 header, err := ParseHeader(string(line))
106 if err != nil {
107 conn.Close()
108 return nil, err
109 }
110
111 return &Response{
112 Header: header,
113 Body: reader,
114 }, nil
115}
116
117func ParseHeader(line string) (header *Header, err error) {
118 matches := HeaderPattern.FindStringSubmatch(line)
119
120 status, err := strconv.Atoi(matches[1])
121 if err != nil {
122 return nil, err
123 }
124
125 meta := matches[2]
126
127 if int(status/10) == 2 {
128 mediaType, params, err := mime.ParseMediaType(meta)
129
130 if err != nil {
131 meta = fmt.Sprintf("%s;charset=%s", DEFAULT_MIME, DEFAULT_CHARSET)
132 } else if strings.HasPrefix(mediaType, "text/") {
133 if _, ok := params["charset"]; !ok {
134 meta += ";charset=" + DEFAULT_CHARSET
135 }
136 }
137 }
138
139 header = &Header{
140 Status: status,
141 Meta: meta,
142 }
143
144 return
145}
diff --git a/pkg/libgopher/libgopher.go b/pkg/libgopher/libgopher.go
new file mode 100644
index 0000000..86d58ff
--- /dev/null
+++ b/pkg/libgopher/libgopher.go
@@ -0,0 +1,312 @@
1package libgopher
2
3import (
4 "bufio"
5 "errors"
6 "io"
7 "log"
8 "net"
9 "net/url"
10 "strings"
11)
12
13// Item Types
14const (
15 FILE = ItemType('0') // Item is a file
16 DIRECTORY = ItemType('1') // Item is a directory
17 PHONEBOOK = ItemType('2') // Item is a CSO phone-book server
18 ERROR = ItemType('3') // Error
19 BINHEX = ItemType('4') // Item is a BinHexed Macintosh file.
20 DOSARCHIVE = ItemType('5') // Item is DOS binary archive of some sort. (*)
21 UUENCODED = ItemType('6') // Item is a UNIX uuencoded file.
22 INDEXSEARCH = ItemType('7') // Item is an Index-Search server.
23 TELNET = ItemType('8') // Item points to a text-based telnet session.
24 BINARY = ItemType('9') // Item is a binary file! (*)
25
26 // (*) Client must read until the TCP connection is closed.
27
28 REDUNDANT = ItemType('+') // Item is a redundant server
29 TN3270 = ItemType('T') // Item points to a text-based tn3270 session.
30 GIF = ItemType('g') // Item is a GIF format graphics file.
31 IMAGE = ItemType('I') // Item is some kind of image file.
32
33 // non-standard
34 INFO = ItemType('i') // Item is an informational message
35 HTML = ItemType('h') // Item is a HTML document
36 AUDIO = ItemType('s') // Item is an Audio file
37 PNG = ItemType('p') // Item is a PNG Image
38 DOC = ItemType('d') // Item is a Document
39)
40
41const (
42 // END represents the terminator used in directory responses
43 END = byte('.')
44
45 // TAB is the delimiter used to separate item response parts
46 TAB = byte('\t')
47
48 // CRLF is the delimiter used per line of response item
49 CRLF = "\r\n"
50
51 // DEFAULT is the default item type
52 DEFAULT = BINARY
53)
54
55// ItemType represents the type of an item
56type ItemType byte
57
58// Return a human friendly represation of an ItemType
59func (it ItemType) String() string {
60 switch it {
61 case FILE:
62 return "TXT"
63 case DIRECTORY:
64 return "DIR"
65 case PHONEBOOK:
66 return "PHO"
67 case ERROR:
68 return "ERR"
69 case BINHEX:
70 return "HEX"
71 case DOSARCHIVE:
72 return "ARC"
73 case UUENCODED:
74 return "UUE"
75 case INDEXSEARCH:
76 return "QRY"
77 case TELNET:
78 return "TEL"
79 case BINARY:
80 return "BIN"
81 case REDUNDANT:
82 return "DUP"
83 case TN3270:
84 return "TN3"
85 case GIF:
86 return "GIF"
87 case IMAGE:
88 return "IMG"
89 case INFO:
90 return "NFO"
91 case HTML:
92 return "HTM"
93 case AUDIO:
94 return "SND"
95 case PNG:
96 return "PNG"
97 case DOC:
98 return "DOC"
99 default:
100 return "???"
101 }
102}
103
104// Item describes an entry in a directory listing.
105type Item struct {
106 Type ItemType `json:"type"`
107 Description string `json:"description"`
108 Selector string `json:"selector"`
109 Host string `json:"host"`
110 Port string `json:"port"`
111
112 // non-standard extensions (ignored by standard clients)
113 Extras []string `json:"extras"`
114}
115
116// ParseItem parses a line of text into an item
117func ParseItem(line string) (item *Item, err error) {
118 parts := strings.Split(strings.Trim(line, "\r\n"), "\t")
119
120 if len(parts[0]) < 1 {
121 return nil, errors.New("no item type: " + string(line))
122 }
123
124 item = &Item{
125 Type: ItemType(parts[0][0]),
126 Description: string(parts[0][1:]),
127 Extras: make([]string, 0),
128 }
129
130 // Selector
131 if len(parts) > 1 {
132 item.Selector = string(parts[1])
133 } else {
134 item.Selector = ""
135 }
136
137 // Host
138 if len(parts) > 2 {
139 item.Host = string(parts[2])
140 } else {
141 item.Host = "null.host"
142 }
143
144 // Port
145 if len(parts) > 3 {
146 item.Port = string(parts[3])
147 } else {
148 item.Port = "0"
149 }
150
151 // Extras
152 if len(parts) >= 4 {
153 for _, v := range parts[4:] {
154 item.Extras = append(item.Extras, string(v))
155 }
156 }
157
158 return
159}
160
161func (i *Item) isDirectoryLike() bool {
162 switch i.Type {
163 case DIRECTORY:
164 return true
165 case INDEXSEARCH:
166 return true
167 default:
168 return false
169 }
170}
171
172// Directory representes a Gopher Menu of Items
173type Directory struct {
174 Items []*Item `json:"items"`
175}
176
177// Response represents a Gopher resource that
178// Items contains a non-empty array of Item(s)
179// for directory types, otherwise the Body
180// contains the fetched resource (file, image, etc).
181type Response struct {
182 Type ItemType
183 Dir Directory
184 Body io.Reader
185}
186
187// Get fetches a Gopher resource by URI
188func Get(uri string) (*Response, error) {
189 u, err := url.Parse(uri)
190 if err != nil {
191 return nil, err
192 }
193
194 if u.Scheme != "gopher" {
195 return nil, errors.New("invalid scheme for uri")
196 }
197
198 host := u.Hostname()
199 port := u.Port()
200
201 if port == "" {
202 port = "70"
203 }
204
205 var (
206 Type ItemType
207 Selector string
208 )
209
210 path := strings.TrimPrefix(u.Path, "/")
211 if len(path) > 2 {
212 Type = ItemType(path[0])
213 Selector = path[1:]
214 if u.RawQuery != "" {
215 Selector += "\t" + u.RawQuery
216 }
217 } else if len(path) == 1 {
218 Type = ItemType(path[0])
219 Selector = ""
220 } else {
221 Type = ItemType(DIRECTORY)
222 Selector = ""
223 }
224
225 i := Item{Type: Type, Selector: Selector, Host: host, Port: port}
226 res := Response{Type: i.Type}
227
228 if i.isDirectoryLike() {
229 d, err := i.FetchDirectory()
230 if err != nil {
231 return nil, err
232 }
233
234 res.Dir = d
235 } else {
236 reader, err := i.FetchFile()
237 if err != nil {
238 return nil, err
239 }
240
241 res.Body = reader
242 }
243
244 return &res, nil
245}
246
247// FetchFile fetches data, not directory information.
248// Calling this on a DIRECTORY Item type
249// or unsupported type will return an error.
250func (i *Item) FetchFile() (io.Reader, error) {
251 if i.Type == DIRECTORY {
252 return nil, errors.New("cannot fetch a directory as a file")
253 }
254
255 conn, err := net.Dial("tcp", net.JoinHostPort(i.Host, i.Port))
256 if err != nil {
257 return nil, err
258 }
259
260 _, err = conn.Write([]byte(i.Selector + CRLF))
261 if err != nil {
262 conn.Close()
263 return nil, err
264 }
265
266 return conn, nil
267}
268
269// FetchDirectory fetches directory information, not data.
270// Calling this on an Item whose type is not DIRECTORY will return an error.
271func (i *Item) FetchDirectory() (Directory, error) {
272 if !i.isDirectoryLike() {
273 return Directory{}, errors.New("cannot fetch a file as a directory")
274 }
275
276 conn, err := net.Dial("tcp", net.JoinHostPort(i.Host, i.Port))
277 if err != nil {
278 return Directory{}, err
279 }
280
281 _, err = conn.Write([]byte(i.Selector + CRLF))
282 if err != nil {
283 return Directory{}, err
284 }
285
286 reader := bufio.NewReader(conn)
287 scanner := bufio.NewScanner(reader)
288 scanner.Split(bufio.ScanLines)
289
290 var items []*Item
291
292 for scanner.Scan() {
293 line := strings.Trim(scanner.Text(), "\r\n")
294
295 if len(line) == 0 {
296 continue
297 }
298
299 if len(line) == 1 && line[0] == END {
300 break
301 }
302
303 item, err := ParseItem(line)
304 if err != nil {
305 log.Printf("Error parsing %q: %q", line, err)
306 continue
307 }
308 items = append(items, item)
309 }
310
311 return Directory{items}, nil
312}