forked from pkulchenko/fullmoon
-
Notifications
You must be signed in to change notification settings - Fork 0
/
fullmoon.lua
1652 lines (1513 loc) · 69.2 KB
/
fullmoon.lua
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
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--
-- ultralight webframework for [Redbean web server](https://redbean.dev/)
-- Copyright 2021 Paul Kulchenko
--
local NAME, VERSION = "fullmoon", "0.28"
--[[-- support functions --]]--
local unpack = table.unpack or unpack
local load = load or loadstring
if not setfenv then -- Lua 5.2+; this assumes f is a function
-- based on http://lua-users.org/lists/lua-l/2010-06/msg00314.html
-- and https://leafo.net/guides/setfenv-in-lua52-and-above.html
local function findenv(f)
local idx = 1
repeat
local name, value = debug.getupvalue(f, idx)
if name == '_ENV' then return idx, value end
idx = idx + 1
until not name
end
getfenv = function (f) return(select(2, findenv(f)) or _G) end
setfenv = function (f, t)
local level = findenv(f)
if level then debug.upvaluejoin(f, level, function() return t end, 1) end
return f
end
end
local function loadsafe(data)
local f, err = load(data)
if not f then return f, err end
local c = -2
local hf, hm, hc = debug.gethook()
debug.sethook(function(a) c=c+1; if c>0 then error("failed safety check") end end, "c")
local ok, res = pcall(f)
c = -1
debug.sethook(hf, hm, hc)
return ok, res
end
local function argerror(cond, narg, extramsg, name)
name = name or debug.getinfo(2, "n").name or "?"
local msg = ("bad argument #%d to %s%s"):format(narg, name, extramsg and " "..extramsg or "")
if not cond then error(msg, 3) end
return cond, msg
end
local function logFormat(fmt, ...)
argerror(type(fmt) == "string", 1, "(string expected)")
return "(fm) "..(select('#', ...) == 0 and fmt or (fmt or ""):format(...))
end
local function getRBVersion()
local v = GetRedbeanVersion()
local major = math.floor(v / 2^16)
local minor = math.floor((v / 2^16 - major) * 2^8)
return ("%d.%d.%d"):format(major, minor, v % 2^8)
end
local LogVerbose = function(...) return Log(kLogVerbose, logFormat(...)) end
local LogInfo = function(...) return Log(kLogInfo, logFormat(...)) end
local LogWarn = function(...) return Log(kLogWarn, logFormat(...)) end
local istype = function(b)
return function(mode) return math.floor((mode % (2*b)) / b) == 1 end end
local isdirectory = istype(2^14)
local isregfile = istype(2^15)
-- headers that are not allowed to be set, as Redbean may
-- alo set them, leading to conflicts and improper handling
local noHeaderMap = {
["content-length"] = true,
["transfer-encoding"] = true,
["content-encoding"] = true,
date = true,
connection = "close",
}
-- request headers based on https://datatracker.ietf.org/doc/html/rfc7231#section-5
-- response headers based on https://datatracker.ietf.org/doc/html/rfc7231#section-7
-- this allows the user to use `.ContentType` instead of `["Content-Type"]`
-- Host is listed to allow retrieving Host header even in the presence of host attribute
local headerMap = {}
(function(s) for h in s:gmatch("[%w%-]+") do headerMap[h:gsub("-","")] = h end end)([[
Cache-Control Host Max-Forwards Proxy-Authorization User-Agent
Accept-Charset Accept-Encoding Accept-Language Content-Disposition
If-Match If-None-Match If-Modified-Since If-Unmodified-Since If-Range
Content-Type Content-Encoding Content-Language Content-Location
Retry-After Last-Modified WWW-Authenticate Proxy-Authenticate Accept-Ranges
Content-Length Transfer-Encoding
]])
local htmlVoidTags = {} -- from https://html.spec.whatwg.org/#void-elements
(function(s) for h in s:gmatch("%w+") do htmlVoidTags[h] = true end end)([[
area base br col embed hr img input link meta param source track wbr
]])
local default500 = [[<!doctype html><title>{%& status %} {%& reason %}</title>
<h1>{%& status %} {%& reason %}</h1>
{% if message then %}<pre>{%& message %}</pre>{% end %}]]
--[[-- route path generation --]]--
local PARAM = "([:*])([%w_]*)"
local routes = {}
local function makePath(name, params)
argerror(type(name) == "string", 1, "(string expected)")
params = params or {}
-- name can be the name or the route itself (even not registered)
local pos = routes[name]
local route = pos and routes[pos].route or name
-- replace :foo and *splat with provided parameters
route = route:gsub(PARAM.."([^(*:]*)", function(sigil, param, rest)
if sigil == "*" and param == "" then param = "splat" end
-- ignore everything that doesn't match `:%w` pattern
if sigil == ":" and param == "" then return sigil..param..rest end
-- if the parameter value is `false`, replace it with an empty string
return ((params[param] or (params[param] == false and "" or sigil..param))
..rest:gsub("^%b[]",""))
end)
-- remove all optional groups
local function findopt(route)
return route:gsub("(%b())", function(optroute)
optroute = optroute:sub(2, -2)
local s = optroute:find("[:*]")
if s then
local p = optroute:find("%b()")
if not p or s < p then return "" end
end
return findopt(optroute)
end)
end
route = findopt(route)
local param = route:match(":(%a[%w_]*)") or route:match("*([%w_]*)")
argerror(not param, 2, "(missing required parameter "
..(param and #param > 0 and param or "splat")..")")
return route
end
local function makeUrl(url, opts)
if type(url) == "table" and opts == nil then url, opts = nil, url end
if not url then url = GetUrl() end
if not opts then opts = {} end
argerror(type(url) == "string", 1, "(string expected)")
argerror(type(opts) == "table", 2, "(table expected)")
-- check if params are in the hash table format and
-- convert to the array format that Redbean expects
if opts.params and not opts.params[1] and next(opts.params) then
local tbl = {}
for k, v in pairs(opts.params) do
table.insert(tbl, v == true and {k} or {k, v})
end
table.sort(tbl, function(a, b) return a[1] < b[1] end)
opts.params = tbl
end
local parts = ParseUrl(url)
-- copy options, but remove those that have `false` values
for k, v in pairs(opts) do parts[k] = v or nil end
return EncodeUrl(parts)
end
local ref = {} -- some unique key value
-- request functions (`request.write()`)
local reqenv = { write = Write,
escapeHtml = EscapeHtml, escapePath = EscapePath,
formatIp = FormatIp, formatHttpDateTime = FormatHttpDateTime,
makePath = makePath, makeUrl = makeUrl, }
-- request properties (`request.authority`)
local reqapi = { authority = function()
local parts = ParseUrl(GetUrl())
return EncodeUrl({scheme = parts.scheme, host = parts.host, port = parts.port})
end, }
local function genEnv(opt)
opt = opt or {}
return function(t, key)
local val = reqenv[key] or rawget(t, ref) and rawget(t, ref)[key]
-- can cache the value, since it's not passed as a parameter
local cancache = val == nil
if val == nil then val = _G[key] end
if opt.request and val == nil and type(key) == "string" then
local func = reqapi[key] or _G["Get"..key:sub(1,1):upper()..key:sub(2)]
-- map a property (like `.host`) to a function call (`.GetHost()`)
if type(func) == "function" then val = func() else val = func end
end
-- allow pseudo-tags, but only if used in a template environment;
-- provide fallback for `table` to make `table{}` and `table.concat` work
local istable = key == "table"
if opt.autotag and (val == nil or istable) then
-- nothing was resolved; this is either undefined value or
-- a pseudo-tag (like `div{}` or `span{}`), so add support for them
val = setmetatable({key}, {
-- support the case of printing/concatenating undefined values
-- tostring handles conversion to a string
__tostring = function() return "" end,
-- concat handles contatenation with a string
__concat = function(a, b) return a end,
__index = (istable and table or nil),
__call = function(t, v, ...)
if type(v) == "table" then
table.insert(v, 1, key)
return v
end
return {t[1], v, ...}
end})
elseif cancache then
t[key] = val -- cache the calculated value for future use
end
return val
end
end
local tmplTagHandlerEnv = {__index = genEnv({autotag = true}) }
local tmplRegHandlerEnv = {__index = genEnv() }
local tmplReqHandlerEnv = {__index = genEnv({request = true}) }
local req
local function getRequest() return req end
local function detectType(s)
local ch = s:match("^%s*(%S)")
return ch and (ch == "<" and "text/html" or ch == "{" and "application/json") or "text/plain"
end
local function serveResponse(status, headers, body)
-- since headers is optional, handle the case when headers are not present
if type(headers) == "string" and body == nil then
body, headers = headers, nil
end
if type(status) == "string" and body == nil and headers == nil then
body, status = status, 200
end
argerror(type(status) == "number", 1, "(number expected)")
argerror(not headers or type(headers) == "table", 2, "(table expected)")
argerror(not body or type(body) == "string", 3, "(string expected)")
return function()
SetStatus(status)
if headers then
-- make sure that the metatable gets transferred as well
local r = getRequest()
r.headers = setmetatable(headers, getmetatable(r.headers))
end
if body then Write(body) end
return true
end
end
--[[-- template engine --]]--
local templates = {}
local function render(name, opt)
argerror(type(name) == "string", 1, "(string expected)")
argerror(templates[name], 1, "(unknown template name '"..tostring(name).."')")
argerror(not opt or type(opt) == "table", 2, "(table expected)")
local params = {}
local env = getfenv(templates[name].handler)
-- add "original" template parameters
for k, v in pairs(rawget(env, ref) or {}) do params[k] = v end
-- add "passed" template parameters
for k, v in pairs(opt or {}) do params[k] = v end
Log(kLogVerbose, logFormat("render template '%s'", name))
-- return template results or an empty string to indicate completion
-- this is useful when the template does direct write to the output buffer
local refcopy = env[ref]
env[ref] = params
local res, more = templates[name].handler(opt)
env[ref] = refcopy
return res or "", more or templates[name].ContentType
end
local function setTemplate(name, code, opt)
-- name as a table designates a list of prefixes for assets paths
-- to load templates from;
-- its hash values provide mapping from extensions to template types
if type(name) == "table" then
for _, prefix in ipairs(name) do
local paths = GetZipPaths(prefix)
for _, path in ipairs(paths) do
local tmplname, ext = path:gsub("^"..prefix.."/?",""):match("(.+)%.(%w+)$")
if ext and name[ext] then
setTemplate(tmplname, {type = name[ext],
LoadAsset(path) or error("Can't load asset: "..path)})
end
end
end
return
end
argerror(type(name) == "string", 1, "(string or table expected)")
local params = {}
if type(code) == "table" then params, code = code, table.remove(code, 1) end
local ctype = type(code)
argerror(ctype == "string" or ctype == "function", 2, "(string, table or function expected)")
LogVerbose("set template '%s'", name)
local tmpl = templates[params.type or "fmt"]
if ctype == "string" then
argerror(tmpl ~= nil, 2, "(unknown template type/name)")
argerror(tmpl.parser ~= nil, 2, "(referenced template doesn't have a parser)")
code = assert(load(tmpl.parser(code), code))
end
local env = setmetatable({render = render, [ref] = opt},
-- get the metatable from the template that this one is based on,
-- to make sure the correct environment is being served
tmpl and getmetatable(getfenv(tmpl.handler)) or
(opt or {}).autotag and tmplTagHandlerEnv or tmplRegHandlerEnv)
params.handler = setfenv(code, env)
templates[name] = params
end
--[[-- routing engine --]]--
local setmap = {}
(function(s) for pat, reg in s:gmatch("(%S+)=([^%s,]+),?") do setmap[pat] = reg end end)([[
d=0-9, ]=[.].], -=[.-.], a=[:alpha:], l=[:lower:], u=[:upper:], w=[:alnum:], x=[:xdigit:],
]])
local function findset(s)
return setmap[s] or s:match("%p") and s or error("Invalid escape sequence %"..s)
end
local function route2regex(route)
-- foo/bar, foo/*, foo/:bar, foo/:bar[%d], foo(/:bar(/:more))(.:ext)
local params = {}
local regex = route:gsub("%)", "%1?") -- update optional groups from () to ()?
:gsub("%.", "\\.") -- escape dots (.)
:gsub(PARAM, function(sigil, param)
if sigil == "*" and param == "" then param = "splat" end
-- ignore everything that doesn't match `:%w` pattern
if sigil == ":" and param == "" then return sigil..param end
table.insert(params, param)
return sigil == "*" and "(.*)" or "([^/]+)"
end)
:gsub("%b[](%+%))(%b[])([^/:*%[]*)", function(sep, pat, rest)
local leftover, more = rest:match("(.-])(.*)")
if leftover then pat = pat..leftover; rest = more end
-- replace Lua character classes with regex ones
return pat:gsub("%%(.)", findset)..sep..rest end)
-- mark optional captures, as they are going to be returned during match
local subnum = 1
local s, e, capture = 0
while true do
s, e, capture = regex:find("%b()([?]?)", s+1)
if not s then break end
if capture > "" then table.insert(params, subnum, false) end
subnum = subnum + 1
end
return "^"..regex.."$", params
end
local function findRoute(route, opts)
for i, r in ipairs(routes) do
local ometh = opts.method
local rmeth = (r.options or {}).method
if route == r.route and
(type(ometh) == "table" and table.concat(ometh, ",") or ometh) ==
(type(rmeth) == "table" and table.concat(rmeth, ",") or rmeth) then
return i
end
end
end
local function setRoute(opts, handler)
local ot = type(opts)
if ot == "string" then
opts = {opts}
elseif ot == "table" then
if #opts == 0 then argerror(false, 1, "(one or more routes expected)") end
else
argerror(false, 1, "(string or table expected)")
end
-- as the handler is optional, allow it to be skipped
local ht = type(handler)
argerror(ht == "function" or ht == "string" or ht == "nil", 2, "(function or string expected)")
if ht == "string" then
-- if `handler` is a string, then turn it into a handler that does
-- internal redirect (to an existing path), but not a directory.
-- This is to avoid failing on a missing directory index.
-- If directory index is still desired, then use `serveIndex()`.
local newroute = handler
handler = function(r)
local path = r.makePath(newroute, r.params)
local mode = GetAssetMode(path)
return mode and isregfile(mode) and RoutePath(path)
end
end
if ot == "table" then
-- remap filters to hash if presented as an (array) table
for k, v in pairs(opts) do
if type(v) == "table" then
-- {"POST", "PUT"} => {"POST", "PUT", PUT = true, POST = true}
for i = 1, #v do v[v[i]] = true end
-- if GET is allowed, then also allow HEAD, unless `HEAD=false` exists
if k == "method" and v.GET and v.HEAD == nil then
table.insert(v, "HEAD") -- add to the list to generate a proper list of methods
v.HEAD = v.GET
end
if v.regex then v.regex = re.compile(v.regex) or argerror(false, 3, "(valid regex expected)") end
elseif headerMap[k] then
opts[k] = {pattern = "%f[%w]"..v.."%f[%W]"}
end
end
end
-- process 1+ routes as specified
while true do
local route = table.remove(opts, 1)
if not route then break end
argerror(type(route) == "string", 1, "(route string expected)")
local pos = findRoute(route, opts) or #routes+1
if opts.routeName then
if routes[opts.routeName] then LogWarn("route '%s' already registered", opts.routeName) end
routes[opts.routeName], opts.routeName = pos, nil
end
local regex, params = route2regex(route)
local tmethod = type(opts.method)
local methods = tmethod == "table" and opts.method or tmethod == "string" and {opts.method} or {'ANY'}
LogVerbose("set route '%s' (%s) at index %d", route, table.concat(methods,','), pos)
routes[pos] = {route = route, handler = handler, options = opts, comp = re.compile(regex), params = params}
routes[route] = pos
end
end
local function matchCondition(value, cond)
if type(cond) == "function" then return cond(value) end
if type(cond) ~= "table" then return value == cond end
-- allow `{function() end, otherwise = ...}` as well
if type(cond[1]) == "function" then return cond[1](value) end
if value == nil or cond[value] then return true end
if cond.regex then return cond.regex:search(value) ~= nil end
if cond.pattern then return value:match(cond.pattern) ~= nil end
return false
end
local function getAllowedMethod(matchedRoutes)
local methods = {}
for _, idx in ipairs(matchedRoutes) do
local routeMethod = routes[idx].options and routes[idx].options.method
if routeMethod then
for _, method in ipairs(type(routeMethod) == "table" and routeMethod or {routeMethod}) do
if not methods[method] then
methods[method] = true
table.insert(methods, method)
end
end
end
end
table.sort(methods)
return (#methods > 0
and table.concat(methods, ", ")..(methods.OPTIONS == nil and ", OPTIONS" or "")
or "GET, HEAD, POST, PUT, DELETE, OPTIONS")
end
local function matchRoute(path, req)
assert(type(req) == "table", "bad argument #2 to match (table expected)")
LogVerbose("match %d route(s) against '%s'", #routes, path)
local matchedRoutes = {}
for idx, route in ipairs(routes) do
-- skip static routes that are only used for path generation
local opts = route.options
if route.handler or opts and opts.otherwise then
local res = {route.comp:search(path)}
local matched = table.remove(res, 1)
LogVerbose("route '%s' %smatched", route.route, matched and "" or "not ")
if matched then -- path matched
table.insert(matchedRoutes, idx)
for ind, val in ipairs(route.params) do
if val and res[ind] then req.params[val] = res[ind] > "" and res[ind] or false end
end
-- check if there are any additional options to filter by
local otherwise
matched = true
if opts and next(opts) then
for filter, cond in pairs(opts) do
if filter ~= "otherwise" then
local header = headerMap[filter]
-- check "dashed" headers, params, properties (method, port, host, etc.), and then headers again
local value = (header and req.headers[header]
or req.params[filter] or req[filter] or req.headers[filter])
-- condition can be a value (to compare with) or a table/hash with multiple values
if not matchCondition(value, cond) then
otherwise = type(cond) == "table" and cond.otherwise or opts.otherwise
matched = false
Log(kLogVerbose, logFormat("route '%s' filter '%s%s' didn't match value '%s'%s",
route.route, filter, type(cond) == "string" and "="..cond or "",
value, tonumber(otherwise) and " and returned "..otherwise or ""))
break
end
end
end
end
if matched and route.handler then
local res, more = route.handler(req)
if res then return res, more end
else
if otherwise then
if type(otherwise) == "function" then
return otherwise()
else
if otherwise == 405 and not req.headers.Allow then
req.headers.Allow = getAllowedMethod(matchedRoutes)
end
return serveResponse(otherwise)
end
end
end
end
end
end
end
--[[-- filters --]]--
local function makeLastModified(asset)
argerror(type(asset) == "string", 1, "(string expected)")
local lastModified = GetLastModifiedTime(asset)
return {
function(ifModifiedSince)
local isModified = (not ifModifiedSince or
ParseHttpDateTime(ifModifiedSince) < lastModified)
if isModified then
getRequest().headers.LastModified = FormatHttpDateTime(lastModified)
end
return isModified
end,
otherwise = 304, -- serve 304 if not modified
}
end
--[[-- security --]]--
local function makeBasicAuth(authtable, opts)
argerror(type(authtable) == "table", 1, "(table expected)")
argerror(opts == nil or type(opts) == "table", 2, "(table expected)")
opts = opts or {}
local realm = opts.realm and (" Realm=%q"):format(opts.realm) or ""
local hash, key = opts.hash, opts.key
return {
function(authorization)
if not authorization then return false end
local pass, user = GetPass(), GetUser()
return pass and user and authtable[user] == (
hash and GetCryptoHash(hash:upper(), pass, key) or pass)
end,
-- if authentication is not present or fails, return 401
otherwise = serveResponse(401, {WWWAuthenticate = "Basic" .. realm}),
}
end
local function makeIpMatcher(list)
if type(list) == "string" then list = {list} end
argerror(type(list) == "table", 1, "(table or string expected)")
local subnets = {}
for _, ip in ipairs(list) do
local v, neg = ip:gsub("^!","")
local addr, mask = v:match("^(%d+%.%d+%.%d+%.%d+)/(%d+)$")
if not addr then addr, mask = v, 32 end
addr = ParseIp(addr)
argerror(addr ~= -1, 1, ("(invalid IP address %s)"):format(ip))
mask = tonumber(mask)
argerror(mask and mask >= 0 and mask <=32, 1, ("invalid mask in %s"):format(ip))
mask = ~0 << (32 - mask)
-- apply mask to addr in case addr/subnet is not properly aligned
table.insert(subnets, {addr & mask, mask, neg > 0})
end
return function(ip)
if ip == -1 then return false end -- fail the check on invalid IP
for _, v in ipairs(subnets) do
local match = v[1] == (ip & v[2])
if match then return not v[3] end
end
return false
end
end
--[[-- core engine --]]--
local function error2tmpl(status, reason, message)
if not reason then reason = GetHttpReason(status) end
SetStatus(status, reason) -- set status, but allow template handlers to overwrite it
local ok, res = pcall(render, tostring(status),
{status = status, reason = reason, message = message})
return ok and res or ServeError(status, reason) or true
end
local function checkPath(path) return type(path) == "string" and path or GetPath() end
local fm = setmetatable({ _VERSION = VERSION, _NAME = NAME, _COPYRIGHT = "Paul Kulchenko",
setTemplate = setTemplate, setRoute = setRoute,
makePath = makePath, makeUrl = makeUrl,
makeBasicAuth = makeBasicAuth, makeIpMatcher = makeIpMatcher,
makeLastModified = makeLastModified,
getAsset = LoadAsset,
render = render,
-- options
cookieOptions = {HttpOnly = true, SameSite = "Strict"},
sessionOptions = {name = "fullmoon_session", hash = "SHA256", secret = true, format = "lua"},
-- serve* methods that take path can be served as a route handler (with request passed)
-- or as a method called from a route handler (with the path passed);
-- serve index.lua or index.html if available; continue if not
serveIndex = function(path) return function() return ServeIndex(checkPath(path)) end end,
-- handle and serve existing path, including asset, Lua, folder/index, and pre-configured redirect
servePath = function(path) return function() return RoutePath(checkPath(path)) end end,
-- return asset (de/compressed) along with checking for asset range and last/not-modified
serveAsset = function(path) return function() return ServeAsset(checkPath(path)) end end,
serveError = function(status, reason) return function() return error2tmpl(status, reason) end end,
serveContent = function(tmpl, params) return function() return render(tmpl, params) end end,
serveRedirect = function(loc, status) return function()
-- if no status or location is specified, then redirect to the original URL with 303
-- this is useful for switching to GET after POST/PUT to an endpoint
-- in all other cases, use the specified status or 307 (temp redirect)
return ServeRedirect(status or loc and 307 or 303, loc or GetPath()) end end,
serveResponse = serveResponse,
}, {__index =
function(t, key)
local function cache(f) t[key] = f return f end
local method = key:match("^[A-Z][A-Z][A-Z]+$")
if method then return cache(function(route)
if type(route) == "string" then return {route, method = method} end
argerror(type(route) == "table", 1, "(string or table expected)")
route.method = method
return route
end)
end
-- handle serve204 and similar calls
local serveStatus = key:match("^serve(%d%d%d)$")
if serveStatus then return cache(t.serveResponse(tonumber(serveStatus))) end
-- handle logVerbose and other log calls
local kVal = not _G[key] and _G[key:gsub("^l(og%w*)$", function(name) return "kL"..name end)]
if kVal then return cache(function(...) return Log(kVal, logFormat(...)) end) end
-- return upper camel case version if exists
return cache(_G[key] or _G[key:sub(1,1):upper()..key:sub(2)])
end})
local isfresh = {} -- some unique key value
local function deleteCookie(name, copts)
local maxage, MaxAge = copts.maxage, copts.MaxAge
copts.maxage, copts.MaxAge = 0, nil
SetCookie(name, "", copts)
copts.maxage, copts.MaxAge = maxage, MaxAge
end
local function getSessionOptions()
local sopts = fm.sessionOptions or {}
if not sopts.name then error("missing session name") end
if not sopts.hash then error("missing session hash") end
if not sopts.format then error("missing session format") end
-- check for session secret and hash
if sopts.secret and type(sopts.secret) ~= "string" then
error("sessionOptions.secret is expected to be a string")
end
return sopts
end
local function setSession(session)
-- if the session hasn't been touched (read or updated), do nothing
if session and session[isfresh] then return end
local sopts = getSessionOptions()
local cookie
if session and next(session) then
local msg = EncodeBase64(EncodeLua(session))
local sig = EncodeBase64(
GetCryptoHash(sopts.hash, msg, sopts.secret or ""))
cookie = msg.."."..sopts.hash.."."..sopts.format.."."..sig
end
local copts = fm.cookieOptions or {}
if cookie then
SetCookie(sopts.name, cookie, copts)
else
deleteCookie(sopts.name, copts)
end
end
local function getSession()
local sopts = getSessionOptions()
local session = GetCookie(sopts.name)
if not session then return {} end
local msg, hash, format, sig = session:match("(.-)%.(.-)%.(.-)%.(.+)")
if not msg then return {} end
if not pcall(GetCryptoHash, hash, "") then
LogWarn("invalid session crypto hash: "..hash)
return {}
end
if DecodeBase64(sig) ~= GetCryptoHash(hash, msg, sopts.secret) then
LogWarn("invalid session signature: "..sig)
return {}
end
if format ~= "lua" then
LogWarn("invalid session format: "..format)
return {}
end
local ok, val = loadsafe("return "..DecodeBase64(msg))
if not ok then LogWarn("invalid session content: "..val) end
return ok and val or {}
end
local function setHeaders(headers)
for name, value in pairs(headers or {}) do
local val = tostring(value)
if type(value) ~= "string" then
LogWarn("header '%s' is assigned non-string value '%s'", name, val)
end
local hname = headerMap[name] or name
local noheader = noHeaderMap[hname:lower()]
if not noheader or (noheader ~= true and val:lower() ~= noheader) then
SetHeader(hname, val)
else
LogVerbose("header '%s' with value '%s' is skipped to avoid conflict", name, val)
end
end
end
local function setCookies(cookies)
local copts = fm.cookieOptions or {}
for cname, cvalue in pairs(cookies or {}) do
local value, opts = cvalue, copts
if type(cvalue) == "table" then
value, opts = cvalue[1], cvalue
end
if value == false then
deleteCookie(cname, opts)
else
SetCookie(cname, value, opts)
end
end
end
-- call the handler and handle any Lua error by returning Server Error
local function hcall(func, ...)
local co = type(func) == "thread" and func or coroutine.create(func)
local ok, res, more = coroutine.resume(co, ...)
if ok then
return coroutine.status(co) == "suspended" and co or false, res, more
end
local err = debug.traceback(co, res)
Log(kLogError, logFormat("Lua error: %s", err))
return false, error2tmpl(500, nil, IsLoopbackIp(GetRemoteAddr()) and err or nil)
end
local function handleRequest(path)
path = path or GetPath()
req = setmetatable({
params = setmetatable({}, {__index = function(_, k)
if not HasParam(k) then return end
-- GetParam may return `nil` for empty parameters,
-- like `foo` in `foo&bar=1`, but need to return `false` instead
return GetParam(k) or false
end}),
-- check headers table first to allow using `.ContentType` instead of `["Content-Type"]`
headers = setmetatable({}, {__index = function(_, k) return GetHeader(headerMap[k] or k) end}),
cookies = setmetatable({}, {__index = function(_, k) return GetCookie(k) end}),
session = setmetatable({[isfresh] = true}, {
__index = function(t, k)
if t[isfresh] then req.session = getSession() end
return req.session[k]
end,
__newindex = function(t, k, v)
if t[isfresh] then req.session = getSession() end
req.session[k] = v
end,
}),
}, tmplReqHandlerEnv)
SetStatus(200) -- set default status; can be reset later
-- find a match and handle any Lua errors in handlers
local co, res, conttype = hcall(matchRoute, path, req)
-- execute the (deferred) function and handle any errors
while type(res) == "function" do co, res, conttype = hcall(res) end
local tres = type(res)
if res == true then
-- do nothing, as this request was already handled
elseif not res and not co then
-- this request wasn't handled, so report 404
return error2tmpl(404) -- use 404 template if available
elseif tres == "string" then
if #res > 0 then
if not conttype then conttype = detectType(res) end
Write(res) -- output content as is
end
elseif not co then
LogWarn("unexpected result from action handler: '%s' (%s)", tostring(res), tres)
end
-- set the content type returned by the render
if (type(conttype) == "string"
and not rawget(req.headers or {}, "ContentType")) then
req.headers.ContentType = conttype
end
-- set the headers as returned by the render
if type(conttype) == "table" then
if not req.headers then req.headers = {} end
for name, value in pairs(conttype) do req.headers[name] = value end
end
setHeaders(req.headers) -- output specified headers
setCookies(req.cookies) -- output specified cookies
setSession(req.session) -- add a session cookie if needed
while co do
coroutine.yield()
co, res = hcall(co)
-- if the function is returned, which may happen if serve* is used
-- as the last call, then process it to get its result
while type(res) == "function" do co, res = hcall(res) end
if type(res) == "string" then Write(res) end
end
end
local function streamWrap(func)
return function(...) return coroutine.yield(func(...)()) or true end
end
fm.streamResponse = streamWrap(fm.serveResponse)
fm.streamContent = streamWrap(fm.serveContent)
local tests -- forward declaration
local function run(opts)
opts = opts or {}
if opts.tests and tests then tests(); os.exit() end
local brand = ("%s/%s %s/%s"):format("redbean", getRBVersion(), NAME, VERSION)
ProgramBrand(brand)
for key, v in pairs(opts) do
if key == "headers" and type(v) == "table" then
for h, val in pairs(v) do ProgramHeader(headerMap[h] or h, val) end
elseif key:find("Options$") and type(v) == "table" then
-- if *Options is assigned, then overwrite the provided default
if fm[key] then
fm[key] = opts[key]
else -- if there is no default, it's some wrong option
argerror(false, 1, ("(unknown option '%s')"):format(key))
end
else
local func = _G["Program"..key:sub(1,1):upper()..key:sub(2)]
argerror(type(func) == "function", 1, ("(unknown option '%s' with value '%s')"):format(key, v))
for _, val in pairs(type(v) == "table" and v or {v}) do func(val) end
end
end
if GetLogLevel then
local level, none = GetLogLevel(), function() end
if level < kLogWarn then LogWarn = none end
if level < kLogVerbose then LogVerbose = none end
if level < kLogInfo then LogInfo = none end
end
LogInfo("started "..brand)
local sopts = fm.sessionOptions
if sopts.secret == true then
sopts.secret = GetRandomBytes(32)
LogVerbose("applied random session secret; set `fm.sessionOptions.secret`"
..(" to `fm.decodeBase64('%s')` to continue using this value")
:format(EncodeBase64(sopts.secret))
.." or to `false` to disable")
end
-- assign Redbean handler to execute on each request
OnHttpRequest = function() handleRequest(GetPath()) end
end
-- assign the rest of the methods
fm.run = run
Log = Log or function() end
fm.setTemplate("fmt", {
parser = function (tmpl)
local EOT = "\0"
local function writer(s) return #s > 0 and ("write(%q)"):format(s) or "" end
local tupd = (tmpl.."{%"..EOT.."%}"):gsub("(.-){%%([=&]*)%s*(.-)%s*%%}", function(htm, pref, val)
return writer(htm)
..(val ~= EOT -- this is not the suffix
and (pref == "" -- this is a code fragment
and val.." "
or ("write(%s(tostring(%s or '')))")
:format(pref == "&" and "escapeHtml" or "", val))
or "")
end)
return tupd
end,
function() end,
})
fm.setTemplate("500", default500) -- register default 500 status template
fm.setTemplate("json", {ContentType = "application/json",
function(val) return EncodeJson(val, {useoutput = true}) end})
fm.setTemplate("sse", function(val)
argerror(type(val) == "table", 1, "(table expected)")
if #val == 0 then val = {val} end
for _, event in ipairs(val) do
for etype, eval in pairs(event) do
Write(("%s: %s\n"):format(
etype == "comment" and "" or etype,
etype == "data" and eval:gsub("\n", "\ndata: ") or eval
))
end
Write("\n")
end
return "", {
ContentType = "text/event-stream",
CacheControl = "no-store",
["X-Accel-Buffering"] = "no",
}
end)
fm.setTemplate("html", {
parser = function(s)
return ([[return render("html", %s)]]):format(s)
end,
function(val)
argerror(type(val) == "table", 1, "(table expected)")
local function writeAttrs(opt)
for attrname, attrval in pairs(opt) do
if type(attrname) == "string" then
local valtype = type(attrval)
local escape = not(valtype == "table" and attrval[1] == "raw")
if valtype == "table" then
-- this handles `_=raw"some<tag>"`
if #attrval > 1 then
attrval = attrval[2]
else
-- the following turns `tx={post="x", get="y"}`
-- into `["tx-post"]="x", ["tx-get"]="y"`
for k, v in pairs(attrval) do
if type(k) == "string" then
if escape then v = EscapeHtml(v) end
Write((' %s="%s"'):format(attrname.."-"..k, v))
end
end
end
elseif attrval == true then
-- this turns `checked=true` into `checked="checked"`
attrval = attrname
elseif attrval == false then
-- write nothing here
end
if type(attrval) == "string" or type(attrval) == "number" then
if escape then attrval = EscapeHtml(attrval) end
Write((' %s="%s"'):format(attrname, attrval))
end
end
end
end
local function writeVal(opt, escape)
if type(opt) == "function" then opt = opt() end
if type(opt) == "table" then
local tag = opt[1]
argerror(tag ~= nil, 1, "(tag name expected)")
if tag == "include" then return(fm.render(opt[2], opt[3])) end
if tag == "raw" then
for i = 2, #opt do writeVal(opt[i], false) end
return
end
if tag == "each" then
-- rewrite messages to point to `each` function
argerror(type(opt[2]) == "function", 1, "(function expected)", "each")
argerror(type(opt[3]) == "table", 2, "(table expected)", "each")
for _, v in ipairs(opt[3]) do writeVal(opt[2](v), false) end
return
end
if tag:lower() == "doctype" then
Write("<!"..tag.." "..(opt[2] or "html")..">")
return
end
if getmetatable(opt) and not htmlVoidTags[tag:lower()] then
LogWarn("rendering '%s' with `nil` value", tag)
return
end
Write("<"..tag)
writeAttrs(opt)
if htmlVoidTags[tag:lower()] then Write("/>") return end
Write(">")
local escape = tag ~= "script"
for i = 2, #opt do writeVal(opt[i], escape) end
Write("</"..tag..">")
else
local val = tostring(opt or "")
-- escape by default if not requested not to
if escape ~= false then val = EscapeHtml(val) end
Write(val)
end
end
for _, v in pairs(val) do writeVal(v) end
end,
}, {autotag = true})
--[[-- various tests --]]--
tests = function()
local out = ""
reqenv.write = function(s) out = out..s end
Write = reqenv.write
local isRedbean = ProgramBrand ~= nil
if not isRedbean then
re = {compile = function(exp) return {search = function(self, path)
local res = {path:match(exp)}
if #res > 0 then table.insert(res, 1, path) end
return unpack(res)
end}
end}
EscapeHtml = function(s)
return (string.gsub(s, "&", "&"):gsub('"', """):gsub("<","<"):gsub(">",">"):gsub("'","'"))
end
ParseIp = function(str)
local v1, v2, v3, v4 = str:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
return (v1 and (tonumber(v1) << 24) + (tonumber(v2) << 16) + (tonumber(v3) << 8) + tonumber(v4)
or -1) -- match ParseIp logic in redbean
end
reqenv.escapeHtml = EscapeHtml
end
-- provide methods not available outside of Redbean or outside of request handling
SetStatus = function() end
SetHeader = function() end
ServeError = function() end
IsLoopbackIp = function() return true end
GetRemoteAddr = function() end
GetHttpReason = function(status) return tostring(status).." reason" end
Log = function(_, ...) print("#", ...) end
local num, success = 0, 0
local section = ""
local function outformat(s) return type(s) == "string" and ("%q"):format(s):gsub("\n","n") or tostring(s) end
local function is(result, expected, message)
local ok = result == expected
num = num + 1
success = success + (ok and 1 or 0)
local msg = ("%s %d\t%s%s%s"):format((ok and "ok" or "not ok"), num,
(section > "" and section.." " or ""), message or "",
ok and "" or " at line "..debug.getinfo(2).currentline
)
if not ok then
msg = msg .. ("\n\treceived: %s\n\texpected: %s"):format(outformat(result), outformat(expected))
end
print(msg)
out = ""