imageedit

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package imageedit is the GoFastr image crop / annotate / redact plugin. It applies the pdf plugin's design — the cage is the product, not the tax — to a second file format, and in doing so proves that shape was general rather than a one-off.

Bytes arrive over the postMessage bridge: the host fetches the source image (GET /img/{id}, session + CSRF attached) and pushes it into the frame; the frame runs under connect-src 'none' and fetches nothing. A frame holding a confidential screenshot therefore cannot exfiltrate it.

The canonical doc (schema imageedit-v1) is an OPERATION LIST, never pixels: {src, crop, rotate, annotations[], redactions[]} in source-image coordinates. The frame previews by applying that list to a canvas; the SERVER re-renders the same list with the standard library's image packages for every export, strips EXIF by full re-encode, enforces size and dimension caps, and verifies redactions against the produced bytes before releasing them. A client that lies about what it did cannot change what gets stored — the stored bytes are a function of the doc, rendered by Go.

Operation order is FIXED and documented (docs/imageedit.md): crop → rotate → annotate → redact. Crop then rotate is not the same picture as rotate then crop; pinning the order (and having both renderers implement the exact same integer pipeline) is what keeps the preview and the server output in agreement.

Capabilities: document:read, document:write, theme:read are always granted; upload:images is optional, appended exactly when the host wires WithUploadHandler. pluginhost.Allow is a capability gate, NOT authentication: it passes for anonymous callers, so any route that WRITES (POST /save, POST /export, POST /upload) must be documented as requiring the host to check the session in its own handler. See docs/imageedit.md.

Index

Constants

View Source
const (
	Name             = "imageedit"
	Version          = "0.1.0"
	RoutePrefix      = "/__gofastr/plugin/imageedit"
	EditorHTMLURL    = RoutePrefix + "/editor.html"
	EditorJSURL      = RoutePrefix + "/editor.js"
	EditorCSSURL     = RoutePrefix + "/editor.css"
	AdapterScriptURL = RoutePrefix + "/adapter.js"
	ConfigScriptURL  = RoutePrefix + "/config.js"
	ImageRoute       = RoutePrefix + "/img/{id}"
	UploadURL        = RoutePrefix + "/upload"
	SaveURL          = RoutePrefix + "/save"
	ExportURL        = RoutePrefix + "/export"
	DemoURL          = "/imageedit"
	SchemaVersion    = "imageedit-v1"

	// CapUploadImages gates POST /upload (a new source image entering the
	// plugin). It is OPTIONAL — it is NOT in [DefaultCapabilities].
	// [WithUploadHandler] appends it (the pdf WithExportHandler pattern):
	// reading bytes into the host's storage is ingress the host explicitly
	// turned on.
	CapUploadImages = "upload:images"
)

Identity and route constants. Both this plugin and host/adapter.js hard-code these exactly (protocol-v1.md §2/§10). The demo lives at /imageedit.

These mirror the imageedit row in plugins.json; internal/registry tests pin Name + RoutePrefix against that row, so they MUST NOT drift.

Variables

View Source
var (
	// ErrTooLarge: bytes or declared dimensions exceed a host ceiling.
	ErrTooLarge = errors.New("image exceeds a size or dimension cap")
	// ErrUnsupportedFormat: not a decodable png/jpeg.
	ErrUnsupportedFormat = errors.New("unsupported image format (want png or jpeg)")
	// ErrBadDoc: the operation list is structurally invalid.
	ErrBadDoc = errors.New("invalid imageedit-v1 doc")
	// ErrCropOutside: the crop rect does not intersect the image.
	ErrCropOutside = errors.New("crop rect outside the image")
	// ErrSrcMismatch: doc.src.sha256 does not match the resolved bytes.
	ErrSrcMismatch = errors.New("source image digest mismatch")
	// ErrRedactionLeak: verification found redacted content in the output.
	ErrRedactionLeak = errors.New("redaction verification failed")
	// ErrConflict: a save/export handler rejected the write as stale.
	ErrConflict = errors.New("conflicting revision")
)

