mtx-server.lua /size: 16 Kb    last modification: 2020-07-01 14:35
1if not modules then modules = { } end modules ['mtx-server'] = {
2    version   = 1.001,
3    comment   = "companion to mtxrun.lua",
4    author    = "Hans Hagen & Taco Hoekwater",
5    copyright = "PRAGMA ADE / ConTeXt Development Team",
6    license   = "see context related readme files"
7}
8
9local helpinfo = [[
10<?xml version="1.0"?>
11<application>
12 <metadata>
13  <entry name="name">mtx-server</entry>
14  <entry name="detail">Simple Webserver For Helpers</entry>
15  <entry name="version">0.10</entry>
16 </metadata>
17 <flags>
18  <category name="basic">
19   <subcategory>
20    <flag name="start"><short>start server</short></flag>
21    <flag name="port"><short>port to listen to</short></flag>
22    <flag name="root"><short>server root</short></flag>
23    <flag name="scripts"><short>scripts sub path</short></flag>
24    <flag name="index"><short>index file</short></flag>
25    <flag name="auto"><short>start on own path</short></flag>
26   </subcategory>
27  </category>
28 </flags>
29</application>
30]]
31
32local application = logs.application {
33    name     = "mtx-server",
34    banner   = "Simple Webserver For Helpers 0.10",
35    helpinfo = helpinfo,
36}
37
38local tonumber, tostring, loadfile, type = tonumber, tostring, loadfile, type
39local find, gsub = string.find, string.gsub
40local joinpath, filesuffix, dirname, is_qualified_path = file.join, file.suffix, file.dirname, file.is_qualified_path
41local loaddata = io.loaddata
42local P, C, patterns, lpegmatch = lpeg.P, lpeg.C, lpeg.patterns, lpeg.match
43local formatters = string.formatters
44local urlhashed, urlquery = url.hashed, url.query
45local report = application.report
46local gettime = os.gettimeofday or os.clock
47
48scripts           = scripts           or { }
49scripts.webserver = scripts.webserver or { }
50
51local socket = socket or require("socket")
52----- http   = http   or require("socket.http") -- not needed
53
54-- The following two lists are taken from webrick (ruby) and
55-- extended with a few extra suffixes.
56
57local mimetypes = {
58    ai    = 'application/postscript',
59    asc   = 'text/plain',
60    avi   = 'video/x-msvideo',
61    bin   = 'application/octet-stream',
62    bmp   = 'image/bmp',
63    bz2   = 'application/x-bzip2',
64    cer   = 'application/pkix-cert',
65    class = 'application/octet-stream',
66    crl   = 'application/pkix-crl',
67    crt   = 'application/x-x509-ca-cert',
68    css   = 'text/css',
69    dms   = 'application/octet-stream',
70    doc   = 'application/msword',
71    dvi   = 'application/x-dvi',
72    eps   = 'application/postscript',
73    etx   = 'text/x-setext',
74    exe   = 'application/octet-stream',
75    gif   = 'image/gif',
76    gz    = 'application/x-tar',
77    hqx   = 'application/mac-binhex40',
78    htm   = 'text/html',
79    html  = 'text/html',
80    jpe   = 'image/jpeg',
81    jpeg  = 'image/jpeg',
82    jpg   = 'image/jpeg',
83    lha   = 'application/octet-stream',
84    lzh   = 'application/octet-stream',
85    mov   = 'video/quicktime',
86    mpe   = 'video/mpeg',
87    mpeg  = 'video/mpeg',
88    mpg   = 'video/mpeg',
89    pbm   = 'image/x-portable-bitmap',
90    pdf   = 'application/pdf',
91    pgm   = 'image/x-portable-graymap',
92    png   = 'image/png',
93    pnm   = 'image/x-portable-anymap',
94    ppm   = 'image/x-portable-pixmap',
95    ppt   = 'application/vnd.ms-powerpoint',
96    ps    = 'application/postscript',
97    qt    = 'video/quicktime',
98    ras   = 'image/x-cmu-raster',
99    rb    = 'text/plain',
100    rd    = 'text/plain',
101    rgb   = 'image/x-rgb',
102    rtf   = 'application/rtf',
103    sgm   = 'text/sgml',
104    sgml  = 'text/sgml',
105    snd   = 'audio/basic',
106    tar   = 'application/x-tar',
107    tgz   = 'application/x-tar',
108    tif   = 'image/tiff',
109    tiff  = 'image/tiff',
110    txt   = 'text/plain',
111    xbm   = 'image/x-xbitmap',
112    xls   = 'application/vnd.ms-excel',
113    xml   = 'text/xml',
114    xpm   = 'image/x-xpixmap',
115    xwd   = 'image/x-xwindowdump',
116    zip   = 'application/zip',
117}
118
119local messages = {
120    [100] = 'Continue',
121    [101] = 'Switching Protocols',
122    [200] = 'OK',
123    [201] = 'Created',
124    [202] = 'Accepted',
125    [203] = 'Non-Authoritative Information',
126    [204] = 'No Content',
127    [205] = 'Reset Content',
128    [206] = 'Partial Content',
129    [300] = 'Multiple Choices',
130    [301] = 'Moved Permanently',
131    [302] = 'Found',
132    [303] = 'See Other',
133    [304] = 'Not Modified',
134    [305] = 'Use Proxy',
135    [307] = 'Temporary Redirect',
136    [400] = 'Bad Request',
137    [401] = 'Unauthorized',
138    [402] = 'Payment Required',
139    [403] = 'Forbidden',
140    [404] = 'Not Found',
141    [405] = 'Method Not Allowed',
142    [406] = 'Not Acceptable',
143    [407] = 'Proxy Authentication Required',
144    [408] = 'Request Timeout',
145    [409] = 'Conflict',
146    [410] = 'Gone',
147    [411] = 'Length Required',
148    [412] = 'Precondition Failed',
149    [413] = 'Request Entity Too Large',
150    [414] = 'Request-URI Too Large',
151    [415] = 'Unsupported Media Type',
152    [416] = 'Request Range Not Satisfiable',
153    [417] = 'Expectation Failed',
154    [500] = 'Internal Server Error',
155    [501] = 'Not Implemented',
156    [502] = 'Bad Gateway',
157    [503] = 'Service Unavailable',
158    [504] = 'Gateway Timeout',
159    [505] = 'HTTP Version Not Supported',
160}
161
162local f_content_length = formatters["Content-Length: %s\r\n"]
163local f_content_type   = formatters["Content-Type: %s\r\n"]
164local f_error_title    = formatters["<head><title>%s %s</title></head><html><h2>%s %s</h2></html>"]
165
166local handlers = { }
167
168local function errormessage(client,configuration,n)
169    local data = f_error_title(n,messages[n],n,messages[n])
170    report("handling error %s: %s",n,messages[n])
171    handlers.generic(client,configuration,data,nil,true)
172end
173
174local validpaths, registered = { }, { }
175
176function scripts.webserver.registerpath(name)
177    if not registered[name] then
178        local cleanname = gsub(name,"%.%.","deleted-parent")
179        report("registering path: %s",cleanname)
180        validpaths[#validpaths+1] = cleanname
181        registered[name] = true
182    end
183end
184
185function handlers.generic(client,configuration,data,suffix,iscontent)
186    local name = data
187    if not iscontent then
188        report("requested file: %s",name)
189        local fullname = joinpath(configuration.root,name)
190        data = loaddata(fullname) or ""
191        if data == "" then
192            for n=1,#validpaths do
193                local fullname = joinpath(validpaths[n],name)
194                data = loaddata(fullname) or ""
195                if data ~= "" then
196                    report("sending generic file: %s",fullname)
197                    break
198                end
199            end
200        else
201            report("sending generic file: %s",fullname)
202        end
203    end
204    if data and data ~= "" then
205        client:send("HTTP/1.1 200 OK\r\n")
206        client:send("Connection: close\r\n")
207        client:send(f_content_length(#data))
208        client:send(f_content_type(suffix and mimetypes[suffix] or "text/html"))
209        client:send("Cache-Control: no-cache, no-store, must-revalidate, max-age=0\r\n")
210        client:send("\r\n")
211        client:send(data)
212        client:send("\r\n")
213    else
214        report("unknown file: %s",tostring(name))
215        errormessage(client,configuration,404)
216    end
217end
218
219-- return os.date()
220
221-- return { content = "crap" }
222
223-- return function(configuration,filename)
224--     return { content = filename }
225-- end
226
227local loaded = { }
228
229function handlers.lua(client,configuration,filename,suffix,iscontent,hashed) -- filename will disappear, and become hashed.filename
230    local filename = joinpath(configuration.scripts,filename)
231    if not is_qualified_path(filename) then
232        filename = joinpath(configuration.root,filename)
233    end
234    -- todo: split url in components, see l-url; rather trivial
235    local result, keep = loaded[filename], false
236    if result then
237        report("reusing script: %s",filename)
238    else
239        report("locating script: %s",filename)
240        if lfs.isfile(filename) then
241            report("loading script: %s",filename)
242            result = loadfile(filename)
243            report("return type: %s",type(result))
244            if result and type(result) == "function" then
245             -- result() should return a table { [type=,] [length=,] content= }, function or string
246                result, keep = result()
247                if keep then
248                    report("saving script: %s",type(result))
249                    loaded[filename] = result
250                end
251            end
252        else
253            report("problematic script: %s",filename)
254            errormessage(client,configuration,404)
255        end
256    end
257    if result then
258        if type(result) == "function" then
259            report("running script: %s",filename)
260            result = result(configuration,filename,hashed) -- second argument will become query
261        end
262        if result and type(result) == "string" then
263            result = { content = result }
264        end
265        if result and type(result) == "table" then
266            if result.content then
267                local suffix = result.type or "text/html"
268                local action = handlers[suffix] or handlers.generic
269                action(client,configuration,result.content,suffix,true) -- content
270            elseif result.filename then
271                local suffix = filesuffix(result.filename) or "text/html"
272                local action = handlers[suffix] or handlers.generic
273                action(client,configuration,result.filename,suffix,false) -- filename
274            else
275                report("no content of filename in result")
276                errormessage(client,configuration,404)
277            end
278        else
279            report("no valid result")
280            errormessage(client,configuration,500)
281        end
282    else
283        report("no result")
284        errormessage(client,configuration,404)
285    end
286end
287
288handlers.luc  = handlers.lua
289handlers.html = handlers.htm
290
291local indices    = { "index.htm", "index.html" }
292local portnumber = 8088
293
294local newline    = patterns.newline
295local spacer     = patterns.spacer
296local whitespace = patterns.whitespace
297local method     = P("GET")
298                 + P("POST")
299local identify   = (1-method)^0
300                 * C(method)
301                 * spacer^1
302                 * C((1-spacer)^1)
303                 * spacer^1
304                 * P("HTTP/")
305                 * (1-whitespace)^0
306                 * C(P(1)^0)
307
308function scripts.webserver.run(configuration)
309    -- check configuration
310    configuration.port = tonumber(configuration.port or os.getenv("MTX_SERVER_PORT") or portnumber) or portnumber
311    if not configuration.root or not lfs.isdir(configuration.root) then
312        configuration.root = os.getenv("MTX_SERVER_ROOT") or "."
313    end
314    -- locate root and index file in tex tree
315    if not lfs.isdir(configuration.root) then
316        for i=1,#indices do
317            local name = indices[i]
318            local root = resolvers.resolve("path:" .. name) or ""
319            if root ~= "" then
320                configuration.root = root
321                configuration.index = configuration.index or name
322                break
323            end
324        end
325    end
326    configuration.root = dir.expandname(configuration.root)
327    if not configuration.index then
328        for i=1,#indices do
329            local name = indices[i]
330            if lfs.isfile(joinpath(configuration.root,name)) then
331                configuration.index = name -- we will prepend the rootpath later
332                break
333            end
334        end
335        configuration.index = configuration.index or "unknown"
336    end
337    if not configuration.scripts or configuration.scripts == "" then
338        configuration.scripts = dir.expandname(joinpath(configuration.root or ".",configuration.scripts or "."))
339    end
340    -- so far for checks
341    report("running at port: %s",configuration.port)
342    report("document root: %s",configuration.root or resolvers.ownpath)
343    report("main index file: %s",configuration.index)
344    report("scripts subpath: %s",configuration.scripts)
345    report("context services: http://localhost:%s/mtx-server-ctx-startup.lua",configuration.port)
346    local server = assert(socket.bind("*", configuration.port))
347    local script = configuration.script
348    while true do -- blocking
349     -- local start = gettime()
350        local client = server:accept()
351        client:settimeout(configuration.timeout or 60)
352        local request, e = client:receive()
353        if e then
354            -- probably a time out
355         -- errormessage(client,configuration,404)
356        else
357            local from = client:getpeername()
358            report("request from: %s",tostring(from))
359            report("request data: %s",tostring(request))
360         -- local fullurl = match(request,"(GET) (.+) HTTP/.*$") or "" -- todo: more clever / post
361         -- if fullurl == "" then
362            local method, fullurl, body = lpegmatch(identify,request)
363            if method == "" or fullurl == "" then
364                report("no url")
365                errormessage(client,configuration,404)
366            else
367
368                -- todo: method: POST
369
370                fullurl = url.unescapeget(fullurl)
371                report("requested url: %s",fullurl)
372             -- fullurl = socket.url.unescape(fullurl) -- happens later
373                local hashed = urlhashed(fullurl)
374                local query = urlquery(hashed.query)
375                local filename = hashed.path -- hm, not query?
376                hashed.body = body
377                if script then
378                    filename = script
379                    report("forced script: %s",filename)
380                    local suffix = filesuffix(filename)
381                    local action = handlers[suffix] or handlers.generic
382                    if action then
383                        report("performing action: %s",filename)
384                        action(client,configuration,filename,suffix,false,hashed) -- filename and no content
385                    else
386                        report("invalid action: %s",filename)
387                        errormessage(client,configuration,404)
388                    end
389                elseif filename then
390                    local rawname = socket.url.unescape(filename)
391                    filename = rawname
392                    report("requested action: %s",filename or "?")
393                    if find(filename,"%.%.") then
394                        filename = nil -- invalid path
395                    end
396                    if filename == nil or filename == "" or filename == "/" then
397                        filename = configuration.index
398                        report("invalid filename, forcing: %s",filename)
399                    end
400                    local suffix = filesuffix(filename)
401                    local action = handlers[suffix] or handlers.generic
402                    if action then
403                        report("performing action: %s",filename or "?")
404                        action(client,configuration,filename,suffix,false,hashed) -- filename and no content
405                    else
406                        report("invalid action: %s",filename or "?")
407                        errormessage(client,configuration,404)
408                    end
409                else
410                    report("invalid request")
411                    errormessage(client,configuration,404)
412                end
413            end
414        end
415        client:close()
416     -- report("time spent with client: %0.03f seconds",gettime()-start)
417    end
418end
419
420if environment.argument("auto") then
421    local path = resolvers.findfile("mtx-server.lua") or "."
422    scripts.webserver.run {
423        port    = environment.argument("port"),
424        root    = environment.argument("root") or dirname(path) or ".",
425        scripts = environment.argument("scripts") or dirname(path) or ".",
426        script  = environment.argument("script"),
427    }
428elseif environment.argument("start") then
429    scripts.webserver.run {
430        port    = environment.argument("port"),
431        root    = environment.argument("root") or ".",           -- "e:/websites/www.pragma-ade.com",
432        index   = environment.argument("index"),
433        scripts = environment.argument("scripts"),
434        script  = environment.argument("script"),
435    }
436elseif environment.argument("exporthelp") then
437    application.export(environment.argument("exporthelp"),environment.files[1])
438else
439    application.help()
440end
441
442-- mtxrun --script server --start => http://localhost:8088/mtx-server-ctx-startup.lua
443