# A file loader server (?) # The idea here is that we receive requests with a socket, then respond to each # with a response (ideally a document.) # For now, the protocol looks like: # C: Request # S: res (0 => success, _ => error) # if success: # S: status code # S: headers # S: response body # # The body is passed to the stream as-is, so effectively nothing can follow it. import nativesockets import net import options import posix import streams import strutils import tables import bindings/curl import io/posixstream import io/promise import io/serialize import io/serversocket import io/socketstream import io/urlfilter import js/error import js/javascript import loader/about import loader/cgi import loader/connecterror import loader/curlhandle import loader/data import loader/file import loader/ftp import loader/gopher import loader/headers import loader/http import loader/loaderhandle import loader/request import loader/response import types/cookie import types/referer import types/urimethodmap import types/url import utils/mimeguess import utils/twtstr import chakasu/charset export request export response type FileLoader* = ref object process*: Pid connecting*: Table[int, ConnectData] ongoing*: Table[int, OngoingData] unregistered*: seq[int] registerFun*: proc(fd: int) unregisterFun*: proc(fd: int) ConnectData = object promise: Promise[JSResult[Response]] stream: Stream request: Request OngoingData = object buf: string readbufsize: int response: Response bodyRead: Promise[string] LoaderCommand = enum LOAD TEE SUSPEND RESUME ADDREF UNREF SET_REFERRER_POLICY LoaderContext = ref object refcount: int ssock: ServerSocket alive: bool curlm: CURLM config: LoaderConfig extra_fds: seq[curl_waitfd] handleList: seq[CurlHandle] handleMap: Table[int, LoaderHandle] referrerpolicy: ReferrerPolicy LoaderConfig* = object defaultheaders*: Headers filter*: URLFilter cookiejar*: CookieJar proxy*: URL # When set to false, requests with a proxy URL are overridden by the # loader proxy. acceptProxy*: bool cgiDir*: seq[string] uriMethodMap*: URIMethodMap FetchPromise* = Promise[JSResult[Response]] proc addFd(ctx: LoaderContext, fd: int, flags: int) = ctx.extra_fds.add(curl_waitfd( fd: cast[cint](fd), events: cast[cshort](flags) )) const MaxRewrites = 2 # should be enough? TODO find out what w3m thinks proc loadResource(ctx: LoaderContext, request: Request, handle: LoaderHandle) = var redo = true var tries = 0 while redo and tries < MaxRewrites: redo = false case request.url.scheme of "file": handle.loadFilePath(request.url) handle.close() of "http", "https": let handleData = handle.loadHttp(ctx.curlm, request) if handleData != nil: ctx.handleList.add(handleData) of "about": handle.loadAbout(request) handle.close() of "data": handle.loadData(request) handle.close() of "ftp", "ftps", "sftp": let handleData = handle.loadFtp(ctx.curlm, request) if handleData != nil: ctx.handleList.add(handleData) of "gopher", "gophers": let handleData = handle.loadGopher(ctx.curlm, request) if handleData != nil: ctx.handleList.add(handleData) of "cgi-bin": handle.loadCGI(request, ctx.config.cgiDir) handle.close() else: case ctx.config.urimethodmap.findAndRewrite(request.url) of URI_RESULT_SUCCESS: inc tries redo = true of URI_RESULT_WRONG_URL: discard handle.sendResult(ERROR_INVALID_URI_METHOD_ENTRY) handle.close() of URI_RESULT_NOT_FOUND: discard handle.sendResult(ERROR_UNKNOWN_SCHEME) handle.close() if tries >= MaxRewrites: discard handle.sendResult(ERROR_TOO_MANY_REWRITES) handle.close() proc onLoad(ctx: LoaderContext, stream: SocketStream) = var request: Request stream.sread(request) if not ctx.config.filter.match(request.url): stream.swrite(ERROR_DISALLOWED_URL) stream.close() else: let handle = newLoaderHandle(stream, request.canredir) for k, v in ctx.config.defaultHeaders.table: if k notin request.headers.table: request.headers.table[k] = v if ctx.config.cookiejar != nil and ctx.config.cookiejar.cookies.len > 0: if "Cookie" notin request.headers.table: let cookie = ctx.config.cookiejar.serialize(request.url) if cookie != "": request.headers["Cookie"] = cookie if request.referer != nil and "Referer" notin request.headers: let r = getReferer(request.referer, request.url, ctx.referrerpolicy) if r != "": request.headers["Referer"] = r if request.proxy == nil or not ctx.config.acceptProxy: request.proxy = ctx.config.proxy let fd = int(stream.source.getFd()) ctx.handleMap[fd] = handle ctx.loadResource(request, handle) proc acceptConnection(ctx: LoaderContext) = #TODO TODO TODO acceptSocketStream should be non-blocking here, # otherwise the client disconnecting between poll and accept could # block this indefinitely. let stream = ctx.ssock.acceptSocketStream() try: var cmd: LoaderCommand stream.sread(cmd) case cmd of LOAD: ctx.onLoad(stream) of TEE: var fd: int stream.sread(fd) if fd notin ctx.handleMap: stream.swrite(false) else: let handle = ctx.handleMap[fd] handle.addOutputStream(stream) stream.swrite(true) of SUSPEND: var fds: seq[int] stream.sread(fds) for fd in fds: ctx.handleMap.withValue(fd, handlep): handlep[].suspend() of RESUME: var fds: seq[int] stream.sread(fds) for fd in fds: ctx.handleMap.withValue(fd, handlep): handlep[].resume() of ADDREF: inc ctx.refcount of UNREF: dec ctx.refcount if ctx.refcount == 0: ctx.alive = false stream.close() else: assert ctx.refcount > 0 of SET_REFERRER_POLICY: stream.sread(ctx.referrerpolicy) stream.close() except IOError: # End-of-file, broken pipe, or something else. For now we just # ignore it and pray nothing breaks. # (TODO: this is probably not a very good idea.) stream.close() proc finishCurlTransfer(ctx: LoaderContext, handleData: CurlHandle, res: int) = if res != int(CURLE_OK): discard handleData.handle.sendResult(int(res)) if handleData.finish != nil: handleData.finish(handleData) discard curl_multi_remove_handle(ctx.curlm, handleData.curl) handleData.cleanup() proc exitLoader(ctx: LoaderContext) = for handleData in ctx.handleList: ctx.finishCurlTransfer(handleData, ERROR_LOADER_KILLED) discard curl_multi_cleanup(ctx.curlm) curl_global_cleanup() ctx.ssock.close() quit(0) var gctx: LoaderContext proc initLoaderContext(fd: cint, config: LoaderConfig): LoaderContext = if curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK: raise newException(Defect, "Failed to initialize libcurl.") let curlm = curl_multi_init() if curlm == nil: raise newException(Defect, "Failed to initialize multi handle.") var ctx = LoaderContext( alive: true, curlm: curlm, config: config, refcount: 1 ) gctx = ctx #TODO ideally, buffered would be true. Unfortunately this conflicts with # sendFileHandle/recvFileHandle. ctx.ssock = initServerSocket(buffered = false) # The server has been initialized, so the main process can resume execution. var writef: File if not open(writef, FileHandle(fd), fmWrite): raise newException(Defect, "Failed to open input handle.") writef.write(char(0u8)) writef.flushFile() close(writef) discard close(fd) onSignal SIGTERM, SIGINT: discard sig gctx.exitLoader() ctx.addFd(int(ctx.ssock.sock.getFd()), CURL_WAIT_POLLIN) return ctx proc runFileLoader*(fd: cint, config: LoaderConfig) = var ctx = initLoaderContext(fd, config) while ctx.alive: var numfds: cint = 0 #TODO do not discard discard curl_multi_poll(ctx.curlm, addr ctx.extra_fds[0], cuint(ctx.extra_fds.len), 30_000, addr numfds) discard curl_multi_perform(ctx.curlm, addr numfds) for extra_fd in ctx.extra_fds.mitems: # For now, this is always ssock.sock.getFd(). if extra_fd.events == extra_fd.revents: ctx.acceptConnection() extra_fd.revents = 0 var msgs_left: cint = 1 while msgs_left > 0: let msg = curl_multi_info_read(ctx.curlm, addr msgs_left) if msg == nil: break if msg.msg == CURLMSG_DONE: # the only possible value atm var idx = -1 for i in 0 ..< ctx.handleList.len: if ctx.handleList[i].curl == msg.easy_handle: idx = i break assert idx != -1 ctx.finishCurlTransfer(ctx.handleList[idx], int(msg.data.result)) ctx.handleList.del(idx) ctx.exitLoader() proc getAttribute(contentType, attrname: string): string = let kvs = contentType.after(';') var i = kvs.find(attrname) var s = "" if i != -1 and kvs.len > i + attrname.len and kvs[i + attrname.len] == '=': i += attrname.len + 1 while i < kvs.len and kvs[i] in AsciiWhitespace: inc i var q = false for j in i ..< kvs.len: if q: s &= kvs[j] else: if kvs[j] == '\\': q = true elif kvs[j] == ';' or kvs[j] in AsciiWhitespace: break else: s &= kvs[j] return s proc applyHeaders(loader: FileLoader, request: Request, response: Response) = if "Content-Type" in response.headers.table: #TODO this is inefficient and broken on several levels. (In particular, # it breaks mailcap named attributes other than charset.) # Ideally, contentType would be a separate object type. let header = response.headers.table["Content-Type"][0].toLowerAscii() response.contentType = header.until(';').strip().toLowerAscii() response.charset = getCharset(header.getAttribute("charset")) else: response.contentType = guessContentType($response.url.path, "application/octet-stream", DefaultGuess) if "Location" in response.headers.table: if response.status in 301u16..303u16 or response.status in 307u16..308u16: let location = response.headers.table["Location"][0] let url = parseUrl(location, option(request.url)) if url.isSome: if (response.status == 303 and request.httpmethod notin {HTTP_GET, HTTP_HEAD}) or (response.status == 301 or response.status == 302 and request.httpmethod == HTTP_POST): response.redirect = newRequest(url.get, HTTP_GET, mode = request.mode, credentialsMode = request.credentialsMode, destination = request.destination) else: response.redirect = newRequest(url.get, request.httpmethod, body = request.body, multipart = request.multipart, mode = request.mode, credentialsMode = request.credentialsMode, destination = request.destination) #TODO: add init proc fetch*(loader: FileLoader, input: Request): FetchPromise = let stream = connectSocketStream(loader.process, false, blocking = true) stream.swrite(LOAD) stream.swrite(input) stream.flush() let fd = int(stream.source.getFd()) loader.registerFun(fd) let promise = FetchPromise() loader.connecting[fd] = ConnectData( promise: promise, request: input, stream: stream ) return promise proc reconnect*(loader: FileLoader, data: ConnectData) = let stream = connectSocketStream(loader.process, false, blocking = true) stream.swrite(LOAD) stream.swrite(data.request) stream.flush() let fd = int(stream.source.getFd()) loader.registerFun(fd) loader.connecting[fd] = ConnectData( promise: data.promise, request: data.request, stream: stream ) proc switchStream*(data: var ConnectData, stream: Stream) = data.stream = stream proc switchStream*(loader: FileLoader, data: var OngoingData, stream: SocketStream) = data.response.body = stream let fd = int(stream.source.getFd()) let realCloseImpl = stream.closeImpl stream.closeImpl = nil data.response.unregisterFun = proc() = loader.ongoing.del(fd) loader.unregistered.add(fd) loader.unregisterFun(fd) realCloseImpl(stream) proc suspend*(loader: FileLoader, fds: seq[int]) = let stream = connectSocketStream(loader.process, false, blocking = true) stream.swrite(SUSPEND) stream.swrite(fds) stream.close() proc resume*(loader: FileLoader, fds: seq[int]) = let stream = connectSocketStream(loader.process, false, blocking = true) stream.swrite(RESUME) stream.swrite(fds) stream.close() proc tee*(loader: FileLoader, fd: int): Stream = let stream = connectSocketStream(loader.process, false, blocking = true) stream.swrite(TEE) stream.swrite(fd) return stream const BufferSize = 4096 proc handleHeaders(loader: FileLoader, request: Request, response: Response, stream: Stream): bool = var status: int stream.sread(status) response.status = cast[uint16](status) response.headers = newHeaders() stream.sread(response.headers) loader.applyHeaders(request, response) # Only a stream of the response body may arrive after this point. response.body = stream return true # success proc onConnected*(loader: FileLoader, fd: int) = let connectData = loader.connecting[fd] let stream = connectData.stream let promise = connectData.promise let request = connectData.request var res: int stream.sread(res) let response = newResponse(res, request, fd, stream) if res == 0 and loader.handleHeaders(request, response, stream): assert loader.unregisterFun != nil let realCloseImpl = stream.closeImpl stream.closeImpl = nil response.unregisterFun = proc() = loader.ongoing.del(fd) loader.unregistered.add(fd) loader.unregisterFun(fd) realCloseImpl(stream) loader.ongoing[fd] = OngoingData( response: response, readbufsize: BufferSize, bodyRead: response.bodyRead ) SocketStream(stream).source.getFd().setBlocking(false) promise.resolve(JSResult[Response].ok(response)) else: loader.unregisterFun(fd) loader.unregistered.add(fd) let err = newTypeError("NetworkError when attempting to fetch resource") promise.resolve(JSResult[Response].err(err)) loader.connecting.del(fd) proc onRead*(loader: FileLoader, fd: int) = loader.ongoing.withValue(fd, buffer): let response = buffer[].response while true: let olen = buffer[].buf.len buffer[].buf.setLen(olen + buffer.readbufsize) try: let n = response.body.readData(addr buffer[].buf[olen], buffer.readbufsize) if n != 0: if buffer[].readbufsize < BufferSize: buffer[].readbufsize = min(BufferSize, buffer[].readbufsize * 2) buffer[].buf.setLen(olen + n) if response.body.atEnd(): buffer[].bodyRead.resolve(buffer[].buf) buffer[].bodyRead = nil buffer[].buf = "" response.unregisterFun() break except ErrorAgain, ErrorWouldBlock: assert buffer.readbufsize > 1 buffer.readbufsize = buffer.readbufsize div 2 proc onError*(loader: FileLoader, fd: int) = loader.onRead(fd) proc doRequest*(loader: FileLoader, request: Request, blocking = true, canredir = false): Response = let response = Response(url: request.url) let stream = connectSocketStream(loader.process, false, blocking = true) if canredir: request.canredir = true #TODO set this somewhere else? stream.swrite(LOAD) stream.swrite(request) stream.flush() stream.sread(response.res) if response.res == 0: if loader.handleHeaders(request, response, stream): if not blocking: stream.source.getFd().setBlocking(blocking) return response proc addref*(loader: FileLoader) = let stream = connectSocketStream(loader.process) if stream != nil: stream.swrite(ADDREF) stream.close() proc unref*(loader: FileLoader) = let stream = connectSocketStream(loader.process) if stream != nil: stream.swrite(UNREF) proc setReferrerPolicy*(loader: FileLoader, referrerpolicy: ReferrerPolicy) = let stream = connectSocketStream(loader.process) if stream != nil: stream.swrite(SET_REFERRER_POLICY) stream.swrite(referrerpolicy) stream.close()