ecb0b656fa9967ad11dd85fcdba9e8e7b81eca43
[scratchy.git] / scratchy.scm
1 ;;; Scratchy gopher server
2 ;;
3 ;; Requires Chicken 5
4 ;;
5
6 ;;; Imports
7
8 (import tcp6
9         (chicken port)
10         (chicken io)
11         (chicken string)
12         (chicken pathname)
13         (chicken file)
14         (chicken time posix)
15         (chicken condition)
16         (chicken process)
17         (chicken process-context)
18         (chicken process-context posix)
19         srfi-1 srfi-13 matchable)
20
21 ;;; Global constants
22
23 (define scratchy-version "1.4.0")
24
25 (define scratchy-footer
26   (conc "\n"
27         "--------------------------------------------------\n"
28         "This gopher hole was dug using Scratchy v" scratchy-version ".\n"
29         "Powered by Chicken Scheme!"))
30
31 (define gopher-index-filename "index")
32
33 ;;; Server loop
34
35 ;; We don't yet use worker threads here to handle requests,
36 ;; the server just blocks until the first request is finished.
37 ;; While we should fix this, it's actually probably okay, as
38 ;; we genuinely don't expect a huge flood of gopher traffic. :-(
39
40 (define-record config
41   root-dir host port display-footer user group blacklist blacklist-resp)
42
43 (define (run-server config)
44   (set-buffering-mode! (current-output-port) #:line)
45   (let ((listener (tcp-listen (config-port config) 10 "::")))
46     (print "Gopher server listening on port " (config-port config) " ...")
47     (drop-privs config)
48     (server-loop listener config))
49   (tcp-close listener))
50
51 (define (drop-privs config)
52   (let ((uid (config-user config))
53         (gid (config-group config)))
54     (if (not (null? gid)) ; Group first, since only root can switch groups.
55         (set! (current-group-id) gid))
56     (if (not (null? uid))
57         (set! (current-user-id) uid))))
58
59 (define (server-loop listener config)
60   (let-values (((in-port out-port) (tcp-accept listener)))
61     (let-values (((local-ip remote-ip) (tcp-addresses in-port)))
62       (print "Accepted connection from " remote-ip
63              " on " (seconds->string))
64       (condition-case
65           (if (or (null? (config-blacklist config))
66                   (not (member remote-ip
67                                (with-input-from-file
68                                    (config-blacklist config)
69                                  read))))
70               (let* ((line (read-line in-port))
71                      (selector (string-trim-both line)))
72                 (condition-case
73                     (begin
74                       (with-output-to-port out-port
75                         (lambda ()
76                           (serve-selector selector config)))
77                       (print "... served selector '" selector "'. Closing connection."))
78                   (o (exn)
79                      (print-error-message o out-port)
80                      (print-error-message o)
81                      (print "Error while attempting to serve selector " selector "."))))
82               (begin
83                 (print "Connection from blacklisted IP. Closing.")
84                 (with-output-to-port out-port
85                   (lambda ()
86                     (print* "Refusing to serve to IP " remote-ip ".\r\n")
87                     (unless (null? (config-blacklist-resp config))
88                       (for-each (lambda (l) (print* l "\r\n"))
89                                 (with-input-from-file
90                                     (config-blacklist-resp config)
91                                   read-lines)))
92                     (print* ".\r\n")))))
93         (o (exn)
94            (print-error-message o))))
95     (close-input-port in-port)
96     (close-output-port out-port))
97   (server-loop listener config))
98
99 ;;; Selector type inference
100
101 (define (true-for-one? predicate values)
102   (if (null? values)
103       #f
104       (if (predicate (car values))
105           #t
106           (true-for-one? predicate (cdr values)))))
107
108 (define (has-suffix? selector . suffixes)
109   (true-for-one? (lambda (suffix)
110                    (string-suffix? suffix selector))
111                  suffixes))
112
113 (define (has-prefix? selector . prefixes)
114   (true-for-one? (lambda (prefix)
115                    (string-prefix? prefix selector))
116                  prefixes))
117
118 (define (infer-selector-type selector)
119   (let ((l (string-downcase selector)))
120     (cond
121      ((or (= (string-length l) 0)
122           (string-suffix? "/" l)) 1)
123      ((has-suffix? l ".txt" ".org" ".md") 0)
124      ((has-suffix? l ".png" ".jpg" ".gif" ".bmp" ".tif" ".tga") 'I)
125      ((has-suffix? l "?" "%3f") 7)
126      ((has-prefix? l "url:" "/url:") 'h)
127      (else 9))))
128
129
130 ;;; Selector retrieval
131
132 (define (serve-selector raw-selector config)
133   (let* ((selector-list (string-split raw-selector "\t" #t))
134          (selector (car selector-list))
135          (arguments (cdr selector-list)))
136     (cond
137      ((string-contains selector "|")
138       (let ((l (string-split selector "|" #t)))
139         (serve-script (car l) (cdr l) config)))
140      ((legal-filename? (directory-index-filename selector config) config)
141       (serve-directory-file selector config))
142      (else
143       (case (infer-selector-type selector)
144         ((1) (error "Invalid directory selector."))
145         ((7) (let ((l (string-split selector "?" #t)))
146                (serve-script (car l) arguments config)))
147         ((0) (serve-text-file selector config))
148         ((h) (serve-url selector config))
149         (else (serve-binary-file selector config)))))))
150
151 (define (legal-filename? filename config)
152   (and (string-prefix? (config-root-dir config)
153                        (normalize-pathname filename))
154        (file-exists? filename)
155        (not (directory-exists? filename))
156        (file-readable? filename)))
157
158 (define (legal-script-filename? filename config)
159   (and (legal-filename? filename config)
160        (string-suffix? ".scm" filename)
161        (file-executable? filename)))
162
163 (define (directory-index-filename selector config)
164   (make-pathname (list (config-root-dir config)
165                        selector)
166                  gopher-index-filename))
167
168 (define (serve-directory-file selector config)
169   (let ((filename (directory-index-filename selector config)))
170     (if (legal-filename? filename config)
171         (begin
172           (with-input-from-file filename
173             (lambda ()
174               (let loop ((c (peek-char)))
175                 (if (eof-object? c)
176                     'done
177                     (begin
178                       (if (eq? c #\,)
179                           (begin
180                             (read-char)
181                             (serve-record (read) selector config)
182                             (read-line))
183                           (serve-info-records (read-line)))
184                       (loop (peek-char)))))))
185           (if (config-display-footer config)
186               (serve-info-records scratchy-footer))
187           (print ".\r"))
188         (error "No legal index file not found."))))
189   
190 (define (serve-text-file selector config)
191   (let ((filename (make-pathname (config-root-dir config) selector)))
192     (if (legal-filename? filename config)
193         (begin
194           (with-input-from-file filename
195             (lambda ()
196               (for-each
197                (lambda (line)
198                  (print line "\r"))
199                (read-lines))))
200           (print ".\r"))
201         (error "File not found." filename))))
202
203 (define (serve-binary-file selector config)
204   (let ((filename (make-pathname (config-root-dir config) selector)))
205     (if (legal-filename? filename config)
206         (with-input-from-file filename
207           (lambda ()
208             (let loop ((b (read-byte)))
209               (if (eof-object? b)
210                   'done
211                   (begin
212                     (write-byte b)
213                     (loop (read-byte)))))))
214         (error "File not found." filename))))
215
216 (define (serve-url selector config)
217   (let ((url (substring selector 4)))
218     (print
219      "<html><head><title>Redirection</title>"
220      "<meta http-equiv=\"refresh\" content=\"10; URL='" url "'\" />"
221      "</head><body>"
222      "<p>If you are seeing this page, your gopher browser does not "
223      "properly support URL directory entries or cannot follow such "
224      "links.</p>"
225      "<p>If you are viewing this page using a web browser, you should "
226      "be redirected shortly.  Otherwise, you can manually open the "
227      "the follwing url:\n"
228      "\n"
229      "<a href=\"" url "\">" url "</a>\n"
230      "</body></html>")))
231
232 (define (serve-script selector arguments config)
233   (let ((filename (make-pathname (config-root-dir config) selector)))
234     (if (legal-script-filename? filename config)
235         (let* ((sexp (with-input-from-file filename read))
236                (script-result (with-selector-dir
237                                (pathname-directory selector) config
238                                (lambda ()
239                                  (apply (eval sexp) arguments)))))
240           (when (pair? script-result)
241             (serve-records script-result
242                            (pathname-directory selector) config)
243             (print ".\r")))
244         (error "No legal index script not found." filename))))
245
246
247 ;;; Index rendering
248
249 (define (serve-records records dir-selector config)
250   (for-each
251    (lambda (record)
252      (serve-record record dir-selector config))
253    records))
254
255 (define (serve-info-records string)
256   (for-each
257    (lambda (line)
258      (print* "i")
259      (for-each (lambda (char)
260                  (print* (if (eq? char #\tab)
261                              "    "
262                              char)))
263                (string->list line))
264      (print "\tfake\tfake\t1\r"))
265    (string-split string "\n" #t)))
266
267 (define (serve-record record dir-selector config)
268   (match record
269     ((? string?) (serve-info-records record))
270     (('shell command) (serve-shell-command command dir-selector config))
271     (('eval expression) (serve-expression expression dir-selector config))
272     (('url display-string url)
273      (print #\h display-string "\tURL:" url
274             "\t" (config-host config)
275             "\t" (config-port config) "\r"))
276     ((type display-string selector host port)
277      (print type display-string "\t" selector "\t" host "\t" port "\r"))
278     ((type display-string selector host)
279      (serve-record (list type display-string selector host 70)
280                    dir-selector config))
281     ((type display-string selector)
282      (serve-record (list type display-string
283                          (make-pathname dir-selector selector)
284                          (config-host config) (config-port config))
285                    dir-selector config))
286     ((display-string selector)
287      (serve-record (list (infer-selector-type selector) display-string selector)
288                    dir-selector config))
289     ((selector)
290      (serve-record (list (infer-selector-type selector) selector)
291                    dir-selector config))
292     (else (error "Unknown record type."))))
293
294 (define (serve-shell-command command dir-selector config)
295   (with-selector-dir
296    dir-selector config
297    (lambda ()
298      (let-values (((in-port out-port id) (process command)))
299        (let ((string (read-string #f in-port)))
300          (if (and (not (eof-object? string))
301                   (> (string-length string) 0))
302              (serve-info-records (string-chomp string "\n")))
303          (close-input-port in-port)
304          (close-output-port out-port))))))
305
306 (define (serve-expression expression dir-selector config)
307   (with-selector-dir
308    dir-selector config
309    (lambda ()
310      (serve-records (eval expression) dir-selector config))))
311
312
313 ;;; Utility methods
314
315 (define (with-current-working-directory directory thunk)
316   (let ((old-wd (current-directory))
317         (result 'none))
318     (condition-case
319         (begin
320           (change-directory directory)
321           (set! result (thunk))
322           (change-directory old-wd)
323           result)
324       (o (exn)
325          (change-directory old-wd)
326          (signal o)))))
327
328 (define (with-selector-dir selector config thunk)
329   (with-current-working-directory
330    (make-pathname (config-root-dir config) selector)
331    thunk))
332
333
334 ;;; Main
335
336 (define (print-usage progname)
337   (let ((indent-str (make-string (string-length progname) #\space)))
338     (print "Usage:\n"
339            progname " -h/--help\n"
340            progname " [-n/--no-footer] [-u/--user UID] [-g/--group GID]\n"
341            indent-str " [-b/--blacklist FILE] [-r/--blacklist-resp RESPFILE] root-dir hostname [port]\n"
342            "\n"
343            "The -n option tells the server to not display a directory footer.\n"
344            "The -u and -g can be used to set the UID and GID of the process following\n"
345            "the creation of the TCP port listener (which often requires root).\n"
346            "The -b option can be used to specify a FILE containing a list of IP addresses\n"
347            "to block from the server. If a connection from a blocked address is served,\n"
348            "the response file RESPFILE is served instead, if this is provided.")))
349
350 (define (main)
351   (let* ((progname (pathname-file (car (argv))))
352          (config (make-config '() '() 70 #t '() '() '() '())))
353     (if (null? (cdr (argv)))
354         (print-usage progname)
355         (let loop ((args (cdr (argv))))
356           (let ((this-arg (car args))
357                 (rest-args (cdr args)))
358             (if (string-prefix? "-" this-arg)
359                 (cond
360                  ((or (equal? this-arg "-h")
361                       (equal? this-arg "--help"))
362                   (print-usage progname))
363                  ((or (equal? this-arg "-n")
364                       (equal? this-arg "--no-footer"))
365                   (config-display-footer-set! config #f)
366                   (loop rest-args))
367                  ((or (equal? this-arg "-u")
368                       (equal? this-arg "--user"))
369                   (config-user-set! config (string->number (car rest-args)))
370                   (loop (cdr rest-args)))
371                  ((or (equal? this-arg "-g")
372                       (equal? this-arg "--group"))
373                   (config-group-set! config (string->number (car rest-args)))
374                   (loop (cdr rest-args)))
375                  ((or (equal? this-arg "-b")
376                       (equal? this-arg "--blacklist"))
377                   (config-blacklist-set! config (car rest-args))
378                   (loop (cdr rest-args)))
379                  ((or (equal? this-arg "-r")
380                       (equal? this-arg "--blacklist-resp"))
381                   (config-blacklist-resp-set! config (car rest-args))
382                   (loop (cdr rest-args)))
383                  (else
384                   (print-usage progname)))
385                 (begin
386                   (config-root-dir-set! config (car args))
387                   (config-host-set! config (cadr args))
388                   (if (>= (length rest-args) 2)
389                       (config-port-set! config (string->number (caddr args))))
390                   (run-server config))))))))
391
392 (main)
393
394 ;; (define (test)
395 ;;   (run-server (make-config "gopher-root" "localhost" 70 #t '() '() '() '())))
396
397 ;; (test)