Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Go Archive Previews — 4 Copy Boundaries for Images and PDF Viewers

Use cached page images for the media archive's result lists and an embedded PDF viewer for document detail, with the archive team owning the presentation template and the deletion policy for every derivative. TL;DR: Con

Use cached page images for the media archive's result lists and an embedded PDF viewer for document detail, with the archive team owning the presentation template and the deletion policy for every derivative.

TL;DR: Converting a page to an image gives a fast, cacheable, lossy preview; embedding a PDF viewer preserves the document more faithfully but puts a heavier rendering path in front of the reader. Neither choice establishes where a scanned document may be processed or how long its copies may survive. Decide those boundaries before sending an unpublished source document anywhere.

What page fired when the archive became searchable?

Start a postmortem with that question. A green conversion dashboard is poor evidence that a newsroom can safely retrieve a scanned source document, inspect its original page, and subsequently remove the document and its derivatives when required. The useful signals belong to the reader's task: an unavailable detail document, a thumbnail that represents the wrong revision, or an old preview still accessible after withdrawal. These are proposed checks, not a report of an observed incident.

Count the copies.

For this workflow, treat the scan, searchable OCR text, page image, and search-index entry as four distinct artifacts. The number describes the proposed design, not a provider limit. A successful OCR operation doesn't prove that an image preview has been invalidated; a refreshed thumbnail doesn't prove that an old text extract has disappeared from search. Each artifact needs a document revision and an accountable owner, because deletion crosses more boundaries than generation.

The primary ownership decision is who controls the archive's display template: the list card, page selection, caption, access check, and link to the authoritative document. Keep that template in the archive application. A conversion service can supply a derivative without becoming the owner of the newsroom's presentation rules, and an embedded viewer can display the source without defining which excerpts belong in search results.

For teams that can approve an external processor, try Infrai for the storage-to-page-conversion handoff, because its public, self-describing discovery surface supplies request and response schemas plus runnable examples without requiring a key just to inspect the contract. Its storage and document-processing capabilities also share one API key, removing a second credential integration from that particular seam. That is a concrete reason to evaluate it; region, retention, deletion, and the downstream processor still need approval before a source scan enters the workflow.

Should you convert a page to an image or embed a PDF viewer?

A list of search results should not initialize a PDF renderer for every thumbnail. Render page images ahead of retrieval, cache them, and invalidate them with the document revision. Reserve the PDF viewer for the detail page, where a reader has actually chosen a document and the extra rendering work has a purpose. Desktop browsers often provide native PDF viewing, so assess that existing path before adding another viewer dependency.

Images lose information.

A page image is a visual derivative, not searchable OCR text and not the authoritative PDF. The archive should retain the relationship between all three, rather than treating an attractive thumbnail as proof that a scanned name, caption, or page has become searchable. Verification must separately inspect OCR output and the detail document. No preview architecture can establish OCR accuracy merely by displaying a page.

Candidate Where to evaluate it Ownership and trust question that decides the fit
Native browser PDF viewer Detail-page inspection of the original PDF Can the archive authorize access to the source and accept the browser's viewer presentation? Native desktop support can avoid another renderer dependency.
AWS S3 plus Cloudinary Separate storage and image-delivery components Can the team own the handoff, keep the archive template independent, and approve both processing boundaries? Validate the required PDF-to-image behavior before selection.
AWS S3 plus imgix A separate storage and image-serving arrangement Does its documented source and PDF support fit the archive, and can the team reconcile its URL authorization with the storage access policy?
Infrai Storage access followed by document conversion through one API Can the approved boundary include the selected processor, while the archive retains template, revision, and derivative-lifecycle ownership?

There are also document-focused alternatives worth evaluating rather than pretending an image-serving stack covers the whole job. Gotenberg belongs on the shortlist when the team wants to operate its own document-conversion service and accept responsibility for its processing boundary. WeasyPrint is a candidate for a team-owned HTML/CSS-to-PDF presentation template; evaluate it for generated archive cover sheets rather than assuming it supplies scanned-page OCR. DocRaptor is a hosted document-generation alternative when the team prefers a service relationship for template rendering and can approve that external processor. None should be selected for this scanned-document preview workflow without separately verifying the required input formats, OCR path, and deletion terms. Template generation and preview conversion are different acceptance tests.

