aboutsummaryrefslogtreecommitdiff
path: root/router.lisp
blob: 7a0dcc21b829e557f465d91bead3a3ab612f7dfe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
(in-package #:nx-router)
(nyxt:use-nyxt-package-nicknames)

(-> list-of-lists-p (t) boolean)
(defun list-of-lists-p (object)
  "Return non-nil of OBJECT is a list of lists."
  (and (listp object)
       (every #'listp object)))

(define-class router ()
  ((name
    nil
    :type (or null symbol))
   (route
    nil
    :type (or null string list function)
    :documentation "Route(s) to determine if `router' is to be activated.")
   (instances-builder
    nil
    :type (maybe (list-of instances-builder))
    :documentation "An `instances-builder' object that holds the necessary setup
to build a list of instances for a service provider.  These will be appended to
the router's `route'.")
   (toplevel-p
    t
    :type boolean
    :documentation "Whether `router' should process only top-level requests."))
  (:export-class-name-p t)
  (:export-slot-names-p t)
  (:export-accessor-names-p t)
  (:documentation "Customizable request resource handler for routing."))

(defun maybe-list-of-routers-p (list)
  "Return t if LIST is null or a list of `router' objects."
  (or (null list)
      (and (consp list)
           (every #'router-p list))))

(deftype maybe-list-of-routers ()
  `(satisfies maybe-list-of-routers-p))

(define-class blocker (router)
  ((block-banner-p
    t
    :type boolean
    :documentation "Whether to display a block banner upon route blocking.")
   (blocklist
    nil
    :type (or boolean string list)
    :documentation "A PCRE to match against the current route, `t' to block the
entire route, or a list of regexps to draw the comparison against.  If any
single list is prefixed with `not', the entire route will be blocked except for
the specified regexps.  If all of the lists are prefixed with `or', this follows
an exception-based blocking where you can specify a more general block regexp
first and bypass it for more specific routes."))
  (:export-class-name-p t)
  (:export-slot-names-p t)
  (:export-accessor-names-p t)
  (:metaclass user-class)
  (:documentation "General-purpose `router' to determine what to block."))

(define-class redirector (router)
  ((redirect
    nil
    :type (or null string list quri:uri function symbol)
    :documentation "A string for the hostname of the URL to redirect to, a PCRE
or an alist of redirection rules.  These have the form REDIRECT . ROUTES, where
ROUTES is a list of regexps that will be matched against and redirected to
REDIRECT.  To redirect all routes except ROUTES to REDIRECT, prefix this list
with `not'.")
   (reverse
    nil
    :type (or null string quri:uri)
    :documentation "Original URL of the redirect.  Useful for storage purposes
 (bookmarks, history, etc.) so this is recorded instead of the redirect."))
  (:export-class-name-p t)
  (:export-slot-names-p t)
  (:export-accessor-names-p t)
  (:metaclass user-class)
  (:documentation "General-purpose redirect `router'."))

(define-class opener (router)
  ((resource
    nil
    :type (or null string function symbol)
    :documentation "A resource can be either a function form, in which case it
takes a single parameter URL and can invoke arbitrary Lisp forms with it.
If it's a string form, it runs the specified command via `uiop:run-program' with
the current URL as argument, and can be given in a `format'-like syntax."))
  (:export-class-name-p t)
  (:export-slot-names-p t)
  (:export-accessor-names-p t)
  (:metaclass user-class)
  (:documentation "`router' that instructs resources to be opened externally."))

(define-mode router-mode ()
  "Apply a set of routers on the current browsing session."
  ((routers
    '()
    :type list
    :documentation "List of `router's to be matched against the current buffer.")
   (nyxt:glyph "⚑")))

(defmethod nyxt:enable ((mode router-mode) &key)
  (with-slots (routers buffer) mode
    (setf routers
          (reverse
           (reduce
            (lambda (acc router)
              (when (name router)
                (let ((base (find (name router) acc :key #'name)))
                  (dolist (slot (set-difference
                                 (mopu:slot-names (class-of base))
                                 (mopu:direct-slot-names (class-of base))))
                    (setf (slot-value router slot) (slot-value base slot)))))
              (cons router acc))
            routers
            :initial-value '())))
    (hooks:add-hook (nyxt:request-resource-hook buffer)
                    (make-instance 'hooks:handler
                                   :fn (lambda (request-data)
                                         (router-handler request-data mode))
                                   :name 'handle-routing))))

(defmethod nyxt:disable ((mode router-mode) &key)
  (hooks:remove-hook (nyxt:request-resource-hook (buffer mode))
                     'handle-routing))

(defmethod initialize-instance :after ((router router) &key)
  (with-slots (instances-builder route) router
    (nyxt:run-thread "nx-router build routes"
      (flet ((construct-predicates (sources)
               (mapcar (lambda (instance)
                         `(nyxt:match-host
                           ,(if (quri:uri-http-p (quri:uri instance))
                                (str:join
                                 ""
                                 (str:split-omit-nulls
                                  "/"
                                  (nyxt::schemeless-url (quri:uri instance))))
                                instance)))
                       sources)))
        (alex:when-let ((instances (and instances-builder
                                        (build-instances instances-builder))))
          (cond
            ((list-of-lists-p route)
             (setf (route router)
                   (append route (construct-predicates instances))))
            (t (setf (route router)
                     (cons route (construct-predicates instances))))))))))

(-> match-by-redirect (quri:uri router-mode) maybe-list-of-routers)
(defun match-by-redirect (url mode)
  "Match MODE routers by route redirect against URL."
  (remove-if-not
   (lambda (router)
     (when (and (redirector-p router)
                (with-slots (redirect) router
                  (and redirect
                       (string= (quri:uri-host url)
                                (etypecase redirect
                                  (string redirect)
                                  (quri:uri (quri:uri-host redirect))
                                  (list (get-redirect redirect url))
                                  ((or function symbol)
                                   (funcall redirect)))))))
       router))
   (routers mode)))

(-> match-by-route (quri:uri router-mode) maybe-list-of-routers)
(defun match-by-route (url mode)
  "Match MODE routers by route against URL."
  (flet ((routes-match-p (routes)
           (some (lambda (pred)
                   (typecase pred
                     (string
                      (funcall (nyxt:match-regex pred) url))
                     (list
                      (funcall (eval pred) url))
                     (function
                      (funcall pred url))))
                 routes)))
    (remove-if-not
     (lambda (router)
       (with-slots (route) router
         (cond
           ((stringp route)
            (funcall (nyxt:match-regex route) url))
           ((list-of-lists-p route)
            (routes-match-p route))
           ((listp route)
            (if (instances-builder router)
                (routes-match-p route)
                (funcall (eval route) url)))
           ((functionp route)
            (funcall route url)))))
     (routers mode))))

(export-always 'trace-url)
(-> trace-url (quri:uri) t)
(defun trace-url (url)
  (alex:if-let ((router (find-if (lambda (r)
                                   (redirector-p r))
                                 (match-by-redirect
                                  url
                                  (nyxt:find-submode
                                   (sym:resolve-symbol :router-mode :mode
                                                       '(:nx-router)))))))
    (with-slots (redirect reverse) router
      (cond
        ((and router
              (string= (etypecase redirect
                         (string redirect)
                         (quri:uri (quri:uri-host redirect))
                         (list "")
                         ((or function symbol) (funcall redirect)))
                       (quri:uri-host url)))
         (compute-router router url :reversed t))
        ((and router reverse) (quri:copy-uri url :host reverse))
        (t url)))
    url))

(-> find-url (quri:uri list &key (:key function) (:test function) (:pred function)) t)
(defun find-url (url url-parts &key (key #'quri:render-uri) (test #'ppcre:scan) (pred #'find-if))
  "Test URL-PARTS with PRED against URL by KEY with TEST."
  (funcall pred (lambda (prefix)
                  (funcall test prefix (funcall key url)))
           url-parts))

(-> get-redirect (list quri:uri &key (:reversed boolean)) (or string null))
(defun get-redirect (rules url &key reversed)
  "Compute redirect TARGETS for URL and return the first matching
redirect.  If REVERSED, reverse the redirection."
  (loop for (replacement . targets) in rules
        collect
        (cond
          (reversed
           (alex:when-let ((prefix (find-url url (list replacement))))
             (ppcre:regex-replace
              (cond
                ((and (consp targets)
                      (equal (first targets) 'not))
                 "")
                ((consp targets) (car targets))
                (t targets))
              prefix (quri:render-uri url))))
          ((and (consp targets) (equal (first targets) 'not))
           (unless (find-url url (rest targets))
             (str:concat replacement
                         (str:join "/" (str:split-omit-nulls
                                        "/" (quri:uri-path url))))))
          (t (alex:when-let ((prefix
                              (find-url
                               url
                               (if (consp targets)
                                   targets
                                   (list targets)))))
               (ppcre:regex-replace prefix (quri:render-uri url)
                                    replacement))))
          into paths
        finally (return (car (delete nil paths)))))

(-> get-blocklist (list quri:uri) boolean)
(defun get-blocklist (targets url)
  "Determine whether TARGETS should be blocked according to URL."
  (loop for target in targets
        collect
        (if (and (consp targets) (equal (first targets) 'not))
            (not (find-url url (rest targets) :pred #'every))
            (find-url url (if (consp targets) targets (list targets)) :pred #'every))
          into blocked-results
        finally (return (not (some #'null blocked-results)))))

(defgeneric compute-router (router url &key &allow-other-keys))

(defmethod compute-router ((router redirector) url &key reversed)
  (flet ((build-uri (uri)
           (let ((uri (quri:uri uri)))
             (apply #'quri:make-uri
                    :scheme (or (quri:uri-scheme uri) (quri:uri-scheme url))
                    :host (or (quri:uri-host uri) (quri:uri-host url))
                    :path (or (quri:uri-path uri) (quri:uri-path url))
                    :query (quri:uri-query url)
                    :fragment (quri:uri-fragment url)
                    :userinfo (quri:uri-userinfo url)
                    (alex:if-let ((port (quri:uri-port uri)))
                      (list :port port)
                      '())))))
    (with-slots (reverse redirect route) router
      (cond
        ((stringp route)
         (quri:uri
          (if (ppcre:scan route (render-url url))
              (ppcre:regex-replace
               route (render-url url) (etypecase redirect
                                        (string redirect)
                                        (quri:uri (render-url redirect))))
              url)))
        ((consp redirect)
         (alex:if-let ((redirect-url
                        (get-redirect redirect url
                                      :reversed reversed)))
           (quri:uri redirect-url)
           url))
        (t
         (build-uri
          (if reversed
              (typecase reverse
                (string (quri:make-uri :host reverse))
                (quri:uri reverse))
              (typecase redirect
                (string (quri:make-uri :host redirect))
                (quri:uri redirect)
                ((or function symbol)
                 (quri:uri (funcall redirect)))))))))))

(defmethod compute-router ((router blocker) url &key)
  (with-slots (blocklist) router
    (typecase blocklist
      (string
       (not (null (ppcre:scan blocklist (render-url url)))))
      (list
       (if (equal (first blocklist) 'or)
           (loop for rules in (rest blocklist)
                 collect
                 (get-blocklist (if (consp rules) rules (list rules)) url)
                   into clauses
                 finally (return (not (some #'null clauses))))
           (get-blocklist blocklist url)))
      (otherwise t))))

(defmethod compute-router ((router opener) url &key)
  (with-slots (resource) router
    (let ((url (quri:url-decode (quri:render-uri url))))
      (typecase resource
        (string
         (uiop:run-program (format nil resource url)))
        ((or function symbol)
         (nyxt:run-thread "Spawn external rules"
           (funcall resource url))))))
  nil)

(nyxt::define-internal-page-command-global display-blocked-page (&key url)
    (buffer "*Blocked Site*" 'nyxt:base-mode)
  "Show blocked internal page for URL."
  (let ((style (theme:themed-css (nyxt:theme nyxt:*browser*)
                 `(body
                   :padding 0
                   :margin 0)
                 `(.container
                   :display flex
                   :height 100vh
                   :justify-content center
                   :align-items center
                   :flex-direction column
                   :text-align center)
                 `(|#banner|
                   :display flex
                   :justify-content center
                   :flex-direction column
                   :width 70vw)
                 `(|#url|
                   :text-decoration none
                   :font-weight bold
                   :color ,theme:accent
                   :pointer-events none))))
    (spinneret:with-html-string
      (:style style)
      (:div :class "container"
            (:img :src "https://nyxt.atlas.engineer/image/nyxt_128x128.png")
            (:div :id "banner"
                  (:h1 "The page you're trying to access has been blocked.")
                  (when url
                    (:a :id "url" url)))))))

(defgeneric dispatch-router (request-data router))

(defmethod dispatch-router (request-data (router redirector))
  (let ((url (and request-data (url request-data))))
    (when (and url (or (nyxt:toplevel-p request-data)
                       (not (toplevel-p router))))
      (setf (url request-data) (compute-router router url))))
  request-data)

(defmethod dispatch-router (request-data (router blocker))
  (let ((url (and request-data (url request-data))))
    (if (and url (or (nyxt:toplevel-p request-data) (not (toplevel-p router))))
        (if (compute-router router url)
            (progn
              (and (block-banner-p router)
                   (nyxt:buffer-load
                    (nyxt:nyxt-url 'display-blocked-page
                                   :url (render-url url))
                    :buffer (buffer request-data)))
              nil)
            request-data)
        request-data)))

(defmethod dispatch-router (request-data (router opener))
  (let ((url (and request-data (url request-data))))
    (when (and url (or (nyxt:toplevel-p request-data)
                       (not (toplevel-p router))))
      (compute-router router url))
    (when (nyxt:toplevel-p request-data)
      (nyxt::buffer-delete (buffer request-data)))))

(defmethod router-handler (request-data (mode router-mode))
  (when request-data
    (alex:if-let ((routers (match-by-route (url request-data) mode)))
      (progn
        (when (nyxt:request-resource-hook (buffer mode))
          (dolist (router routers)
            (setf request-data (dispatch-router request-data router))))
        request-data)
      request-data)))