Sentinel errors the handlers map onto HTTP status + code. They are the plugin's whole failure vocabulary for the render path.

Functions

func DefaultCapabilities

func DefaultCapabilities() []string

DefaultCapabilities is the always-on grant set advertised to the frame: reading the source image, persisting the operation list, and bridging theme tokens. upload:images is deliberately absent — it is an optionalCapability, appended by WithUploadHandler.

func Mount

func Mount(cfg MountConfig) render.HTML

Mount renders the generic mount marker plus the hidden input the adapter syncs on docChanged. Drop it into a form. All interpolated values are HTML-escaped inside pluginhost.MountMarker.

func SampleImage

func SampleImage() []byte

SampleImage returns the generated demo image as PNG bytes. The result is a cached copy; callers must not mutate it.

func TextWidth

func TextWidth(s string, scale int) int

TextWidth is the advance width of s at scale in pixels (exported for the frame's hit-testing parity and for tests).

func UIHostOption

func UIHostOption() uihost.Option

UIHostOption injects the platform broker, this plugin's config script, and this plugin's adapter (in that order — the adapter reads the config global the config script publishes, and registers with the broker the former defines).

func ValidateDoc

func ValidateDoc(doc Doc) error

ValidateDoc checks an operation list against the structural bounds. The same rules run in /save (before persisting) and /export (before rendering) so the two routes cannot disagree about what a doc is. nil crop, rotate 0 and empty lists are all valid.

Types

type Annotation

type Annotation struct {
	ID    string `json:"id"`
	Type  string `json:"type"`
	Color string `json:"color"`
	Width int    `json:"width"`
	X     int    `json:"x"`
	Y     int    `json:"y"`
	W     int    `json:"w"`
	H     int    `json:"h"`
	X2    int    `json:"x2"`
	Y2    int    `json:"y2"`
	Size  int    `json:"size"`
	Text  string `json:"text"`
}

Annotation is one non-destructive marking drawn above the (cropped, rotated) image. Geometry is authored in SOURCE-image pixels and mapped forward through crop+rotate at render time, so annotations stay pinned to image content across later crop/rotate edits.

Type is "rect" (stroked rectangle, X/Y/W/H), "arrow" (X,Y tail → X2,Y2 head) or "text" (X,Y top-left anchor, Size = glyph cell scale). Color is #RRGGBB — a content color drawn into the image, not a theme token. Width is the stroke thickness in output pixels (≥1).

type ComposeResult

type ComposeResult struct {
	Out            *image.NRGBA
	Pre            *image.NRGBA
	RedactionRects []Rect // output-space, one per doc redaction (same order)
	Width, Height  int
}

ComposeResult is everything the export path needs after rendering: the final image, the pre-redaction composite (annotations applied, redactions not — the verifier's "what was there" reference), and the output-space redaction rects.

type Doc

type Doc struct {
	SchemaVersion string       `json:"schemaVersion"`
	Src           SrcRef       `json:"src"`
	Crop          *Rect        `json:"crop,omitempty"` // nil = uncropped
	Rotate        int          `json:"rotate"`         // 0 | 90 | 180 | 270, clockwise
	Annotations   []Annotation `json:"annotations"`
	Redactions    []Redaction  `json:"redactions"`
	Rev           int          `json:"rev"`
}

Doc is the canonical imageedit-v1 operation list. It round-trips through the hidden form field like every other plugin's doc; pixels never do.

type ExportRequest

type ExportRequest struct {
	DocID  string
	Doc    Doc
	Bytes  []byte
	Format string // "png" | "jpeg"
	Width  int
	Height int
	SHA256 string // hex digest of Bytes
	Report VerifyReport
}

ExportRequest is handed to the host's export handler: the authoritative re-rendered bytes (Go composed them from the operation list, stripped EXIF and verified the redactions before encoding), plus the render facts and the verification report. The handler stores them and returns a URL.

type MountConfig

type MountConfig struct {
	// DocID is the persistence key for the operation-list doc.
	DocID string
	// Doc is the initial doc JSON server-rendered into the marker
	// (data-fui-plugin-doc) — the frame reads crop/rotate/annotations/
	// redactions out of it on init and requests the image it names.
	Doc string
	// Field is the hidden-input name the adapter mirrors the current doc
	// into on docChanged, so a normal form POST round-trips it. Defaults to
	// "imageedit_doc". The name is published on the marker as
	// data-fui-plugin-field, which is how the adapter finds it — the input
	// itself is rendered after the marker, not inside it.
	Field string
	// MinHeight is the initial iframe height before the editor sizes itself.
	MinHeight string
	// Capabilities is an optional CSV grant override.
	Capabilities string
}

MountConfig configures Mount.

type Option

type Option func(*Plugin)

Option configures a Plugin.

func WithCapabilities

func WithCapabilities(caps ...string) Option

WithCapabilities overrides the grant set advertised to the frame. Default: DefaultCapabilities. upload:images is appended separately by WithUploadHandler even when this fully replaces the set — the gate is on ingress the host explicitly enabled, so silently dropping it would just break loading with a 403.

Grants are matched with the framework's scope grammar at runtime, so wildcards are legal here: "upload:*" and "*:*" imply upload:images. New requires the matching handler for anything the grant set implies — a wildcard that implies an optional capability without its handler is a construction panic, exactly like the literal capability (see [Plugin.grantsCapability]).

func WithDemoPage

func WithDemoPage() Option

WithDemoPage registers the self-contained themed demo page at DemoURL.

func WithDemoRoute

func WithDemoRoute(path string) Option

WithDemoRoute overrides where WithDemoPage mounts the demo (default DemoURL, "/imageedit").

func WithDevGrantAll

func WithDevGrantAll() Option

WithDevGrantAll short-circuits the capability gate (demo / tests). It bypasses BOTH gate sides, so the write routes behind it must still fail closed on an unwired handler (a clear error, never a panic) — see handlers.go.

func WithExportHandler

func WithExportHandler(fn func(ctx context.Context, req ExportRequest) (string, error)) Option

WithExportHandler overrides the export hook behind POST /export. The plugin re-renders the doc server-side (render.go), verifies the redactions against the produced bytes, and hands the handler the result; the handler stores it and returns a URL. The default handler echoes a data: URL — enough to prove the round trip; a production host points this at its file store.

func WithJPEGQuality

func WithJPEGQuality(q int) Option

WithJPEGQuality sets the re-encode quality for JPEG sources (1–95). New panics outside 1..95 because a zero quality silently produces a mud brick and a >95 file bloats for no visible gain.

func WithMaxBytes

func WithMaxBytes(n int64) Option

WithMaxBytes sets the host-enforced ceiling on image bytes in transit (source, upload and produced output). Checked before decode/buffer.

func WithMaxDim

func WithMaxDim(n int) Option

WithMaxDim sets the per-axis dimension ceiling, checked at the same header-read stage as WithMaxPixels.

func WithMaxPixels

func WithMaxPixels(n int) Option

WithMaxPixels sets the decode budget: images whose header declares more pixels than this are refused (413) BEFORE image.Decode allocates them.

func WithSaveHandler

func WithSaveHandler(fn func(ctx context.Context, req SaveRequest) error) Option

WithSaveHandler overrides the operation-list persistence hook. The default stores the canonical doc JSON in an in-memory map keyed by DocID.

func WithSource

func WithSource(fn func(ctx context.Context, id string) ([]byte, error)) Option

WithSource installs the image resolver backing GET /img/{id}. The default serves SampleImage; a production host points this at its media store. The function runs in the host page with the session and CSRF token attached — the frame cannot call /img/{id} (connect-src 'none'), so authorization stays here at the data layer. Returning (nil, nil) means "no such image" (404).

func WithUploadHandler

func WithUploadHandler(fn func(ctx context.Context, req UploadRequest) (string, error)) Option

WithUploadHandler installs the ingress hook behind POST /upload AND opts the plugin into the optional upload:images capability (appended to the grant set if not already present). The handler is the host's chance to authorize the upload against the real session: pluginhost.Allow is a capability gate, not authentication, and it passes for anonymous callers.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the image editor. It implements framework.Plugin and mirrors the pdf/datagrid shape: opaque-origin sandboxed iframe, protocol v1 over postMessage, go:embed'd frame bundle, capability gate, host-side RPC routes. The difference is where the pixels come from: the server renders them from the doc, so the doc is the only thing that crosses.

func New

func New(opts ...Option) *Plugin

New constructs a Plugin. All fail-loud validation runs here so a misconfiguration aborts construction rather than mounting a silent hole.

func (*Plugin) Capabilities

func (p *Plugin) Capabilities() []string

Capabilities returns the grant set this plugin advertises to the frame.

func (*Plugin) Init

func (p *Plugin) Init(app *framework.App) error

Init registers every asset and RPC route on the app's router.

func (*Plugin) LoadDoc

func (p *Plugin) LoadDoc(ctx context.Context, docID string) (docJSON string, ok bool)

LoadDoc returns the last-saved operation-list JSON for docID (demo round-trip). ok is false when the doc has never been saved.

func (*Plugin) Manifest

func (p *Plugin) Manifest() pluginhost.Manifest

func (*Plugin) Name

func (p *Plugin) Name() string

type Rect

type Rect struct {
	X int `json:"x"`
	Y int `json:"y"`
	W int `json:"w"`
	H int `json:"h"`
}

Rect is an axis-aligned integer rectangle in source-image pixels: inclusive origin (X, Y), exclusive extent (X+W, Y+H). 90° rotations keep rects axis-aligned, so the whole geometry vocabulary survives rotation.

func SampleTokenRect

func SampleTokenRect() Rect

SampleTokenRect returns the source-image rect covering the sample's visible secret token. The redaction demo, the e2e journey and the Go tests all aim here.

type Redaction

type Redaction struct {
	ID   string `json:"id"`
	Rect Rect   `json:"rect"`
	Fill string `json:"fill"`
}

Redaction is one destructive removal: the region is FILLED in the final output (default black), never covered by a drawable object. Fill is #RRGGBB.

type RenderOutput

type RenderOutput struct {
	Bytes  []byte
	Format string
	Width  int
	Height int
	SHA256 string
	Report VerifyReport
}

RenderOutput is the finished export artifact plus its verification.

type SaveRequest

type SaveRequest struct {
	DocID         string
	Doc           Doc
	DocJSON       string // raw canonical JSON, verbatim (the authoritative record)
	SchemaVersion string
	Rev           int
}

SaveRequest is the operation-list persist signal (POST /save).

type SrcRef

type SrcRef struct {
	Kind   string `json:"kind"`
	Ref    string `json:"ref"`
	SHA256 string `json:"sha256,omitempty"`
}

SrcRef identifies the image an operation list applies to. Kind is always "id": the host resolves Ref through WithSource. SHA256 optionally binds the doc to the exact source bytes (hex, lowercase); on mismatch the export is refused rather than applying coordinates to a different picture.

type UploadRequest

type UploadRequest struct {
	Bytes  []byte
	Format string // "png" | "jpeg"
	Width  int
	Height int
}

UploadRequest is handed to the host's upload handler: the raw image bytes the frame read from a local file (the frame's file picker is the only thing the sandbox grants it; the bytes still cannot LEAVE except over the bridge), their sniffed format and header dimensions. The handler stores them and returns the id the doc's Src.Ref will reference.

type VerifyReport

type VerifyReport struct {
	RedactionsChecked int      `json:"redactionsChecked"`
	Failed            []string `json:"failed,omitempty"`  // ids whose region still leaks
	Vacuous           []string `json:"vacuous,omitempty"` // ids over content already == fill
	EXIFStripped      bool     `json:"exifStripped"`      // output bytes carry no EXIF
	DimensionsMatch   bool     `json:"dimensionsMatch"`   // composed dims == encoded dims
	PixelsSampled     int      `json:"pixelsSampled"`     // pixels the fill check walked
	Pass              bool     `json:"pass"`
}

VerifyReport is the bounded audit record that travels with every export. Verdicts plus per-redaction ids, never an unbounded pixel dump.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL