httpserver.scm
| 1 | ;;; SPDX-FileCopyrightText: 2021 Li Ian-Xue (b4283) <b4283@pm.me> |
| 2 | ;;; SPDX-FileCopyrightText: 2025 Evgeny Pisemsky <mail@pisemsky.site> |
| 3 | ;;; |
| 4 | ;;; SPDX-License-Identifier: GPL-3.0-only |
| 5 | |
| 6 | (define-module (pastebin httpserver)) |
| 7 | |
| 8 | (use-modules (web request) |
| 9 | (web response) |
| 10 | (web uri) |
| 11 | (sxml simple) |
| 12 | (pastebin data) |
| 13 | (rnrs bytevectors) |
| 14 | (ice-9 textual-ports) |
| 15 | (ice-9 binary-ports) |
| 16 | (ice-9 regex) |
| 17 | (ice-9 match) |
| 18 | (srfi srfi-1)) |
| 19 | |
| 20 | (export make-pastebin-handler) |
| 21 | |
| 22 | (define (read-parts reqbody boundary) |
| 23 | (define b2 (string-append "(\r\n)?--" boundary)) |
| 24 | (let A ((start 0) (parts '())) |
| 25 | (let ((sm (string-match b2 reqbody start))) |
| 26 | (if sm |
| 27 | ;; +2 => CRLF |
| 28 | (A (+ 2 (match:end sm 0)) |
| 29 | (cons (substring reqbody start (match:start sm 0)) parts)) |
| 30 | (cdr (reverse parts)))))) |
| 31 | |
| 32 | (define (get-new-pin str pin) |
| 33 | (let ((crlfi (string-contains str "\r\n" pin))) |
| 34 | (if crlfi crlfi (string-length str)))) |
| 35 | |
| 36 | (define (parse-part partstr) |
| 37 | (let A ((headers '()) (pin 0)) |
| 38 | (let* ((newpin (get-new-pin partstr pin)) |
| 39 | (line (substring partstr pin newpin))) |
| 40 | (if (string-null? line) |
| 41 | (cons (reverse headers) (substring partstr (+ 2 newpin))) |
| 42 | (A (cons line headers) (+ 2 newpin)))))) |
| 43 | |
| 44 | (define (get-content-dispo-name-from-headers headers) |
| 45 | (let ((fl (find (lambda (line) (string-prefix-ci? "content-disposition: " line)) headers))) |
| 46 | (if fl |
| 47 | (let ((sm (string-match "name=(.*)" fl))) |
| 48 | (if sm (string-trim-both (match:substring sm 1) #\") "")) |
| 49 | ""))) |
| 50 | |
| 51 | (define (read-multipart-form-data reqbody boundary) |
| 52 | (define parts (read-parts reqbody boundary)) |
| 53 | (map |
| 54 | (lambda (part) |
| 55 | (let ((pp (parse-part part))) |
| 56 | (cons (get-content-dispo-name-from-headers (car pp)) (cdr pp)))) |
| 57 | parts)) |
| 58 | |
| 59 | (define (not-found) |
| 60 | (values (build-response #:code 404) |
| 61 | (lambda (port) 1))) |
| 62 | |
| 63 | (define (templatize title body) |
| 64 | `(html (@ (lang "en")) |
| 65 | (head |
| 66 | (title ,title) |
| 67 | (meta (@ (charset "utf-8"))) |
| 68 | (meta (@ (name "viewport") (content "width=device-width, initial-scale=1.0"))) |
| 69 | (link (@ (rel "stylesheet") (href "https://unpkg.com/simpledotcss/simple.min.css")))) |
| 70 | (body ,@body))) |
| 71 | |
| 72 | (define (list-handler pb-data-path) |
| 73 | (values (build-response |
| 74 | #:code 200 |
| 75 | #:headers '((content-type . (text/html)))) |
| 76 | (lambda (port) |
| 77 | (let* ((top5 (call-with-dir-as-pb-data |
| 78 | pb-data-path |
| 79 | (lambda (pb-data) |
| 80 | (pb-data-get-top pb-data 5)))) |
| 81 | (sxml (templatize |
| 82 | "Pastebin" |
| 83 | `((header (h1 "Pastebin")) |
| 84 | (main (section (h2 "Recent") |
| 85 | ,(map (lambda (entry) |
| 86 | `(article (h3 (a (@ (href ,(format #f "/raw/~a" (pb-entry-id entry))) |
| 87 | (target "_blank")) |
| 88 | ,(pb-entry-id entry))) |
| 89 | (pre ,(pb-entry-text entry)))) |
| 90 | top5)) |
| 91 | (section (h2 "New") |
| 92 | (form (@ (method "post") |
| 93 | (enctype "multipart/form-data") |
| 94 | (action "/post")) |
| 95 | (fieldset (div (label (@ (for "form-text")) |
| 96 | "Paste content")) |
| 97 | (div (textarea (@ (id "form-text") |
| 98 | (name "text")) "")) |
| 99 | (div (label (@ (for "form-showurl")) |
| 100 | (input (@ (type "checkbox") |
| 101 | (id "form-showurl") |
| 102 | (name "showUrl") |
| 103 | (value "1"))) |
| 104 | "Show raw URL after paste")) |
| 105 | (div (input (@ (type "submit") |
| 106 | (value "Submit")))))))) |
| 107 | (footer (p (a (@ (href "https://github.com/pisemsky/guile-pastebin") |
| 108 | (target "_blank")) |
| 109 | "Source"))))))) |
| 110 | (display "<!DOCTYPE html>\n" port) |
| 111 | (sxml->xml sxml port))))) |
| 112 | |
| 113 | (define (post-handler request request-body pb-data-path) |
| 114 | (if (eq? (request-method request) 'POST) |
| 115 | (let* ((headers (request-headers request)) |
| 116 | (content-type-all (assq-ref headers 'content-type)) |
| 117 | (content-type (if content-type-all |
| 118 | (car content-type-all) |
| 119 | #f)) |
| 120 | (boundary (if (eq? content-type 'multipart/form-data) |
| 121 | (assq-ref (cdr content-type-all) 'boundary) |
| 122 | #f)) |
| 123 | (reqbody-string (utf8->string request-body)) |
| 124 | (form-data (if boundary |
| 125 | (read-multipart-form-data reqbody-string boundary) |
| 126 | #f)) |
| 127 | (new-pb-data (if form-data |
| 128 | (call-with-dir-as-pb-data |
| 129 | pb-data-path |
| 130 | (lambda (pb-data) |
| 131 | (pb-data-new-entry pb-data |
| 132 | (assoc-ref form-data "text")))) |
| 133 | #f))) |
| 134 | |
| 135 | ;; determine what to respond |
| 136 | (if (and new-pb-data |
| 137 | (assoc-ref form-data "showUrl")) |
| 138 | |
| 139 | ;; show url after paste |
| 140 | (values (build-response |
| 141 | #:code 200 |
| 142 | #:headers '((content-type . (text/plain)))) |
| 143 | (lambda (port) |
| 144 | (let* ((hostp (assq-ref headers 'host)) |
| 145 | (proto (assq-ref headers 'x-forwarded-proto))) |
| 146 | (put-string |
| 147 | port |
| 148 | (uri->string |
| 149 | (build-uri (if (equal? proto "https") 'https 'http) |
| 150 | #:host (car hostp) |
| 151 | #:port (cdr hostp) |
| 152 | #:path (format #f "/raw/~a\r\n" |
| 153 | (pb-entry-id new-pb-data)))))))) |
| 154 | |
| 155 | ;; respond with 303 See Other |
| 156 | (values (build-response |
| 157 | #:code 303 |
| 158 | #:headers `((location . ,(build-uri-reference #:path "/")))) |
| 159 | (lambda (port) 1)))) |
| 160 | |
| 161 | ;; INVALID request: access /post without HTTP POST |
| 162 | (values (build-response #:code 400) |
| 163 | (lambda (port) 1)))) |
| 164 | |
| 165 | (define (raw-handler pb-data-path pb-id) |
| 166 | (if (pb-entry-id-valid? pb-id) |
| 167 | (values (build-response |
| 168 | #:code 200 |
| 169 | #:headers '((content-type . (text/plain)))) |
| 170 | (lambda (port) |
| 171 | (call-with-input-file |
| 172 | ;; the file name |
| 173 | (call-with-dir-as-pb-data |
| 174 | pb-data-path |
| 175 | (lambda (p) (pb-get-file-path p pb-id))) |
| 176 | ;; the input port |
| 177 | (lambda (inport) |
| 178 | (let A ((inport' inport)) |
| 179 | (let ((bv (get-bytevector-n inport' 4096))) |
| 180 | (if (not (eof-object? bv)) |
| 181 | (begin |
| 182 | (put-bytevector port bv) |
| 183 | (A inport'))))))))) |
| 184 | (not-found))) |
| 185 | |
| 186 | (define (make-pastebin-handler data-path) |
| 187 | (lambda (request request-body) |
| 188 | (match (split-and-decode-uri-path (uri-path (request-uri request))) |
| 189 | |
| 190 | ;; URI: / -- show the top 5 paste list |
| 191 | (() |
| 192 | (list-handler data-path)) |
| 193 | |
| 194 | ;; URI: /post -- create paste |
| 195 | (("post") |
| 196 | (post-handler request request-body data-path)) |
| 197 | |
| 198 | ;; URI: /raw/<id> -- return raw content of the paste |
| 199 | (("raw" pb-id) |
| 200 | (raw-handler data-path pb-id)) |
| 201 | |
| 202 | ;; URI: * -- everything else -- show 404 error |
| 203 | (_ |
| 204 | (not-found))))) |
| 205 |