mtx-server.lua /size: 16 Kb    last modification: 2024-01-16 09:02
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
290handlers.pdf  = handlers.generic
291
292local indices    = { "index.htm", "index.html" }
293local portnumber = 8088
294
295local newline    = patterns.newline
296local spacer     = patterns.spacer
297local whitespace = patterns.whitespace
298local method     = P("GET")
299                 + P("POST")
300local identify   = (1-method)^0
301                 * C(method)
302                 * spacer^1
303                 * C((1-spacer)^1)
304                 * spacer^1
305                 * P("HTTP/")
306                 * (1-whitespace)^0
307                 * C(P(1)^0)
308
309function scripts.webserver.run(configuration)
310    -- check configuration
311    configuration.port = tonumber(configuration.port or os.getenv("MTX_SERVER_PORT") or portnumber) or portnumber
312    if not configuration.root or not lfs.isdir(configuration.root) then
313        configuration.root = os.getenv("MTX_SERVER_ROOT") or "."
314    end
315    -- locate root and index file in tex tree
316    if not lfs.isdir(configuration.root) then
317        for i=1,#indices do
318            local name = indices[i]
319            local root = resolvers.resolve("path:" .. name) or ""
320            if root ~= "" then
321                configuration.root = root
322                configuration.index = configuration.index or name
323                break
324            end
325        end
326    end
327    configuration.root = dir.expandname(configuration.root)
328    if not configuration.index then
329        for i=1,#indices do
330            local name = indices[i]
331            if lfs.isfile(joinpath(configuration.root,name)) then
332                configuration.index = name -- we will prepend the rootpath later
333                break
334            end
335        end
336        configuration.index = configuration.index or "unknown"
337    end
338    if not configuration.scripts or configuration.scripts == "" then
339        configuration.scripts = dir.expandname(joinpath(configuration.root or ".",configuration.scripts or "."))
340    end
341    -- so far for checks
342    report("running at port: %s",configuration.port)
343    report("document root: %s",configuration.root or resolvers.ownpath)
344    report("main index file: %s",configuration.index)
345    report("scripts subpath: %s",configuration.scripts)
346    report("context services: http://localhost:%s/mtx-server-ctx-startup.lua",configuration.port)
347    local server = assert(socket.bind("*", configuration.port))
348    local script = configuration.script
349    while true do -- blocking
350     -- local start = gettime()
351        local client = server:accept()
352        client:settimeout(configuration.timeout or 60)
353        local request, e = client:receive()
354        if e then
355            -- probably a time out
356         -- errormessage(client,configuration,404)
357        else
358            local from = client:getpeername()
359            report("request from: %s",tostring(from))
360            report("request data: %s",tostring(request))
361         -- local fullurl = match(request,"(GET) (.+) HTTP/.*$") or "" -- todo: more clever / post
362         -- if fullurl == "" then
363            local method, fullurl, body = lpegmatch(identify,request)
364            if method == "" or fullurl == "" then
365                report("no url")
366                errormessage(client,configuration,404)
367            else
368
369                -- todo: method: POST
370
371                fullurl = url.unescapeget(fullurl)
372                report("requested url: %s",fullurl)
373             -- fullurl = socket.url.unescape(fullurl) -- happens later
374                local hashed = urlhashed(fullurl)
375                local query = urlquery(hashed.query)
376                local filename = hashed.path -- hm, not query?
377                hashed.body = body
378                if script then
379                    filename = script
380                    report("forced script: %s",filename)
381                    local suffix = filesuffix(filename)
382                    local action = handlers[suffix] or handlers.generic
383                    if action then
384                        report("performing action: %s",filename)
385                        action(client,configuration,filename,suffix,false,hashed) -- filename and no content
386                    else
387                        report("invalid action: %s",filename)
388                        errormessage(client,configuration,404)
389                    end
390                elseif filename then
391                    local rawname = socket.url.unescape(filename)
392                    filename = rawname
393                    report("requested action: %s",filename or "?")
394                    if find(filename,"%.%.") then
395                        filename = nil -- invalid path
396                    end
397                    if filename == nil or filename == "" or filename == "/" then
398                        filename = configuration.index
399                        report("invalid filename, forcing: %s",filename)
400                    end
401                    local suffix = filesuffix(filename)
402                    local action = handlers[suffix] or handlers.generic
403                    if action then
404                        report("performing action: %s",filename or "?")
405                        action(client,configuration,filename,suffix,false,hashed) -- filename and no content
406                    else
407                        report("invalid action: %s",filename or "?")
408                        errormessage(client,configuration,404)
409                    end
410                else
411                    report("invalid request")
412                    errormessage(client,configuration,404)
413                end
414            end
415        end
416        client:close()
417     -- report("time spent with client: %0.03f seconds",gettime()-start)
418    end
419end
420
421if environment.argument("auto") then
422    local path = resolvers.findfile("mtx-server.lua") or "."
423    scripts.webserver.run {
424        port    = environment.argument("port"),
425        root    = environment.argument("root") or dirname(path) or ".",
426        scripts = environment.argument("scripts") or dirname(path) or ".",
427        script  = environment.argument("script"),
428    }
429elseif environment.argument("start") then
430    scripts.webserver.run {
431        port    = environment.argument("port"),
432        root    = environment.argument("root") or ".",           -- "e:/websites/www.pragma-ade.com",
433        index   = environment.argument("index"),
434        scripts = environment.argument("scripts"),
435        script  = environment.argument("script"),
436    }
437elseif environment.argument("exporthelp") then
438    application.export(environment.argument("exporthelp"),environment.files[1])
439else
440    application.help()
441end
442
443-- mtxrun --script server --start => http://localhost:8088/mtx-server-ctx-startup.lua
444