Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# optimole

`optimole` uploads local files into an [Optimole](https://optimole.com) media library, into the folders you
choose, and exports the library back as ZIP archives. It is a single binary with no dependencies.
choose, exports the library back as ZIP archives, and builds the CDN URLs of its images, resized or
converted. It is a single binary with no dependencies.

```sh
optimole login # paste your API key once
optimole upload -r ./photos --folder Campaigns # photos/2024/a.jpg -> Campaigns/2024
optimole export start # export the library, download ./optimole-export-ID
optimole url KEY --width 800 --format webp # the image's CDN URL, 800 px wide, as WebP
```

## Install
Expand Down Expand Up @@ -346,6 +348,71 @@ An export is `queued`, `running`, `completed`, `failed` or `cancelled`. Once eve
exports (`export_limit_reached`); delete one with `optimole export delete ID`, or wait: finished exports are
deleted automatically 10 days after they finish.

### `optimole url [flags] KEY|URL...`

Prints the CDN URL of each asset key or Optimole URL, changed as the flags ask. Without arguments it reads one
key or URL per line from stdin, and also the JSON lines of `optimole upload --json` and `optimole url --json`,
so commands can be piped together.

```sh
# An asset's URL, 800 px wide, as WebP
optimole url 42cf6621995747f69a30d32f6a6c4f82 --width 800 --format webp

# The URLs of everything just uploaded, 1200 px wide
optimole upload -r ./photos --json | optimole url --width 1200

# Change a URL you already have (no API call, no key needed)
optimole url 'https://abc.i.optimole.com/w:auto/h:auto/q:auto/id:42cf…/directUpload/photo.jpg' --quality 60

# Chain changes
optimole url KEY --width 800 | optimole url --resize fill --height 400 --gravity top
```

| Flag | URL option | Meaning |
|---|---|---|
| `--width N\|auto`, `--height N\|auto` | `w:`, `h:` | Size in pixels. |
| `--resize TYPE` | `rt:` | `fit` (the default: keeps the aspect ratio and fits the size), `fill` (keeps the aspect ratio, fills the size and cuts what sticks out), `fill-down` (like `fill`, but an image smaller than the size is only cropped to its aspect ratio), `force` (ignores the aspect ratio) or `auto` (`fill` when the image and the size have the same orientation, else `fit`). |
| `--gravity POS` | `g:` | The part `fill` keeps: `center`, `top`, `bottom`, `left`, `right`, `top-left`, `top-right`, `bottom-left`, `bottom-right` or `smart`. |
| `--focus X:Y` | `g:fp:X:Y` | The point `fill` keeps, as fractions: `0:0` is the top left, `1:1` the bottom right. |
| `--quality Q` | `q:` | `auto`, `mauto`, `eco` or 1–100. |
| `--format F` | `f:` | `auto` removes `f:`, and the CDN serves each browser AVIF or WebP when it accepts them; or force `webp`, `jpg`, `png`, `gif`, `ico`, `jxl` or `heic`. |
| `--dpr N` | `dpr:` | Pixel density, 1–5. |
| `--download` | `att:1` | Serve the image as a download. |
| `--set NAME:VALUE` | any | Any other option the CDN reads, e.g. `--set bl:5` (blur) or `--set c:300:200:ce` (crop); repeatable. Unknown names are refused, because the CDN drops them silently. |
| `--unset NAME` | | Remove an option from the URL; repeatable. |
| `--reset` | | Start from `w:auto/h:auto/q:auto`, dropping the URL's options. |
| `-c`, `--concurrency N` | | Keys looked up in parallel, 1–16 (default 4). |
| `--json` | | One JSON object per input. |
| `--api-key`, `--api-url` | | See [Authentication](#authentication). |

#### How URLs are changed

- A key is looked up with `GET /api/assets/{key}`, which gives its URL on the account's delivery domain
(custom domains included). A URL is changed locally: no API call and no key are needed.
- An option already in the URL is replaced, never added a second time: the CDN uses only the first copy of an
option, so a second `w:` would do nothing. Options the flags do not name are kept.
- Options are written in the order the CDN applies them, so the same changes always give the same URL.
- Two flags that change the same option (`--width` and `--set w:…`, `--gravity` and `--focus`) are an error.
- Values are checked the way the CDN reads them. The CDN answers a bad `w`, `h` or `q` with an empty image,
and ignores a cache buster (`cb:`) unless it looks like `abc.1` (3 or more characters, a dot, then more), so
those are refused.
- `--format avif` is refused: the CDN drops `f:avif` and picks the format from the browser instead, which is
what `--format auto` asks for.
- Files that are not images (their URLs have `/files/`, or CSS and JS assets) are printed as they are; image
flags are an error for them.
- A URL the CDN would misread is refused: one with spaces or control characters (a newline would also print
two URLs), or one whose origin path holds a segment the CDN takes for an option, such as `w:9` when the URL
sets no `w:` of its own.

#### Output

One URL per line, in input order; a failed input is reported on stderr and the others are still printed. With
`--json`, each input gives one object: `input`, `url` and, when known, `key`, `mime_type`, `original_width`
and `original_height`; a failed input gives `input`, `error` and `code` (`invalid_input`, `not_found`,
`no_url` or `not_an_image`). A key the dashboard does not know and a key outside a folder-scoped key's folders
both answer `not_found`. A rejected API key stops the run. Messages never show the API keys in use (flag,
environment or saved key), and a line that looks like an API key is not echoed at all.

### `optimole login`, `optimole logout`

`login` reads the key from stdin: type or paste it at the prompt, or pipe it (`optimole login < key.txt`;
Expand All @@ -362,9 +429,9 @@ Prints the version, commit and build date.

| Code | Meaning |
|---|---|
| 0 | Every selected file was uploaded (skipped files do not count). For `export start` and `export download`: the export completed and every selected archive is on disk (`export start --no-wait`: the export was started, or one was already running). |
| 1 | At least one file failed, there was nothing to upload, or the output could not be written. For exports: the export failed or was cancelled, an archive failed, `--timeout` passed, or the account keeps the most exports it may. |
| 2 | Usage or configuration error (bad flags, a directory without `-r`, an unknown `--domain`, missing key, non-https URL). Nothing was uploaded or downloaded. |
| 0 | Every selected file was uploaded (skipped files do not count). For `export start` and `export download`: the export completed and every selected archive is on disk (`export start --no-wait`: the export was started, or one was already running). For `url`: every input was printed. |
| 1 | At least one file failed, there was nothing to upload, or the output could not be written. For exports: the export failed or was cancelled, an archive failed, `--timeout` passed, or the account keeps the most exports it may. For `url`: an input failed (the others are still printed), or the API key was rejected. |
| 2 | Usage or configuration error (bad flags, a directory without `-r`, an unknown `--domain`, missing key, non-https URL). Nothing was uploaded or downloaded. For `url`, a missing key stops the run at the first asset key. |
| 130 | Interrupted with Ctrl-C. |

## Limits
Expand All @@ -373,7 +440,8 @@ Prints the version, commit and build date.
Reservations count towards it until they are completed or settled by the server's hourly cleanup, which
is another reason the CLI avoids reserving an asset twice for one file.
- The API allows 300 requests per minute per account; each file takes 2 API requests plus the storage upload,
so very large batches may see `429` answers, which the CLI waits out.
and each key given to `optimole url` one, so very large batches may see `429` answers, which the CLI waits
out. URLs given to `optimole url` take none.
- A presigned upload URL is valid for 15 minutes.
- An account runs one export at a time and keeps at most 5. Export archives are about 1 GB each (a part can be
larger when its first files are), and their download URLs are valid for a day.
Expand Down
32 changes: 25 additions & 7 deletions internal/api/client.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package api is a small client for the parts of the Optimole dashboard API
// the CLI uses: reserving an asset, sending its bytes to the presigned storage
// URL, completing it, listing folders, and running asset exports and
// downloading their archives.
// URL, completing it, looking an asset up, listing folders, and running asset
// exports and downloading their archives.
package api

import (
Expand Down Expand Up @@ -42,11 +42,16 @@ const (
const (
OpCreateAsset = "create asset"
OpCompleteUpload = "complete upload"
OpGetAsset = "get asset"
OpListFolders = "list folders"
)

var assetKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)

// IsAssetKey reports whether s has the form of an asset key: 32 hexadecimal
// characters.
func IsAssetKey(s string) bool { return assetKeyPattern.MatchString(s) }

// Options configures a Client.
type Options struct {
// BaseURL is the API root, e.g. https://dashboard.optimole.com/api.
Expand Down Expand Up @@ -286,18 +291,31 @@ func (c *Client) CompleteAsset(ctx context.Context, key string, dims *Dimensions
if err := c.doJSON(ctx, OpCompleteUpload, http.MethodPost, c.endpoint("assets", strings.ToLower(key), "completion"), body, &raw, retryIdempotent); err != nil {
return nil, err
}
return decodeAsset(raw)
return decodeAsset(OpCompleteUpload, raw)
}

// GetAsset looks an asset up by its key. The dashboard answers 403 both for
// a key it does not know and for an asset the API key may not see.
func (c *Client) GetAsset(ctx context.Context, key string) (*Asset, error) {
if !assetKeyPattern.MatchString(key) {
return nil, fmt.Errorf("%s: invalid asset key %q", OpGetAsset, key)
}
var raw json.RawMessage
if err := c.doJSON(ctx, OpGetAsset, http.MethodGet, c.endpoint("assets", strings.ToLower(key)), nil, &raw, retryIdempotent); err != nil {
return nil, err
}
return decodeAsset(OpGetAsset, raw)
}

// decodeAsset accepts a bare AssetResource and, defensively, one wrapped in
// "asset" or "data".
func decodeAsset(raw json.RawMessage) (*Asset, error) {
// "asset" or "data". op names the call in errors.
func decodeAsset(op string, raw json.RawMessage) (*Asset, error) {
var wrapped struct {
Asset *Asset `json:"asset"`
Data *Asset `json:"data"`
}
if err := json.Unmarshal(raw, &wrapped); err != nil {
return nil, fmt.Errorf("complete upload: invalid JSON response: %w", err)
return nil, fmt.Errorf("%s: invalid JSON response: %w", op, err)
}
if wrapped.Asset != nil {
return wrapped.Asset, nil
Expand All @@ -307,7 +325,7 @@ func decodeAsset(raw json.RawMessage) (*Asset, error) {
}
var a Asset
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("complete upload: invalid JSON response: %w", err)
return nil, fmt.Errorf("%s: invalid JSON response: %w", op, err)
}
return &a, nil
}
Expand Down
64 changes: 63 additions & 1 deletion internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ func TestDecodeAssetShapes(t *testing.T) {
`{"asset":{"resourceS3":"k","url":"https://x/u","folder_id":"12"}}`,
`{"data":{"resourceS3":"k","url":"https://x/u","folder_id":12}}`,
} {
a, err := decodeAsset([]byte(body))
a, err := decodeAsset(OpCompleteUpload, []byte(body))
if err != nil || a.URL != "https://x/u" || a.FolderID.Value != 12 {
t.Errorf("decodeAsset(%s) = %+v, %v", body, a, err)
}
Expand Down Expand Up @@ -750,3 +750,65 @@ func TestBackoffIsCapped(t *testing.T) {
}
}
}

func TestGetAsset(t *testing.T) {
srv := testserver.New(t)
c, sl := newTestClient(t, srv.URL)
ctx := context.Background()
key := srv.AddAsset(testserver.Asset{Filename: "photo.jpg", ContentType: "image/jpeg", Size: 9, Width: 1200, Height: 800})

a, err := c.GetAsset(ctx, strings.ToUpper(key))
if err != nil {
t.Fatal(err)
}
want := "https://app.i.optimole.com/w:auto/h:auto/q:auto/id:" + key + "/directUpload/photo.jpg"
if a.Key != key || a.URL != want || a.MimeType != "image/jpeg" || a.Width.Value != 1200 || a.Height.Value != 800 {
t.Fatalf("asset = %+v", a)
}
if ev := srv.Events()[0]; ev.Route != testserver.RouteAsset || ev.Key != key {
t.Fatalf("event = %+v", ev)
}

// An unknown key is a 403, which is final.
_, err = c.GetAsset(ctx, strings.Repeat("ab", 16))
var apiErr *Error
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusForbidden || apiErr.Op != OpGetAsset || srv.Count(testserver.RouteAsset) != 2 {
t.Fatalf("err = %v, %d calls", err, srv.Count(testserver.RouteAsset))
}

// A busy dashboard is retried: the lookup is safe to repeat.
srv.Inject(testserver.RouteAsset, testserver.Response{Status: http.StatusServiceUnavailable, Body: `{"message":"Busy."}`})
if _, err := c.GetAsset(ctx, key); err != nil || srv.Count(testserver.RouteAsset) != 4 || len(sl.all()) != 1 {
t.Fatalf("err = %v, %d calls, %d waits", err, srv.Count(testserver.RouteAsset), len(sl.all()))
}

sent := len(srv.Events())
for _, bad := range []string{"", "../../me", key + "0", "zz" + key[2:]} {
if _, err := c.GetAsset(ctx, bad); err == nil {
t.Fatalf("GetAsset(%q) succeeded", bad)
}
}
if n := len(srv.Events()); n != sent {
t.Fatalf("invalid keys sent %d requests", n-sent)
}
}

func TestGetAssetHonoursFolderScopedKeys(t *testing.T) {
srv := testserver.New(t)
shared := srv.AddFolder("Shared")
other := srv.AddFolder("Other")
srv.AddKey("optml-s-scoped", testserver.Key{Scoped: true, Granted: []int64{shared}})
inShared := srv.AddAsset(testserver.Asset{Filename: "a.jpg", ContentType: "image/jpeg", FolderID: shared})
inOther := srv.AddAsset(testserver.Asset{Filename: "b.jpg", ContentType: "image/jpeg", FolderID: other})
c, err := New(Options{BaseURL: srv.URL, APIKey: "optml-s-scoped", UserAgent: "optimole-cli/test"})
if err != nil {
t.Fatal(err)
}
if _, err := c.GetAsset(context.Background(), inShared); err != nil {
t.Fatalf("asset in a shared folder: %v", err)
}
var apiErr *Error
if _, err := c.GetAsset(context.Background(), inOther); !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusForbidden {
t.Fatalf("asset outside the shared folders: %v", err)
}
}
3 changes: 3 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ type Asset struct {
Status string `json:"itemStatus"`
MimeType string `json:"mimeType"`
URL string `json:"url"`
// Width and Height are the original image's size in pixels, when known.
Width FlexInt `json:"originalWidth"`
Height FlexInt `json:"originalHeight"`
}

// UploadInstructions tells the client how to send the file bytes to storage.
Expand Down
42 changes: 32 additions & 10 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ func DefaultEnv(build BuildInfo) Env {
}
}

const mainUsage = `optimole uploads local files into an Optimole media library and exports it.
const mainUsage = `optimole uploads local files into an Optimole media library, exports it, and
builds the CDN URLs of its images.

Usage:
optimole <command> [flags] [arguments]
Expand All @@ -71,6 +72,7 @@ Commands:
upload Upload files and directories into library folders
folders List the library's folders
export Export the library as ZIP archives and download them
url Print an asset's CDN URL, resized or converted
login Save an API key (read from stdin)
logout Delete the saved API key
version Print the version
Expand All @@ -92,6 +94,8 @@ func Run(ctx context.Context, args []string, env Env) int {
return runFolders(ctx, rest, env)
case "export":
return runExport(ctx, rest, env)
case "url":
return runURL(ctx, rest, env)
case "login":
return runLogin(ctx, rest, env)
case "logout":
Expand Down Expand Up @@ -186,9 +190,20 @@ func configPath(env Env) (string, error) {
return config.Path(dir), nil
}

// connect resolves the key and URL and builds a client. Errors are
// configuration errors (exit 2); they never contain the key.
func (c *commonFlags) connect(env Env) (*session, error) {
// resolved is what the connection flags, the environment and the saved
// config lead to.
type resolved struct {
settings config.Settings
err error
path string
pathErr error
// redact hides every key involved: the one used, and any given by flag
// or environment.
redact redact.Redactor
}

// resolve finds the key and URL without connecting.
func (c *commonFlags) resolve(env Env) resolved {
path, pathErr := configPath(env)
in := config.Inputs{FlagAPIKey: c.apiKey, FlagAPIURL: c.apiURL, Getenv: env.Getenv}
if pathErr == nil {
Expand All @@ -199,15 +214,22 @@ func (c *commonFlags) connect(env Env) (*session, error) {
if env.Getenv != nil {
envKey = env.Getenv(config.EnvAPIKey)
}
red := redact.New(settings.APIKey, c.apiKey, envKey)
if err != nil {
if errors.Is(err, config.ErrNoAPIKey) && pathErr != nil {
return nil, fmt.Errorf("%w (%v)", err, pathErr)
return resolved{settings: settings, err: err, path: path, pathErr: pathErr, redact: redact.New(settings.APIKey, c.apiKey, envKey)}
}

// connect resolves the key and URL and builds a client. Errors are
// configuration errors (exit 2); they never contain the key.
func (c *commonFlags) connect(env Env) (*session, error) {
r := c.resolve(env)
settings, red := r.settings, r.redact
if r.err != nil {
if errors.Is(r.err, config.ErrNoAPIKey) && r.pathErr != nil {
return nil, fmt.Errorf("%w (%v)", r.err, r.pathErr)
}
return nil, errors.New(red.Error(err))
return nil, errors.New(red.Error(r.err))
}
if settings.LooseConfig {
fmt.Fprintf(env.Stderr, "warning: %s can be read by other users; run: chmod 600 %q\n", path, path)
fmt.Fprintf(env.Stderr, "warning: %s can be read by other users; run: chmod 600 %q\n", r.path, r.path)
}
client, err := newClient(env, settings.APIURL, settings.APIKey)
if err != nil {
Expand Down
Empty file.
13 changes: 0 additions & 13 deletions internal/cli/optimole-export-1/export.json

This file was deleted.

Loading
Loading