about summary refs log tree commit diff stats
path: root/src
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-06-27 21:50:31 +0200
committerbptato <nincsnevem662@gmail.com>2024-06-28 21:26:33 +0200
commit8268ba2cf049be1865875e3e01305a500fa533e2 (patch)
tree7ce60624f9212e65dad0c1a2d168eb6ab7c03ec9 /src
parentf9734e2b3900781901bb5f268137bd9adbfc33ef (diff)
downloadchawan-8268ba2cf049be1865875e3e01305a500fa533e2.tar.gz
img, loader: add image resizing, misc fixes
* resize images with stb_image_resize
* use tee for output handle redirection (redirectToFile blocks)
* cache original image files
* accept lseek in sandbox
* misc stbi fixes

For now, I just pulled in stb_image_resize v1. v2 is an extra 150K in
size, not sure if it's worth the cost. (Either way, we can always switch
later if needed, since the API is almost the same.)

Next step: move sixel/kitty encoders to CGI, and cache their output in
memory instead of the intermediate RGBA representation.
Diffstat (limited to 'src')
-rw-r--r--src/css/cascade.nim5
-rw-r--r--src/html/dom.nim60
-rw-r--r--src/img/bitmap.nim3
-rw-r--r--src/io/bufreader.nim11
-rw-r--r--src/io/bufwriter.nim3
-rw-r--r--src/layout/engine.nim30
-rw-r--r--src/layout/renderdocument.nim4
-rw-r--r--src/loader/loader.nim96
-rw-r--r--src/loader/loaderhandle.nim19
-rw-r--r--src/loader/response.nim13
-rw-r--r--src/local/pager.nim45
-rw-r--r--src/server/buffer.nim1
-rw-r--r--src/utils/sandbox.nim2
13 files changed, 174 insertions, 118 deletions
diff --git a/src/css/cascade.nim b/src/css/cascade.nim
index 78953303..f39a335c 100644
--- a/src/css/cascade.nim
+++ b/src/css/cascade.nim
@@ -570,8 +570,9 @@ proc appendChildren(styledStack: var seq[CascadeFrame]; frame: CascadeFrame;
   let elem = Element(styledChild.node)
   styledStack.stackAppend(frame, styledChild, peAfter, idx, parentDeclMap)
   case elem.tagType
-  of TAG_TEXTAREA: styledStack.stackAppend(frame, styledChild, peTextareaText, idx)
-  of TAG_IMG, TAG_IMAGE: styledStack.stackAppend(frame, styledChild, peImage, idx)
+  of TAG_TEXTAREA:
+    styledStack.stackAppend(frame, styledChild, peTextareaText, idx)
+  of TAG_IMG: styledStack.stackAppend(frame, styledChild, peImage, idx)
   of TAG_VIDEO: styledStack.stackAppend(frame, styledChild, peVideo, idx)
   of TAG_AUDIO: styledStack.stackAppend(frame, styledChild, peAudio, idx)
   of TAG_BR: styledStack.stackAppend(frame, styledChild, peNewline, idx)
diff --git a/src/html/dom.nim b/src/html/dom.nim
index 3e5dddd0..503bdf27 100644
--- a/src/html/dom.nim
+++ b/src/html/dom.nim
@@ -2911,46 +2911,52 @@ proc loadResource(window: Window; image: HTMLImageElement) =
       # mixed content :/
       #TODO maybe do this in loader?
       url.scheme = "https"
-    let p = window.loader.fetch(newRequest(url))
-      .then(proc(res: JSResult[Response]): Promise[JSResult[Response]] =
+    let p = window.loader.fetch(newRequest(url)).then(
+      proc(res: JSResult[Response]): EmptyPromise =
         if res.isNone:
           return
         let response = res.get
         let contentType = response.getContentType("image/x-unknown")
         if contentType.until('/') != "image":
           return
+        let cacheId = window.loader.addCacheFile(response.outputId,
+          window.loader.clientPid)
         let request = newRequest(
           newURL("img-codec+" & contentType.after('/') & ":decode").get,
           httpMethod = hmPost,
-          body = RequestBody(t: rbtOutput, outputId: response.outputId)
+          headers = newHeaders({"Cha-Image-Info-Only": "1"}),
+          body = RequestBody(t: rbtOutput, outputId: response.outputId),
         )
         let r = window.loader.fetch(request)
-        window.loader.resume(response.outputId)
+        response.resume()
         response.unregisterFun()
         response.body.sclose()
-        return r
-      ).then(proc(res: JSResult[Response]): EmptyPromise =
-        if res.isNone:
-          return
-        let response = res.get
-        # we can close immediately; loader will not clean this output up until
-        # the `resume' command in pager.
-        response.unregisterFun()
-        response.body.sclose()
-        if "Cha-Image-Dimensions" notin response.headers.table:
-          window.console.error("Cha-Image-Dimensions missing in", $response.url)
-          return
-        let dims = response.headers.table["Cha-Image-Dimensions"][0]
-        let width = parseUInt64(dims.until('x'), allowSign = false)
-        let height = parseUInt64(dims.after('x'), allowSign = false)
-        if width.isNone or height.isNone:
-          window.console.error("wrong Cha-Image-Dimensions in", $response.url)
-          return
-        image.bitmap = NetworkBitmap(
-          width: width.get,
-          height: height.get,
-          outputId: response.outputId,
-          imageId: window.getImageId()
+        return r.then(proc(res: JSResult[Response]): EmptyPromise =
+          if res.isNone:
+            window.console.error("Failed to decode", $response.url)
+            return
+          let response = res.get
+          # close immediately; all data we're interested in is in the headers.
+          response.resume()
+          response.unregisterFun()
+          response.body.sclose()
+          if "Cha-Image-Dimensions" notin response.headers.table:
+            window.console.error("Cha-Image-Dimensions missing in",
+              $response.url)
+            return
+          let dims = response.headers.table["Cha-Image-Dimensions"][0]
+          let width = parseUInt64(dims.until('x'), allowSign = false)
+          let height = parseUInt64(dims.after('x'), allowSign = false)
+          if width.isNone or height.isNone:
+            window.console.error("wrong Cha-Image-Dimensions in", $response.url)
+            return
+          image.bitmap = NetworkBitmap(
+            width: width.get,
+            height: height.get,
+            cacheId: cacheId,
+            imageId: window.getImageId(),
+            contentType: contentType
+          )
         )
       )
     window.loadingResourcePromises.add(p)
diff --git a/src/img/bitmap.nim b/src/img/bitmap.nim
index 70244643..c5df19fc 100644
--- a/src/img/bitmap.nim
+++ b/src/img/bitmap.nim
@@ -9,8 +9,9 @@ type
   ImageBitmap* = ref object of Bitmap
 
   NetworkBitmap* = ref object of Bitmap
-    outputId*: int
+    cacheId*: int
     imageId*: int
+    contentType*: string
 
 proc newBitmap*(width, height: uint64): ImageBitmap =
   return ImageBitmap(
diff --git a/src/io/bufreader.nim b/src/io/bufreader.nim
index bd2486be..5f522666 100644
--- a/src/io/bufreader.nim
+++ b/src/io/bufreader.nim
@@ -230,13 +230,16 @@ proc sread*(reader: var BufferedReader; bmp: var Bitmap) =
     )
     reader.sread(bmp.px)
   else:
-    var outputId: int
+    var cacheId: int
     var imageId: int
-    reader.sread(outputId)
+    var contentType: string
+    reader.sread(cacheId)
     reader.sread(imageId)
+    reader.sread(contentType)
     bmp = NetworkBitmap(
       width: width,
       height: height,
-      outputId: outputId,
-      imageId: imageId
+      cacheId: cacheId,
+      imageId: imageId,
+      contentType: contentType
     )
diff --git a/src/io/bufwriter.nim b/src/io/bufwriter.nim
index 56e30f5b..8166eab8 100644
--- a/src/io/bufwriter.nim
+++ b/src/io/bufwriter.nim
@@ -208,5 +208,6 @@ proc swrite*(writer: var BufferedWriter; bmp: Bitmap) =
   if bmp of ImageBitmap:
     writer.swrite(bmp.px)
   else:
-    writer.swrite(NetworkBitmap(bmp).outputId)
+    writer.swrite(NetworkBitmap(bmp).cacheId)
     writer.swrite(NetworkBitmap(bmp).imageId)
+    writer.swrite(NetworkBitmap(bmp).contentType)
diff --git a/src/layout/engine.nim b/src/layout/engine.nim
index a764faad..3acc958e 100644
--- a/src/layout/engine.nim
+++ b/src/layout/engine.nim
@@ -1370,7 +1370,7 @@ proc addInlineBlock(ictx: var InlineContext; state: var InlineState;
   ictx.whitespacenum = 0
 
 proc addInlineImage(ictx: var InlineContext; state: var InlineState;
-    bmp: Bitmap) =
+    bmp: Bitmap; padding: LayoutUnit) =
   let h = int(bmp.height).toLayoutUnit().ceilTo(ictx.cellHeight)
   let iastate = InlineAtomState(
     vertalign: state.fragment.computed{"vertical-align"},
@@ -1381,6 +1381,20 @@ proc addInlineImage(ictx: var InlineContext; state: var InlineState;
     bmp: bmp,
     size: size(w = int(bmp.width), h = h), #TODO overflow
   )
+  let computed = state.fragment.computed
+  let lctx = ictx.lctx
+  if computed{"width"}.canpx(ictx.space.w):
+    let w = computed{"width"}.spx(lctx, ictx.space.w, computed, padding)
+    if not computed{"height"}.canpx(ictx.space.h):
+      # maintain aspect ratio
+      atom.size.h = atom.size.h div atom.size.w * w
+    atom.size.w = w
+  if computed{"height"}.canpx(ictx.space.h):
+    let h = computed{"height"}.spx(lctx, ictx.space.h, computed, padding)
+    if not computed{"width"}.canpx(ictx.space.w):
+      # maintain aspect ratio
+      atom.size.w = atom.size.w div atom.size.h * h
+    atom.size.h = h
   discard ictx.addAtom(state, iastate, atom)
 
 func calcLineHeight(computed: CSSComputedValues; lctx: LayoutContext):
@@ -1421,7 +1435,7 @@ proc layoutInline(ictx: var InlineContext; fragment: InlineFragment) =
   case fragment.t
   of iftNewline: ictx.flushLine(state)
   of iftBox: ictx.addInlineBlock(state, fragment.box)
-  of iftBitmap: ictx.addInlineImage(state, fragment.bmp)
+  of iftBitmap: ictx.addInlineImage(state, fragment.bmp, padding.sum())
   of iftText: ictx.layoutText(state, fragment.text)
   of iftParent:
     for child in fragment.children:
@@ -2636,13 +2650,12 @@ proc pushInline(ctx: var InnerBlockContext; fragment: InlineFragment) =
 
 proc pushInlineText(ctx: var InnerBlockContext; computed: CSSComputedValues;
     styledNode: StyledNode; text: string) =
-  let box = InlineFragment(
+  ctx.pushInline(InlineFragment(
     t: iftText,
     computed: computed,
     node: styledNode,
     text: text
-  )
-  ctx.pushInline(box)
+  ))
 
 proc pushInlineBlock(ctx: var InnerBlockContext; styledNode: StyledNode;
     computed: CSSComputedValues) =
@@ -2788,13 +2801,12 @@ proc buildReplacement(ctx: var InnerBlockContext; child, parent: StyledNode;
     ctx.pushInlineText(computed, parent, child.content.s)
   of ContentImage:
     if child.content.bmp != nil:
-      let wrapper = InlineFragment(
+      ctx.pushInline(InlineFragment(
         t: iftBitmap,
-        computed: computed,
+        computed: parent.computed,
         node: parent,
         bmp: child.content.bmp
-      )
-      ctx.pushInline(wrapper)
+      ))
     else:
       ctx.pushInlineText(computed, parent, "[img]")
   of ContentVideo:
diff --git a/src/layout/renderdocument.nim b/src/layout/renderdocument.nim
index e45b8af4..89afde32 100644
--- a/src/layout/renderdocument.nim
+++ b/src/layout/renderdocument.nim
@@ -218,6 +218,8 @@ type
   PosBitmap* = ref object
     x*: int
     y*: int
+    width*: int
+    height*: int
     bmp*: Bitmap
 
   RenderState = object
@@ -379,6 +381,8 @@ proc renderInlineFragment(grid: var FlexibleGrid; state: var RenderState;
         state.images.add(PosBitmap(
           x: (offset.x div state.attrs.ppc).toInt,
           y: (offset.y div state.attrs.ppl).toInt,
+          width: atom.size.w.toInt,
+          height: atom.size.h.toInt,
           bmp: atom.bmp
         ))
   if fragment.computed{"position"} != PositionStatic:
diff --git a/src/loader/loader.nim b/src/loader/loader.nim
index 7c76ae59..11cf714f 100644
--- a/src/loader/loader.nim
+++ b/src/loader/loader.nim
@@ -174,6 +174,16 @@ func findCachedHandle(ctx: LoaderContext; cacheId: int): LoaderHandle =
 type PushBufferResult = enum
   pbrDone, pbrUnregister
 
+proc register(ctx: LoaderContext; output: OutputHandle) =
+  assert not output.registered
+  ctx.selector.registerHandle(output.ostream.fd, {Write}, 0)
+  output.registered = true
+
+proc unregister(ctx: LoaderContext; output: OutputHandle) =
+  assert output.registered
+  ctx.selector.unregister(output.ostream.fd)
+  output.registered = false
+
 # Either write data to the target output, or append it to the list of buffers to
 # write and register the output in our selector.
 proc pushBuffer(ctx: LoaderContext; output: OutputHandle; buffer: LoaderBuffer;
@@ -203,8 +213,7 @@ proc pushBuffer(ctx: LoaderContext; output: OutputHandle; buffer: LoaderBuffer;
     if n < buffer.len:
       output.currentBuffer = buffer
       output.currentBufferIdx = n
-      ctx.selector.registerHandle(output.ostream.fd, {Write}, 0)
-      output.registered = true
+      ctx.register(output)
   else:
     output.buffers.addLast(buffer)
   pbrDone
@@ -213,8 +222,11 @@ proc getOutputId(ctx: LoaderContext): int =
   result = ctx.outputNum
   inc ctx.outputNum
 
-proc redirectToStream(ctx: LoaderContext; output: OutputHandle;
-    ps: PosixStream): bool =
+proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
+    targetPath: string): bool =
+  let ps = newPosixStream(targetPath, O_CREAT or O_WRONLY, 0o600)
+  if ps == nil:
+    return false
   try:
     if output.currentBuffer != nil:
       let n = ps.sendData(output.currentBuffer, output.currentBufferIdx)
@@ -227,7 +239,7 @@ proc redirectToStream(ctx: LoaderContext; output: OutputHandle;
         ps.sclose()
         return false
   except ErrorBrokenPipe:
-    # ps or output is dead; give up.
+    # ps is dead; give up.
     ps.sclose()
     return false
   if output.istreamAtEnd:
@@ -241,13 +253,6 @@ proc redirectToStream(ctx: LoaderContext; output: OutputHandle;
     ))
   return true
 
-proc redirectToFile(ctx: LoaderContext; output: OutputHandle;
-    targetPath: string): bool =
-  let ps = newPosixStream(targetPath, O_CREAT or O_WRONLY, 0o600)
-  if ps == nil:
-    return false
-  return ctx.redirectToStream(output, ps)
-
 proc addCacheFile(ctx: LoaderContext; client: ClientData; output: OutputHandle):
     int =
   if output.parent != nil and output.parent.cacheId != -1:
@@ -326,8 +331,7 @@ proc loadStreamRegular(ctx: LoaderContext; handle, cachedHandle: LoaderHandle) =
     output.parent = nil
     let i = handle.outputs.find(output)
     if output.registered:
-      ctx.selector.unregister(output.ostream.fd)
-      output.registered = false
+      ctx.unregister(output)
     handle.outputs.del(i)
   for output in handle.outputs:
     if r == hrrBrokenPipe:
@@ -397,8 +401,8 @@ proc loadFromCache(ctx: LoaderContext; client: ClientData; handle: LoaderHandle;
   else:
     handle.sendResult(ERROR_URL_NOT_IN_CACHE)
 
-proc loadResource(ctx: LoaderContext; client: ClientData; config: LoaderClientConfig;
-    request: Request; handle: LoaderHandle) =
+proc loadResource(ctx: LoaderContext; client: ClientData;
+    config: LoaderClientConfig; request: Request; handle: LoaderHandle) =
   var redo = true
   var tries = 0
   var prevurl: URL = nil
@@ -419,16 +423,18 @@ proc loadResource(ctx: LoaderContext; client: ClientData; config: LoaderClientCo
         config.insecureSSLNoVerify, ostream)
       if handle.istream != nil:
         if ostream != nil:
-          let output = ctx.findOutput(request.body.outputId, client)
-          if output != nil:
-            if not ctx.redirectToStream(output, ostream):
-              # give up.
-              handle.rejectHandle(ERROR_FAILED_TO_REDIRECT)
-              return
+          let outputIn = ctx.findOutput(request.body.outputId, client)
+          if outputIn != nil:
+            ostream.setBlocking(false)
+            let output = outputIn.tee(ostream, ctx.getOutputId(), client.pid)
+            ctx.outputMap[ostream.fd] = output
+            output.suspended = false
+            ctx.register(output)
           else:
             ostream.sclose()
         ctx.addFd(handle)
       else:
+        assert ostream == nil
         handle.close()
     elif request.url.scheme == "stream":
       ctx.loadStream(handle, request)
@@ -538,16 +544,15 @@ proc removeClient(ctx: LoaderContext; stream: SocketStream;
     ctx.clientData.del(pid)
   stream.sclose()
 
-proc addCacheFile(ctx: LoaderContext; stream: SocketStream;
+proc addCacheFile(ctx: LoaderContext; stream: SocketStream; client: ClientData;
     r: var BufferedReader) =
   var outputId: int
   var targetPid: int
-  var sourcePid: int
   r.sread(outputId)
+  #TODO get rid of targetPid
   r.sread(targetPid)
-  r.sread(sourcePid)
-  let sourceClient = ctx.clientData[sourcePid]
-  let output = ctx.findOutput(outputId, sourceClient)
+  doAssert ctx.isPrivileged(client) or client.pid == targetPid
+  let output = ctx.findOutput(outputId, client)
   assert output != nil
   let targetClient = ctx.clientData[targetPid]
   let id = ctx.addCacheFile(targetClient, output)
@@ -613,10 +618,11 @@ proc tee(ctx: LoaderContext; stream: SocketStream; client: ClientData;
   var targetPid: int
   r.sread(sourceId)
   r.sread(targetPid)
-  let output = ctx.findOutput(sourceId, client)
-  if output != nil:
+  let outputIn = ctx.findOutput(sourceId, client)
+  if outputIn != nil:
     let id = ctx.getOutputId()
-    output.tee(stream, id, targetPid)
+    let output = outputIn.tee(stream, id, targetPid)
+    ctx.outputMap[output.ostream.fd] = output
     stream.withPacketWriter w:
       w.swrite(id)
     stream.setBlocking(false)
@@ -635,8 +641,7 @@ proc suspend(ctx: LoaderContext; stream: SocketStream; client: ClientData;
       output.suspended = true
       if output.registered:
         # do not waste cycles trying to push into output
-        output.registered = false
-        ctx.selector.unregister(output.ostream.fd)
+        ctx.unregister(output)
 
 proc resume(ctx: LoaderContext; stream: SocketStream; client: ClientData;
     r: var BufferedReader) =
@@ -646,9 +651,7 @@ proc resume(ctx: LoaderContext; stream: SocketStream; client: ClientData;
     let output = ctx.findOutput(id, client)
     if output != nil:
       output.suspended = false
-      assert not output.registered
-      output.registered = true
-      ctx.selector.registerHandle(output.ostream.fd, {Write}, 0)
+      ctx.register(output)
 
 proc equalsConstantTime(a, b: ClientKey): bool =
   static:
@@ -690,9 +693,6 @@ proc acceptConnection(ctx: LoaderContext) =
       of lcRemoveClient:
         privileged_command
         ctx.removeClient(stream, r)
-      of lcAddCacheFile:
-        privileged_command
-        ctx.addCacheFile(stream, r)
       of lcShareCachedItem:
         privileged_command
         ctx.shareCachedItem(stream, r)
@@ -708,6 +708,8 @@ proc acceptConnection(ctx: LoaderContext) =
       of lcGetCacheFile:
         privileged_command
         ctx.getCacheFile(stream, client, r)
+      of lcAddCacheFile:
+        ctx.addCacheFile(stream, client, r)
       of lcRemoveCachedItem:
         ctx.removeCachedItem(stream, client, r)
       of lcLoad:
@@ -812,8 +814,7 @@ proc handleWrite(ctx: LoaderContext; output: OutputHandle;
       unregWrite.add(output)
     else:
       # all buffers sent, no need to select on this output again for now
-      output.registered = false
-      ctx.selector.unregister(output.ostream.fd)
+      ctx.unregister(output)
 
 proc finishCycle(ctx: LoaderContext; unregRead: var seq[LoaderHandle];
     unregWrite: var seq[OutputHandle]) =
@@ -835,7 +836,7 @@ proc finishCycle(ctx: LoaderContext; unregRead: var seq[LoaderHandle];
   for output in unregWrite:
     if output.ostream != nil:
       if output.registered:
-        ctx.selector.unregister(output.ostream.fd)
+        ctx.unregister(output)
       ctx.outputMap.del(output.ostream.fd)
       output.oclose()
       let handle = output.parent
@@ -973,20 +974,14 @@ proc tee*(loader: FileLoader; sourceId, targetPid: int): (SocketStream, int) =
   r.sread(outputId)
   return (stream, outputId)
 
-# sourcePid is the PID of the output's owner. This is used in pager for images,
-# so that we can be sure that a container only loads images on the page that
-# it owns.
-proc addCacheFile*(loader: FileLoader; outputId, targetPid: int;
-    sourcePid = -1): int =
+proc addCacheFile*(loader: FileLoader; outputId, targetPid: int): int =
   let stream = loader.connect()
   if stream == nil:
     return -1
-  let sourcePid = if sourcePid == -1: loader.clientPid else: sourcePid
   stream.withLoaderPacketWriter loader, w:
     w.swrite(lcAddCacheFile)
     w.swrite(outputId)
     w.swrite(targetPid)
-    w.swrite(sourcePid)
   var r = stream.initPacketReader()
   var outputId: int
   r.sread(outputId)
@@ -1098,12 +1093,13 @@ proc doRequest*(loader: FileLoader; request: Request): Response =
     stream.sclose()
   return response
 
-proc shareCachedItem*(loader: FileLoader; id, targetPid: int) =
+proc shareCachedItem*(loader: FileLoader; id, targetPid: int; sourcePid = -1) =
   let stream = loader.connect()
   if stream != nil:
+    let sourcePid = if sourcePid != -1: sourcePid else: loader.clientPid
     stream.withLoaderPacketWriter loader, w:
       w.swrite(lcShareCachedItem)
-      w.swrite(loader.clientPid)
+      w.swrite(sourcePid)
       w.swrite(targetPid)
       w.swrite(id)
     stream.sclose()
diff --git a/src/loader/loaderhandle.nim b/src/loader/loaderhandle.nim
index 13869c8f..b1b04dee 100644
--- a/src/loader/loaderhandle.nim
+++ b/src/loader/loaderhandle.nim
@@ -108,10 +108,11 @@ proc bufferCleared*(output: OutputHandle) =
   else:
     output.currentBuffer = nil
 
-proc tee*(outputIn: OutputHandle; ostream: PosixStream; outputId, pid: int) =
-  let parent = outputIn.parent
-  parent.outputs.add(OutputHandle(
-    parent: parent,
+proc tee*(outputIn: OutputHandle; ostream: PosixStream; outputId, pid: int):
+    OutputHandle =
+  assert outputIn.suspended
+  let output = OutputHandle(
+    parent: outputIn.parent,
     ostream: ostream,
     currentBuffer: outputIn.currentBuffer,
     currentBufferIdx: outputIn.currentBufferIdx,
@@ -120,7 +121,13 @@ proc tee*(outputIn: OutputHandle; ostream: PosixStream; outputId, pid: int) =
     outputId: outputId,
     ownerPid: pid,
     suspended: outputIn.suspended
-  ))
+  )
+  when defined(debug):
+    output.url = outputIn.url
+  if outputIn.parent != nil:
+    assert outputIn.parent.parser == nil
+    outputIn.parent.outputs.add(output)
+  return output
 
 template output*(handle: LoaderHandle): OutputHandle =
   handle.outputs[0]
@@ -194,6 +201,6 @@ proc oclose*(output: OutputHandle) =
 proc close*(handle: LoaderHandle) =
   handle.iclose()
   for output in handle.outputs:
-    #TODO assert not output.registered
+    assert not output.registered
     if output.ostream != nil:
       output.oclose()
diff --git a/src/loader/response.nim b/src/loader/response.nim
index ab74571e..b8d95e36 100644
--- a/src/loader/response.nim
+++ b/src/loader/response.nim
@@ -129,6 +129,10 @@ proc onReadText(response: Response) =
       opaque.buf.setLen(olen)
       break
 
+proc resume*(response: Response) =
+  response.resumeFun(response.outputId)
+  response.resumeFun = nil
+
 proc text*(response: Response): Promise[JSResult[string]] {.jsfunc.} =
   if response.body == nil:
     let p = newPromise[JSResult[string]]()
@@ -144,8 +148,7 @@ proc text*(response: Response): Promise[JSResult[string]] {.jsfunc.} =
   response.opaque = opaque
   response.onRead = onReadText
   response.bodyUsed = true
-  response.resumeFun(response.outputId)
-  response.resumeFun = nil
+  response.resume()
   return response.bodyRead.then(proc(): JSResult[string] =
     let charset = response.getCharset(CHARSET_UTF_8)
     ok(opaque.buf.decodeAll(charset))
@@ -183,8 +186,7 @@ proc blob*(response: Response): Promise[JSResult[Blob]] {.jsfunc.} =
   response.opaque = opaque
   response.onRead = onReadBlob
   response.bodyUsed = true
-  response.resumeFun(response.outputId)
-  response.resumeFun = nil
+  response.resume()
   let contentType = response.getContentType()
   return response.bodyRead.then(proc(): JSResult[Blob] =
     let p = realloc(opaque.p, opaque.len)
@@ -220,8 +222,7 @@ proc saveToBitmap*(response: Response; bmp: Bitmap): EmptyPromise =
   response.opaque = opaque
   response.onRead = onReadBitmap
   response.bodyUsed = true
-  response.resumeFun(response.outputId)
-  response.resumeFun = nil
+  response.resume()
   return response.bodyRead
 
 proc json(ctx: JSContext; this: Response): Promise[JSResult[JSValue]]
diff --git a/src/local/pager.nim b/src/local/pager.nim
index 643e7689..cd0df216 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -21,6 +21,7 @@ import io/socketstream
 import io/stdio
 import io/tempfile
 import io/urlfilter
+import layout/renderdocument
 import loader/connecterror
 import loader/headers
 import loader/loader
@@ -472,7 +473,7 @@ proc redraw(pager: Pager) {.jsfunc.} =
     if pager.container.select != nil:
       pager.container.select.redraw = true
 
-proc loadCachedImage(pager: Pager; container: Container; bmp: NetworkBitmap) =
+proc loadCachedImage(pager: Pager; container: Container; image: PosBitmap) =
   #TODO this is kinda dumb, because we cannot unload cached images.
   # ideally the filesystem cache should serve as the only cache, but right
   # now it's just sort of a temporary place before the image is dumped to
@@ -481,22 +482,42 @@ proc loadCachedImage(pager: Pager; container: Container; bmp: NetworkBitmap) =
   # load start" event in container, and then add one in the pager?
   # the first option seems better; it's simpler, and buffers can add arbitrary
   # cache files if they just tell the pager it's an image anyway.
-  let cacheId = pager.loader.addCacheFile(bmp.outputId,
-    pager.loader.clientPid, container.process)
-  let request = newRequest(newURL("cache:" & $cacheId).get)
+  let bmp = NetworkBitmap(image.bmp)
+  let request = newRequest(newURL("cache:" & $bmp.cacheId).get)
   let cachedImage = CachedImage(bmp: bmp)
-  pager.loader.fetch(request).then(proc(res: JSResult[Response]): EmptyPromise =
+  pager.loader.shareCachedItem(bmp.cacheId, pager.loader.clientPid,
+    container.process)
+  pager.loader.fetch(request).then(proc(res: JSResult[Response]):
+      Promise[JSResult[Response]] =
     if res.isNone:
-      let i = container.cachedImages.find(cachedImage)
-      container.cachedImages.del(i)
-      return nil
-    return res.get.saveToBitmap(bmp)
+      return
+    let response = res.get
+    let headers = newHeaders()
+    if uint64(image.width) != bmp.width or uint64(image.height) != bmp.height:
+      headers.add("Cha-Image-Target-Dimensions", $image.width & 'x' &
+        $image.height)
+    let request = newRequest(
+      newURL("img-codec+" & bmp.contentType.after('/') & ":decode").get,
+      httpMethod = hmPost,
+      headers = headers,
+      body = RequestBody(t: rbtOutput, outputId: response.outputId),
+    )
+    let r = pager.loader.fetch(request)
+    response.resume()
+    response.unregisterFun()
+    response.body.sclose()
+    return r
+  ).then(proc(res: JSResult[Response]): EmptyPromise =
+    let response = res.get
+    # take target sizes
+    bmp.width = uint64(image.width)
+    bmp.height = uint64(image.height)
+    return response.saveToBitmap(bmp)
   ).then(proc() =
     container.redraw = true
     cachedImage.loaded = true
-    pager.loader.removeCachedItem(cacheId)
+    pager.loader.removeCachedItem(bmp.cacheId)
   )
-  pager.loader.resume(bmp.outputId) # get rid of dangling output
   container.cachedImages.add(cachedImage)
 
 proc initImages(pager: Pager; container: Container) =
@@ -509,7 +530,7 @@ proc initImages(pager: Pager; container: Container) =
       let cached = container.findCachedImage(bmp.imageId)
       imageId = bmp.imageId
       if cached == nil:
-        pager.loadCachedImage(container, bmp)
+        pager.loadCachedImage(container, image)
         continue
       image.bmp = cached.bmp
       if not cached.loaded:
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index df0cb0ae..f14cda60 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -1022,6 +1022,7 @@ proc clone*(buffer: Buffer; newurl: URL): int {.proxy.} =
     let c = ps.sreadChar()
     assert c == char(0)
     ps.sclose()
+    #TODO share cached images with new buffer
     buffer.loader.resume(ids)
     return pid
 
diff --git a/src/utils/sandbox.nim b/src/utils/sandbox.nim
index ce9b194e..d4312a49 100644
--- a/src/utils/sandbox.nim
+++ b/src/utils/sandbox.nim
@@ -89,6 +89,7 @@ elif defined(linux) and not disableSandbox:
       "getrlimit", # glibc uses it after fork it seems
       "getsockname", # Nim needs it for connecting
       "gettimeofday", # used by QuickJS in Date.now()
+      "lseek", # glibc calls lseek on open files at exit
       "mmap", # memory allocation
       "mmap2", # memory allocation
       "mremap", # memory allocation
@@ -131,6 +132,7 @@ elif defined(linux) and not disableSandbox:
     const allowList = [
       cstring"close", "exit_group", # duh
       "read", "write", "recv", "send", "recvfrom", "sendto", # socket i/o
+      "lseek", # glibc calls lseek on open files at exit
       "fcntl", "fcntl64", # so we can set nonblock etc.
       "mmap", "mmap2", "mremap", "munmap", "brk", # memory allocation
       "poll", # curl needs poll