diff options
Diffstat (limited to 'pkg')
-rw-r--r-- | pkg/libgemini/libgemini.go | 145 | ||||
-rw-r--r-- | pkg/libgopher/libgopher.go | 312 |
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 @@ | |||
1 | package libgemini | ||
2 | |||
3 | import ( | ||
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 | |||
17 | const ( | ||
18 | CRLF = "\r\n" | ||
19 | ) | ||
20 | |||
21 | const ( | ||
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 | |||
45 | const ( | ||
46 | MIME_GEMINI = "text/gemini" | ||
47 | DEFAULT_MIME = MIME_GEMINI | ||
48 | DEFAULT_CHARSET = "utf-8" | ||
49 | ) | ||
50 | |||
51 | var ( | ||
52 | HeaderPattern = regexp.MustCompile("^(\\d\\d)[ \\t]+(.*)$") | ||
53 | LinkPattern = regexp.MustCompile("^=>[ \\t]*([^ \\t]+)(?:[ \\t]+(.*))?$") | ||
54 | ) | ||
55 | |||
56 | type Header struct { | ||
57 | Status int | ||
58 | Meta string | ||
59 | } | ||
60 | |||
61 | type Response struct { | ||
62 | Header *Header | ||
63 | Body io.Reader | ||
64 | } | ||
65 | |||
66 | func 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 | |||
117 | func 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 @@ | |||
1 | package libgopher | ||
2 | |||
3 | import ( | ||
4 | "bufio" | ||
5 | "errors" | ||
6 | "io" | ||
7 | "log" | ||
8 | "net" | ||
9 | "net/url" | ||
10 | "strings" | ||
11 | ) | ||
12 | |||
13 | // Item Types | ||
14 | const ( | ||
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 | |||
41 | const ( | ||
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 | ||
56 | type ItemType byte | ||
57 | |||
58 | // Return a human friendly represation of an ItemType | ||
59 | func (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. | ||
105 | type 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 | ||
117 | func 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 | |||
161 | func (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 | ||
173 | type 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). | ||
181 | type Response struct { | ||
182 | Type ItemType | ||
183 | Dir Directory | ||
184 | Body io.Reader | ||
185 | } | ||
186 | |||
187 | // Get fetches a Gopher resource by URI | ||
188 | func 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. | ||
250 | func (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. | ||
271 | func (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 | } | ||