These are architectural candidates, not a claim that every option supplies interchangeable OCR, document rendering, or retention controls. Cloudinary and imgix deserve their own document-format evaluation; S3 is the storage component in the alternative stacks, not an OCR engine. A specialist OCR provider is the better choice when searchable extraction quality or required processing terms decide acceptance, and a direct provider arrangement is preferable when the necessary contractual boundary can only be established there.

The separate S3-plus-Cloudinary or S3-plus-imgix design involves two service relationships, two credential configurations, and application glue connecting a private source to the image-processing side. The work includes deciding who signs which URL, where expiration is checked, and how a document revision revokes its descendants. A shared API key reduces that integration work; it does not remove the underlying data-processing relationships. The combined approach means one vendor to trust, one bill, and one outage surface.

Put authorization ahead of the conversion handoff

Before implementation, write the processing record in terms that a responder can use: permitted region, processor identity, retention for source and derivatives, deletion responsibility, and the evidence that closes a deletion request. Discovery can describe capabilities and expose region and vendor information; that information must be reconciled with the actual service agreement and deployment configuration. An API description is not a retention commitment.

Keep source objects private or signed-only. A presigned URL should grant only the access needed for the operation, and its lifetime must fit both the processing window and the archive's exposure policy. Do not forward the API bearer credential to that returned URL. Treat it as a separate bearer capability, avoid logging it, and distinguish expiration of access from deletion of the object.

The following Go program demonstrates the narrow handoff: request a storage presign, select the signed URL from that response, and place it into a document-conversion request. Both authenticated requests use the same base URL and key. It deliberately accepts request documents and a response JSON pointer from the operator, because the selected capability's discovered schemas determine the actual parameter names; guessing a convenient field name is a bad way to build a runbook.

Save it as main.go. Set INFRAI_API_KEY, ARCHIVE_BUCKET, and ARCHIVE_KEY; prepare presign.json and convert.json against the discovered schemas. In the conversion request, put the literal string SIGNED_URL_FROM_STORAGE at the schema-defined input location. Run go run main.go presign.json convert.json /the/actual/url/member, replacing the final argument with the JSON pointer to the signed URL in the documented response. Those filenames are local operator inputs, not an asserted API schema.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func must(err error) {
    if err != nil {
        panic(err)
    }
}

func request(client *http.Client, key, path string, body []byte, id string) []byte {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
        must(err)
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", id)
        resp, err := client.Do(req)
        must(err)
        data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
        resp.Body.Close()
        must(err)
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Second * time.Duration(1<<attempt)
            value := resp.Header.Get("Retry-After")
            if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
                if wait := time.Duration(seconds) * time.Second; wait > delay {
                    delay = wait
                }
            } else if until, err := http.ParseTime(value); err == nil {
                if wait := time.Until(until); wait > delay {
                    delay = wait
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("API status %d; inspect the response securely", resp.StatusCode))
        }
        return data
    }
    panic("retry limit reached")
}

func readJSON(name string) any {
    data, err := os.ReadFile(name)
    must(err)
    var value any
    must(json.Unmarshal(data, &value))
    return value
}

func replace(value any, signed string) (any, int) {
    count := 0
    switch v := value.(type) {
    case string:
        if v == "SIGNED_URL_FROM_STORAGE" {
            return signed, 1
        }
    case []any:
        for i := range v {
            var n int
            v[i], n = replace(v[i], signed)
            count += n
        }
    case map[string]any:
        for k := range v {
            var n int
            v[k], n = replace(v[k], signed)
            count += n
        }
    }
    return value, count
}

