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
75 changes: 69 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# optimole

`optimole` uploads local files into an [Optimole](https://optimole.com) media library, into the folders you
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.
choose, exports the library back as ZIP archives, builds the CDN URLs of its images, resized or
converted, and deletes images. 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
optimole delete --folder Campaigns/2025 # delete every image of a folder (asks first)
```

## Install
Expand Down Expand Up @@ -70,7 +71,8 @@ the storage URL the file bytes go to.

**Folder-scoped keys** (the ones starting with `optml-s`) work for uploads into the folders they were
granted (and their subfolders): always pass `--folder` (or `--folder-id`). They cannot create folders
(`folder_creation_forbidden`), upload into the library root (`folder_required`), list folders, or export.
(`folder_creation_forbidden`), upload into the library root (`folder_required`), list folders, export, or
delete.

The API base URL defaults to `https://dashboard.optimole.com/api`; override it with `--api-url` or
`OPTIMOLE_API_URL` (for example to try another environment). Only `https` URLs are
Expand Down Expand Up @@ -413,6 +415,66 @@ and `original_height`; a failed input gives `input`, `error` and `code` (`invali
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 delete`

Deletes images from the library for good: the CDN stops serving them, so pages that still use them show broken
images. Name the images with exactly one of:

| Flag | Deletes |
|---|---|
| `--keys FILE` | The images whose asset keys are in `FILE`, one per line (`-` reads stdin). Blank lines and spaces around a key are ignored, and a key listed twice counts once. |
| `--folder PATH` | Every image in this folder and all its subfolders, e.g. `Campaigns/2026` (the paths `optimole folders` prints; case does not matter). For a website's folder, also that website's images that are in no folder. |
| `--folder-id N` | The same, naming the folder by its ID. |
| `--site DOMAIN` | Every image of this website of the account, in any folder. `https://www.example.com/blog` means `example.com`. An internationalized domain must be given in its `xn--` form. |

| Flag | Meaning |
|---|---|
| `--yes` | Delete without asking. |
| `--json` | Print the result as one JSON object. |
| `--api-key`, `--api-url` | See [Authentication](#authentication). |

```sh
optimole delete --folder Campaigns/2025
optimole delete --site example.com
optimole delete --keys old-keys.txt
printf '%s\n' KEY1 KEY2 | optimole delete --keys - --yes # in a script: no question
```

Deleting needs a full-access API key; folder-scoped keys cannot delete. There are no approval codes: before
deleting, the command says what it will delete and asks you to type the folder's path (its name, for a path
longer than a terminal line holds), the website's domain, or `delete` for keys, then Enter. Any other answer,
Ctrl-C or Ctrl-D deletes nothing. `--yes` skips the question; without a terminal to ask on (in a script, or with
`--keys -`, where stdin carries the keys) the command refuses to run without `--yes`.

Folders and websites are kept. A folder is looked up first (`GET /api/folders`), so an unknown folder, or a path
two folders share, is refused before anything is deleted; name the folder with `--folder-id` then. A path is only
matched against the whole folder list: if the list does not come back whole, the command stops.

- **Keys** are sent 100 per request (`DELETE /api/assets` with `{"keys": [...]}`) and deleted at once. The server
checks every key of a request first: if one is not the account's, or is deleted already, it refuses the whole
request (`422`, naming the key) and deletes none of it. The run stops at the first request that fails; the
images of the requests before it stay deleted, and the command says how many keys to leave out to finish.
- **A folder or a website** (`{"folder": ID}` or `{"domain": "example.com"}`) is emptied in the background: the
server answers at once with how many images go (`202`), and they leave the library within a few minutes.
Images added afterwards are kept.
- **Retries.** A delete request is repeated only when the server cannot have received it (a `429`, or a
connection that failed before sending). A repeat after a lost answer would start a second background deletion,
or be refused for the keys it deleted. When an answer is lost (or is a `5xx`), the command says the images may
be deleted, or the deletion may have started.

Output:

```text
Deleted 250 images.
Deleting 1520 images in the background. They leave the library within a few minutes.
Nothing to delete: the folder "Campaigns/2025" has no images.
```

With `--json`, one object: `{"scope":"keys","requested":250,"deleted":250}` for keys (after a failure also
`error`, and `maybe_deleted` when the outcome of the failed request is unknown), and
`{"scope":"folder","folder_id":15,"folder":"Campaigns/2025","deleting":1520}` or
`{"scope":"site","domain":"example.com","deleting":1520}` for a folder or a website.

### `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 @@ -429,9 +491,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). 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. |
| 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. For `delete`: the images were deleted (a folder or a website: are being deleted). |
| 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. For `delete`: the folder was not found, the API refused or failed, or the typed answer did not match. |
| 2 | Usage or configuration error (bad flags, a directory without `-r`, an unknown `--domain`, missing key, non-https URL, `delete` without `--yes` and without a terminal). Nothing was uploaded, downloaded or deleted. For `url`, a missing key stops the run at the first asset key. |
| 130 | Interrupted with Ctrl-C. |

## Limits
Expand All @@ -443,6 +505,7 @@ Prints the version, commit and build date.
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.
- `optimole delete --keys` sends 100 keys per request.
- 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
5 changes: 3 additions & 2 deletions cmd/optimole/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Command optimole uploads local files into an Optimole media library and
// exports the library as ZIP archives.
// Command optimole uploads local files into an Optimole media library,
// exports the library as ZIP archives, builds the CDN URLs of its images, and
// deletes them.
package main

import (
Expand Down
16 changes: 8 additions & 8 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, looking an asset up, listing folders, and running asset
// exports and downloading their archives.
// URL, completing it, looking an asset up, deleting assets, listing folders,
// and running asset exports and downloading their archives.
package api

import (
Expand Down Expand Up @@ -362,12 +362,12 @@ const (
// retryIdempotent is for calls that are safe to repeat (listing,
// completion): 429, 502, 503, 504 and any network error.
retryIdempotent retryMode = iota
// retryUncommitted is for asset creation, where a repeat reserves a
// second asset. Only failures that prove nothing was reserved are
// retried: 429 (refused before the action), 503 with
// upload_service_unavailable (the server deletes the reservation before
// answering), and connection errors before the request was sent (DNS,
// dial, TLS handshake).
// retryUncommitted is for calls a repeat would do twice, or answer
// differently: asset creation (a second reservation), starting an export,
// deleting assets (a deleted key is refused). Only failures that prove nothing was done are retried: 429
// (refused before the action), 503 with upload_service_unavailable (the
// server deletes the reservation before answering), and connection
// errors before the request was sent (DNS, dial, TLS handshake).
retryUncommitted
)

Expand Down
78 changes: 78 additions & 0 deletions internal/api/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package api

import (
"context"
"fmt"
"net/http"
"strings"
)

// OpDeleteAssets names DELETE /assets in errors.
const OpDeleteAssets = "delete assets"

// MaxDeleteKeys is how many asset keys one DELETE /assets takes.
const MaxDeleteKeys = 100

// DeleteAssets deletes up to MaxDeleteKeys assets, named by their keys, at
// once. The server checks every key first: one that is not the account's, or
// is deleted already, refuses the whole request with a 422 naming it, and
// nothing is deleted.
//
// It is retried like asset creation, only when the server cannot have
// received it: a repeat after a lost answer would be refused for the keys it
// deleted, and a deletion that worked would look failed.
func (c *Client) DeleteAssets(ctx context.Context, keys []string) error {
if len(keys) == 0 || len(keys) > MaxDeleteKeys {
return fmt.Errorf("%s: %d keys given; one request takes 1 to %d", OpDeleteAssets, len(keys), MaxDeleteKeys)
}
lower := make([]string, len(keys))
for i, k := range keys {
if !assetKeyPattern.MatchString(k) {
return fmt.Errorf("%s: invalid asset key %q", OpDeleteAssets, k)
}
lower[i] = strings.ToLower(k)
}
body := struct {
Keys []string `json:"keys"`
}{lower}
return c.doJSON(ctx, OpDeleteAssets, http.MethodDelete, c.endpoint("assets"), body, nil, retryUncommitted)
}

// DeleteFolderAssets starts deleting every asset of a folder and its
// subfolders in the background; for a website's folder, also the website's
// assets that are in no folder. The folders are kept. It returns how many
// assets the server deletes (unset when it did not say).
//
// It is retried like asset creation, only when the server cannot have
// received it: a repeat would start a second background deletion.
func (c *Client) DeleteFolderAssets(ctx context.Context, folderID int64) (FlexInt, error) {
if folderID <= 0 {
return FlexInt{}, fmt.Errorf("%s: invalid folder ID %d", OpDeleteAssets, folderID)
}
return c.deleteInBackground(ctx, struct {
Folder int64 `json:"folder"`
}{folderID})
}

// DeleteSiteAssets starts deleting every asset of one of the account's
// websites, in any folder, in the background, and returns how many assets
// the server deletes (unset when it did not say). It is retried like
// DeleteFolderAssets.
func (c *Client) DeleteSiteAssets(ctx context.Context, domain string) (FlexInt, error) {
if strings.TrimSpace(domain) == "" {
return FlexInt{}, fmt.Errorf("%s: no domain", OpDeleteAssets)
}
return c.deleteInBackground(ctx, struct {
Domain string `json:"domain"`
}{domain})
}

func (c *Client) deleteInBackground(ctx context.Context, body any) (FlexInt, error) {
var out struct {
Assets FlexInt `json:"assets"`
}
if err := c.doJSON(ctx, OpDeleteAssets, http.MethodDelete, c.endpoint("assets"), body, &out, retryUncommitted); err != nil {
return FlexInt{}, err
}
return out.Assets, nil
}
Loading
Loading