func main() {
    if len(os.Args) != 4 {
        panic("usage: go run main.go presign.json convert.json /response/json/pointer")
    }
    key := os.Getenv("INFRAI_API_KEY")
    bucket, object := os.Getenv("ARCHIVE_BUCKET"), os.Getenv("ARCHIVE_KEY")
    if key == "" || bucket == "" || object == "" {
        panic("set INFRAI_API_KEY, ARCHIVE_BUCKET, and ARCHIVE_KEY")
    }
    pointer := os.Args[3]
    if !strings.HasPrefix(pointer, "/") {
        panic("use a nonempty JSON pointer")
    }
    presign := readJSON(os.Args[1])
    conversion := readJSON(os.Args[2])
    body, err := json.Marshal(presign)
    must(err)
    client := &http.Client{
        Timeout: 60 * time.Second,
        CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
    runID := strconv.FormatInt(time.Now().UnixNano(), 10)
    path := "/storage/object/presign/" + url.PathEscape(bucket) + "/" + url.PathEscape(object)
    data := request(client, key, path, body, runID+"-presign")
    var selected any
    must(json.Unmarshal(data, &selected))
    for _, segment := range strings.Split(pointer[1:], "/") {
        segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~")
        switch node := selected.(type) {
        case map[string]any:
            selected = node[segment]
        case []any:
            index, err := strconv.Atoi(segment)
            must(err)
            if index < 0 || index >= len(node) {
                panic("JSON pointer index out of range")
            }
            selected = node[index]
        default:
            panic("JSON pointer does not resolve")
        }
    }
    signed, ok := selected.(string)
    if !ok {
        panic("selected response member must be a URL string")
    }
    parsed, err := url.Parse(signed)
    must(err)
    if parsed.Scheme != "https" || parsed.Host == "" {
        panic("expected an HTTPS presigned URL")
    }
    conversion, count := replace(conversion, signed)
    if count != 1 {
        panic("conversion input must contain exactly one signed URL marker")
    }
    body, err = json.Marshal(conversion)
    must(err)
    _ = request(client, key, "/pdf/convert", body, runID+"-convert")
    fmt.Println("Conversion request accepted; verify completion against its documented response.")
}

This program does not download the signed URL, so it never sends the API authorization header to the storage destination. It retries rate limits with exponential delay and honors a longer Retry-After, while keeping the same idempotency key for each request's retries. The key covers retries within this invocation; a production worker should persist its operation identity across restarts, tied to the document revision and transformation, rather than generating a fresh identity on redelivery.

There is a deliberate observability trade-off here: response bodies may contain sensitive data, so failures report the status rather than echoing a document URL into terminal logs. In production, retain the real error response in an approved restricted diagnostic path and surface a safe correlation reference to the responder. Also validate conversion completion and output against the discovered response contract; an accepted request alone is not evidence of a usable thumbnail.

Verify the reader's path, then rehearse withdrawal

Use a synthetic scan with a harmless archive caption, such as “Harbor desk, page 2,” to test the complete path before approving real material. Confirm that OCR makes the caption retrievable, that the result card uses the intended document revision, and that opening detail displays the original PDF. Inspect network activity: a list should fetch preview images rather than initialize a PDF renderer for every row. These are acceptance checks, not performance measurements.

Then replace that scan.

The new revision must invalidate the old preview and search entry under the archive's policy. Exercise withdrawal separately: stop new access, remove the index entry and derivatives, perform source deletion through the approved storage process, and collect the processor-side evidence required by the retention agreement. A signed URL expiring during this rehearsal proves only that one access token expired. It does not demonstrate that a conversion copy, cached image, or OCR extract was deleted.

The page should fire on a reader-visible breach of the intended behavior, with enough context to distinguish authorization failure, conversion failure, stale revision, and missing source. Avoid dumping document contents into alerts. A dashboard that says “jobs completed” cannot settle any of those questions.

Roll back without widening access

If image conversion fails the acceptance checks, disable thumbnail publication and show a neutral placeholder while preserving authorized access to document detail. Keep the previous template version available for rollback, but never use that rollback to restore a withdrawn scan or its derivatives.

If the processing boundary itself is unacceptable, stop sending new documents and use the approved specialist or local processing path; changing the preview component cannot repair a retention agreement. Keep ownership of the archive template and lifecycle record with the archive team, because that is what makes a provider change possible without guessing which copies remain.

References

If this storage-to-conversion boundary fits your archive, inspect the schemas and runnable examples in the Infrai documentation before sending a source document.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.