Developers
Call the Pixel Sentinel API from your own server
The same tour in seven languages: get a token, read the account, upload an image, embed it, list, create a download link, set approved sites, read traffic and patrols, and delete. Each client is one file with no dependencies beyond the language's usual HTTP and JSON libraries. This website runs on the Node.js one.
1. Create an API client
In the Pixel Sentinel dashboard, open the API tab, choose Create an API client, and tick the scopes it needs. Copy the client secret straight away: it's shown once. Keep both on your server, never in browser code.
| Scope | Endpoints | For |
|---|---|---|
account:read | GET /client/v1/account | Name, status, credits, views this month, approved sites |
images:read | GET /client/v1/images, GET /client/v1/images/{id} | List (newest first, q filters by name) and read images |
images:write | POST /client/v1/images, DELETE /client/v1/images/{id} | Upload (multipart, field "file") and delete |
links:write | POST /client/v1/images/{id}/download-link | Signed, watermarked full-size download links |
domains:write | PUT /client/v1/domains | Replace the approved sites list |
traffic:read | GET /client/v1/traffic | The Traffic report: totals, daily, top sources |
patrols:read | GET /client/v1/patrols, …/{id}, …/{id}/report.csv | Patrols, allowance and reports |
patrols:write | POST /client/v1/patrols | Search the web for copies (sends unprotected copies to Google and TinEye) |
2. Get a token
OAuth 2.0 client credentials. The token lasts an hour; the clients below keep it and fetch a new one when it's about to expire, or once when a call answers 401.
curl -u "$PS_CLIENT_ID:$PS_CLIENT_SECRET" -d grant_type=client_credentials \
https://argus.pixelsentinel.net/oauth/token
# {"access_token":"pst_…","token_type":"Bearer","expires_in":3600,"scope":"account:read images:read …"}3. Run a sample
Every sample reads PS_CLIENT_ID, PS_CLIENT_SECRET and, optionally, PS_API_BASE. Add --keep to keep the uploaded image, or --patrol to run a patrol on it too.
In samples/python:
pip install -r requirements.txt
python demo.py photo.jpgpixelsentinel.py
"""A small Pixel Sentinel Client API wrapper (Python 3.9+, `pip install requests`).
Authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before it
expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
from pixelsentinel import PixelSentinel
ps = PixelSentinel(client_id, client_secret)
for image in ps.list_images(q="photo--")["images"]:
print(image["id"], image["name"])
"""
from __future__ import annotations
import mimetypes
import os
import time
from typing import Any, Iterable, Iterator
import requests
DEFAULT_BASE_URL = "https://argus.pixelsentinel.net"
class PixelSentinelError(Exception):
"""An error answer from the API: `status` is the HTTP status, `body` the parsed JSON (if any)."""
def __init__(self, status: int, message: str, body: Any = None):
super().__init__(f"{status}: {message}")
self.status = status
self.body = body
class PixelSentinel:
def __init__(self, client_id: str, client_secret: str, base_url: str = DEFAULT_BASE_URL,
scope: str | None = None, timeout: float = 60):
self.client_id = client_id
self.client_secret = client_secret
self.base_url = base_url.rstrip("/")
self.scope = scope # ask for fewer scopes than the client has, e.g. "images:read"
self.timeout = timeout
self.session = requests.Session()
self._token: str | None = None
self._expires = 0.0
@classmethod
def from_env(cls) -> "PixelSentinel":
"""PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE."""
return cls(os.environ["PS_CLIENT_ID"], os.environ["PS_CLIENT_SECRET"],
os.environ.get("PS_API_BASE", DEFAULT_BASE_URL))
# ------------------------------------------------------------------ auth
def token(self, force: bool = False) -> str:
"""POST /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour."""
if not force and self._token and time.time() < self._expires - 60:
return self._token
data = {"grant_type": "client_credentials"}
if self.scope:
data["scope"] = self.scope
res = self.session.post(f"{self.base_url}/oauth/token", data=data,
auth=(self.client_id, self.client_secret), timeout=self.timeout)
body = _json(res)
if not res.ok:
raise PixelSentinelError(res.status_code, body.get("error_description") or body.get("error") or res.reason, body)
self._token = body["access_token"]
self._expires = time.time() + body["expires_in"]
return self._token
def _request(self, method: str, path: str, *, raw: bool = False, **kwargs) -> Any:
for attempt in (1, 2):
headers = {"Authorization": f"Bearer {self.token(force=attempt == 2)}"}
res = self.session.request(method, f"{self.base_url}/client/v1{path}", headers=headers,
timeout=self.timeout, **kwargs)
if res.status_code != 401:
break
# The token expired early or the secret was replaced: get a new one, once.
if "files" in kwargs:
for f in kwargs["files"].values():
f[1].seek(0)
if raw and res.ok:
return res
body = _json(res)
if not res.ok:
raise PixelSentinelError(res.status_code, body.get("error") or res.reason, body)
return body
# ------------------------------------------------------------------ account
def get_account(self) -> dict:
"""account:read - name, status, credits, views this month, approved sites."""
return self._request("GET", "/account")
def set_domains(self, domains: Iterable[str]) -> list[str]:
"""domains:write - replace the whole approved-sites list ("*.example.com" covers subdomains)."""
return self._request("PUT", "/domains", json={"domains": list(domains)})["domains"]
# ------------------------------------------------------------------ images
def list_images(self, q: str | None = None, limit: int = 25, offset: int = 0) -> dict:
"""images:read - newest first; `q` filters by name. Returns {images, total, offset, limit}."""
params = {"limit": limit, "offset": offset}
if q:
params["q"] = q
return self._request("GET", "/images", params=params)
def iter_images(self, q: str | None = None, page_size: int = 100) -> Iterator[dict]:
"""Every image, following the pages."""
offset = 0
while True:
page = self.list_images(q=q, limit=page_size, offset=offset)
yield from page["images"]
offset += len(page["images"])
if not page["images"] or offset >= page["total"]:
return
def get_image(self, image_id: str) -> dict:
"""images:read - one image; 404 if it isn't in this account."""
return self._request("GET", f"/images/{image_id}")["image"]
def upload_image(self, path: str, name: str | None = None) -> dict:
"""images:write - upload a file. The image's name is the filename sent (`name` overrides it)."""
name = name or os.path.basename(path)
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
with open(path, "rb") as f:
return self._request("POST", "/images", files={"file": (name, f, mime)})["image"]
def delete_image(self, image_id: str) -> None:
"""images:write"""
self._request("DELETE", f"/images/{image_id}")
def create_download_link(self, image_id: str, expires_in_hours: float = 168) -> dict:
"""links:write - a signed, watermarked full-size download. Returns {url, expiresAt}."""
return self._request("POST", f"/images/{image_id}/download-link", json={"expiresInHours": expires_in_hours})
@staticmethod
def embed_html(image: dict, alt: str = "") -> str:
"""The script-embed snippet: the <img> loads the scrambled copy, embed.js swaps in the real one."""
base = image["urls"]["scrambled"].split("/i/")[0]
return (f'<script src="{base}/embed.js"></script>\n'
f'<img src="{image["urls"]["scrambled"]}" data-pg="{image["id"]}" '
f'width="{image["width"]}" height="{image["height"]}" alt="{alt}">')
# ------------------------------------------------------------------ traffic and patrols
def traffic(self, days: int = 30, image_id: str | None = None) -> dict:
"""traffic:read - the dashboard's Traffic report."""
params: dict[str, Any] = {"days": days}
if image_id:
params["image"] = image_id
return self._request("GET", "/traffic", params=params)
def list_patrols(self) -> dict:
"""patrols:read - recent patrols and this month's allowance."""
return self._request("GET", "/patrols")
def start_patrol(self, image_ids: list[str], allow_unprotected: bool = False) -> dict:
"""patrols:write - search the web for copies. Sends unprotected 1000 px copies of these images
to Google and TinEye, so you must pass allow_unprotected=True deliberately."""
if allow_unprotected is not True:
raise ValueError("start_patrol sends unprotected copies to search providers: pass allow_unprotected=True")
return self._request("POST", "/patrols", json={"imageIds": image_ids, "allowUnprotected": True})
def get_patrol(self, patrol_id: str) -> dict:
"""patrols:read - the report, with every sighting."""
return self._request("GET", f"/patrols/{patrol_id}")["patrol"]
def wait_for_patrol(self, patrol_id: str, every: float = 5, timeout: float = 600) -> dict:
deadline = time.time() + timeout
while True:
patrol = self.get_patrol(patrol_id)
if patrol["status"] in ("done", "failed") or time.time() > deadline:
return patrol
time.sleep(every)
def patrol_csv(self, patrol_id: str) -> str:
"""patrols:read - the report as CSV."""
return self._request("GET", f"/patrols/{patrol_id}/report.csv", raw=True).text
def _json(res: requests.Response) -> dict:
try:
body = res.json()
return body if isinstance(body, dict) else {"data": body}
except ValueError:
return {}demo.py
"""A tour of every Client API call. Uploads one image, works with it, and deletes it again.
export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
python demo.py path/to/image.jpg [--keep] [--patrol]
--keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
"""
import sys
from pixelsentinel import PixelSentinel, PixelSentinelError
def main(args: list[str]) -> None:
files = [a for a in args if not a.startswith("--")]
if not files:
sys.exit(__doc__)
ps = PixelSentinel.from_env()
ps.token()
print("1. Got an access token")
account = ps.get_account()
print(f"2. Account {account['orgName'] or account['id']}: {account['status']}, "
f"{account['credits']} credits, {account['viewsThisMonth']} views this month")
print(f" Approved sites: {', '.join(account['domains']) or '(none yet)'}")
image = ps.upload_image(files[0])
print(f"3. Uploaded {image['name']} as {image['id']} ({image['width']}x{image['height']})")
image = ps.get_image(image["id"])
print("4. Embed it on an approved site with:\n" + ps.embed_html(image, alt=image["name"]))
print(f" Plain image (no script): {image['urls']['plain']}")
page = ps.list_images(limit=5)
print(f"5. The account has {page['total']} images; newest: {', '.join(i['name'] for i in page['images'])}")
link = ps.create_download_link(image["id"], expires_in_hours=24)
print(f"6. Full-size download link until {link['expiresAt']}:\n {link['url']}")
domains = ps.set_domains(account["domains"])
print(f"7. Saved the approved sites unchanged: {domains}")
report = ps.traffic(days=7)
print(f"8. Traffic, last 7 days: {report['totals']}")
patrols = ps.list_patrols()
allowance = patrols.get("allowance") or {}
print(f"9. {len(patrols['patrols'])} patrols so far; {allowance.get('left', 0)} left this month")
if "--patrol" in args:
started = ps.start_patrol([image["id"]], allow_unprotected=True)
patrol = ps.wait_for_patrol(started["patrol"]["id"])
print(f" Patrol {patrol['id']}: {patrol['status']}, {patrol['copies']} copies found")
print(ps.patrol_csv(patrol["id"]))
if "--keep" in args:
print(f"10. Kept {image['id']}")
else:
ps.delete_image(image["id"])
print(f"10. Deleted {image['id']}")
if __name__ == "__main__":
try:
main(sys.argv[1:])
except PixelSentinelError as e:
sys.exit(f"Pixel Sentinel said {e}")seed.py
"""Uploads a folder of images, skipping names the account already has.
python seed.py ../../seed-images
The gallery files images by name prefix: photo--…, art--…, graphics--….
"""
import pathlib
import sys
from pixelsentinel import PixelSentinel
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".heic", ".tif", ".tiff"}
def main(folder: str) -> None:
ps = PixelSentinel.from_env()
have = {image["name"] for image in ps.iter_images()}
for path in sorted(pathlib.Path(folder).iterdir()):
if path.suffix.lower() not in EXTENSIONS:
continue
if path.name in have:
print(f"have {path.name}")
continue
image = ps.upload_image(str(path))
print(f"uploaded {path.name} -> {image['id']}")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
main(sys.argv[1])In samples/csharp:
dotnet run -- photo.jpgPixelSentinel.cs
// A small Pixel Sentinel Client API wrapper for .NET 8+ (HttpClient and System.Text.Json, no packages).
//
// Authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before it
// expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
//
// var ps = new PixelSentinelClient(clientId, clientSecret);
// var page = await ps.ListImagesAsync(q: "photo--");
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PixelSentinel;
public sealed record ImageUrls(string Scrambled, string Plain);
public sealed record Review(string Status, string Message);
public sealed record Image(string Id, string Name, int Width, int Height, long StoredBytes, DateTimeOffset CreatedAt, Review? Review, ImageUrls Urls);
public sealed record ImagePage(List<Image> Images, int Total, int Offset, int Limit);
public sealed record Account(string Id, string OrgName, string Copyright, string Status, string? StatusReason,
decimal Credits, long ViewsThisMonth, List<string> Domains, string EmbedScript);
public sealed record DownloadLink(string Url, DateTimeOffset ExpiresAt);
public sealed record PatrolAllowance(int PerMonth, int Used, int Left, DateTimeOffset? Since);
public sealed record PatrolSighting(long Id, string ImageId, string Kind, string Verdict, double? Similarity,
string? ImageUrl, string? PageUrl, string? PageTitle);
public sealed record PatrolImage(string Id, string Name, string Status, string? Error, List<string>? Searched, List<PatrolSighting>? Hits);
public sealed record PatrolSummary(string Id, string Status, DateTimeOffset CreatedAt, DateTimeOffset? FinishedAt,
List<string> Sources, JsonElement Images, int Copies, int Exposed, int Watermarks);
public sealed record PatrolOverview(bool Enabled, int MaxImages, PatrolAllowance? Allowance, List<PatrolSummary> Patrols);
public sealed record PatrolStarted(PatrolSummary Patrol, PatrolAllowance Allowance);
public sealed class PixelSentinelException(HttpStatusCode status, string message, string body)
: Exception($"{(int)status}: {message}")
{
public HttpStatusCode Status { get; } = status;
public string Body { get; } = body;
}
public sealed class PixelSentinelClient : IDisposable
{
public const string DefaultBaseUrl = "https://argus.pixelsentinel.net";
static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
readonly HttpClient _http;
readonly string _clientId, _clientSecret;
readonly string? _scope;
readonly SemaphoreSlim _tokenLock = new(1, 1);
string? _token;
DateTimeOffset _expires;
/// <param name="scope">Ask for fewer scopes than the client has, e.g. "images:read".</param>
public PixelSentinelClient(string clientId, string clientSecret, string baseUrl = DefaultBaseUrl, string? scope = null, HttpClient? http = null)
{
_clientId = clientId;
_clientSecret = clientSecret;
_scope = scope;
_http = http ?? new HttpClient();
_http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
}
/// <summary>PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE.</summary>
public static PixelSentinelClient FromEnvironment() => new(
Environment.GetEnvironmentVariable("PS_CLIENT_ID") ?? throw new InvalidOperationException("Set PS_CLIENT_ID"),
Environment.GetEnvironmentVariable("PS_CLIENT_SECRET") ?? throw new InvalidOperationException("Set PS_CLIENT_SECRET"),
Environment.GetEnvironmentVariable("PS_API_BASE") ?? DefaultBaseUrl);
// ---------------------------------------------------------------- auth
/// <summary>POST /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour.</summary>
public async Task<string> GetTokenAsync(bool force = false, CancellationToken ct = default)
{
await _tokenLock.WaitAsync(ct);
try
{
if (!force && _token is not null && DateTimeOffset.UtcNow < _expires.AddSeconds(-60)) return _token;
var form = new Dictionary<string, string> { ["grant_type"] = "client_credentials" };
if (_scope is not null) form["scope"] = _scope;
using var req = new HttpRequestMessage(HttpMethod.Post, "oauth/token") { Content = new FormUrlEncodedContent(form) };
var basic = $"{Uri.EscapeDataString(_clientId)}:{Uri.EscapeDataString(_clientSecret)}";
req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(basic)));
using var res = await _http.SendAsync(req, ct);
var text = await res.Content.ReadAsStringAsync(ct);
if (!res.IsSuccessStatusCode) throw Error(res.StatusCode, text, "error_description");
using var doc = JsonDocument.Parse(text);
_token = doc.RootElement.GetProperty("access_token").GetString()!;
_expires = DateTimeOffset.UtcNow.AddSeconds(doc.RootElement.GetProperty("expires_in").GetInt32());
return _token;
}
finally { _tokenLock.Release(); }
}
async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, Func<HttpContent?>? content, CancellationToken ct)
{
HttpResponseMessage res = null!;
foreach (var attempt in new[] { 1, 2 })
{
var req = new HttpRequestMessage(method, "client/v1/" + path.TrimStart('/')) { Content = content?.Invoke() };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await GetTokenAsync(force: attempt == 2, ct));
res = await _http.SendAsync(req, ct);
// The token expired early or the secret was replaced: get a new one, once.
if (res.StatusCode != HttpStatusCode.Unauthorized) break;
}
if (!res.IsSuccessStatusCode) throw Error(res.StatusCode, await res.Content.ReadAsStringAsync(ct), "error");
return res;
}
async Task<T> RequestAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
using var res = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body, options: Json), ct);
return (await res.Content.ReadFromJsonAsync<T>(Json, ct))!;
}
static PixelSentinelException Error(HttpStatusCode status, string text, string field)
{
var message = status.ToString();
try
{
using var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty(field, out var m) || doc.RootElement.TryGetProperty("error", out m)) message = m.GetString() ?? message;
}
catch (JsonException) { }
return new PixelSentinelException(status, message, text);
}
sealed record ImageEnvelope(Image Image);
sealed record DomainsEnvelope(List<string> Domains);
sealed record PatrolEnvelope(JsonElement Patrol);
// ---------------------------------------------------------------- account
/// <summary>account:read - name, status, credits, views this month, approved sites.</summary>
public Task<Account> GetAccountAsync(CancellationToken ct = default) => RequestAsync<Account>(HttpMethod.Get, "account", ct: ct);
/// <summary>domains:write - replace the whole approved-sites list ("*.example.com" covers subdomains).</summary>
public async Task<List<string>> SetDomainsAsync(IEnumerable<string> domains, CancellationToken ct = default) =>
(await RequestAsync<DomainsEnvelope>(HttpMethod.Put, "domains", new { domains }, ct)).Domains;
// ---------------------------------------------------------------- images
/// <summary>images:read - newest first; <paramref name="q"/> filters by name.</summary>
public Task<ImagePage> ListImagesAsync(string? q = null, int limit = 25, int offset = 0, CancellationToken ct = default)
{
var query = $"images?limit={limit}&offset={offset}" + (q is null ? "" : "&q=" + Uri.EscapeDataString(q));
return RequestAsync<ImagePage>(HttpMethod.Get, query, ct: ct);
}
/// <summary>Every image, following the pages.</summary>
public async IAsyncEnumerable<Image> ListAllImagesAsync(string? q = null, int pageSize = 100)
{
for (var offset = 0; ;)
{
var page = await ListImagesAsync(q, pageSize, offset);
foreach (var image in page.Images) yield return image;
offset += page.Images.Count;
if (page.Images.Count == 0 || offset >= page.Total) yield break;
}
}
/// <summary>images:read - one image; 404 if it isn't in this account.</summary>
public async Task<Image> GetImageAsync(string id, CancellationToken ct = default) =>
(await RequestAsync<ImageEnvelope>(HttpMethod.Get, $"images/{Uri.EscapeDataString(id)}", ct: ct)).Image;
/// <summary>images:write - upload a file. The image's name is the filename sent (<paramref name="name"/> overrides it).</summary>
public async Task<Image> UploadImageAsync(string path, string? name = null, CancellationToken ct = default)
{
var bytes = await File.ReadAllBytesAsync(path, ct);
name ??= Path.GetFileName(path);
HttpContent Form()
{
var file = new ByteArrayContent(bytes);
file.Headers.ContentType = new MediaTypeHeaderValue(MimeType(name));
return new MultipartFormDataContent { { file, "file", name } };
}
using var res = await SendAsync(HttpMethod.Post, "images", Form, ct);
return (await res.Content.ReadFromJsonAsync<ImageEnvelope>(Json, ct))!.Image;
}
static string MimeType(string name) => Path.GetExtension(name).ToLowerInvariant() switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".webp" => "image/webp",
".gif" => "image/gif",
".avif" => "image/avif",
".heic" => "image/heic",
".tif" or ".tiff" => "image/tiff",
_ => "application/octet-stream",
};
/// <summary>images:write</summary>
public async Task DeleteImageAsync(string id, CancellationToken ct = default) =>
(await SendAsync(HttpMethod.Delete, $"images/{Uri.EscapeDataString(id)}", null, ct)).Dispose();
/// <summary>links:write - a signed, watermarked full-size download.</summary>
public Task<DownloadLink> CreateDownloadLinkAsync(string id, double expiresInHours = 168, CancellationToken ct = default) =>
RequestAsync<DownloadLink>(HttpMethod.Post, $"images/{Uri.EscapeDataString(id)}/download-link", new { expiresInHours }, ct);
/// <summary>The script-embed snippet: the img loads the scrambled copy, embed.js swaps in the real one.</summary>
public static string EmbedHtml(Image image, string alt = "")
{
var baseUrl = image.Urls.Scrambled[..image.Urls.Scrambled.IndexOf("/i/", StringComparison.Ordinal)];
return $"<script src=\"{baseUrl}/embed.js\"></script>\n" +
$"<img src=\"{image.Urls.Scrambled}\" data-pg=\"{image.Id}\" width=\"{image.Width}\" height=\"{image.Height}\" alt=\"{WebUtility.HtmlEncode(alt)}\">";
}
// ---------------------------------------------------------------- traffic and patrols
/// <summary>traffic:read - the dashboard's Traffic report (totals, daily, breakdown).</summary>
public Task<JsonElement> GetTrafficAsync(int days = 30, string? imageId = null, CancellationToken ct = default) =>
RequestAsync<JsonElement>(HttpMethod.Get, $"traffic?days={days}" + (imageId is null ? "" : "&image=" + Uri.EscapeDataString(imageId)), ct: ct);
/// <summary>patrols:read - recent patrols and this month's allowance.</summary>
public Task<PatrolOverview> ListPatrolsAsync(CancellationToken ct = default) => RequestAsync<PatrolOverview>(HttpMethod.Get, "patrols", ct: ct);
/// <summary>
/// patrols:write - search the web for copies. Sends unprotected 1000 px copies of these images to
/// Google and TinEye, so <paramref name="allowUnprotected"/> must be passed as true deliberately.
/// </summary>
public Task<PatrolStarted> StartPatrolAsync(IEnumerable<string> imageIds, bool allowUnprotected, CancellationToken ct = default)
{
if (!allowUnprotected) throw new ArgumentException("StartPatrolAsync sends unprotected copies to search providers: pass allowUnprotected: true");
return RequestAsync<PatrolStarted>(HttpMethod.Post, "patrols", new { imageIds, allowUnprotected = true }, ct);
}
/// <summary>patrols:read - the report, with every sighting.</summary>
public async Task<JsonElement> GetPatrolAsync(string id, CancellationToken ct = default) =>
(await RequestAsync<PatrolEnvelope>(HttpMethod.Get, $"patrols/{Uri.EscapeDataString(id)}", ct: ct)).Patrol;
public async Task<JsonElement> WaitForPatrolAsync(string id, TimeSpan? every = null, TimeSpan? timeout = null, CancellationToken ct = default)
{
var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromMinutes(10));
while (true)
{
var patrol = await GetPatrolAsync(id, ct);
var status = patrol.GetProperty("status").GetString();
if (status is "done" or "failed" || DateTimeOffset.UtcNow > deadline) return patrol;
await Task.Delay(every ?? TimeSpan.FromSeconds(5), ct);
}
}
/// <summary>patrols:read - the report as CSV.</summary>
public async Task<string> GetPatrolCsvAsync(string id, CancellationToken ct = default)
{
using var res = await SendAsync(HttpMethod.Get, $"patrols/{Uri.EscapeDataString(id)}/report.csv", null, ct);
return await res.Content.ReadAsStringAsync(ct);
}
public void Dispose() => _http.Dispose();
}Program.cs
// A tour of every Client API call. Uploads one image, works with it, and deletes it again.
//
// export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
// dotnet run -- path/to/image.jpg [--keep] [--patrol]
//
// --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
// from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
using System.Text.Json;
using PixelSentinel;
var file = args.FirstOrDefault(a => !a.StartsWith("--"));
if (file is null)
{
Console.Error.WriteLine("Usage: dotnet run -- path/to/image.jpg [--keep] [--patrol]");
return 1;
}
try
{
using var ps = PixelSentinelClient.FromEnvironment();
await ps.GetTokenAsync();
Console.WriteLine("1. Got an access token");
var account = await ps.GetAccountAsync();
Console.WriteLine($"2. Account {(account.OrgName is { Length: > 0 } ? account.OrgName : account.Id)}: {account.Status}, " +
$"{account.Credits} credits, {account.ViewsThisMonth} views this month");
Console.WriteLine($" Approved sites: {(account.Domains.Count > 0 ? string.Join(", ", account.Domains) : "(none yet)")}");
var image = await ps.UploadImageAsync(file);
Console.WriteLine($"3. Uploaded {image.Name} as {image.Id} ({image.Width}x{image.Height})");
image = await ps.GetImageAsync(image.Id);
Console.WriteLine($"4. Embed it on an approved site with:\n{PixelSentinelClient.EmbedHtml(image, image.Name)}");
Console.WriteLine($" Plain image (no script): {image.Urls.Plain}");
var page = await ps.ListImagesAsync(limit: 5);
Console.WriteLine($"5. The account has {page.Total} images; newest: {string.Join(", ", page.Images.Select(i => i.Name))}");
var link = await ps.CreateDownloadLinkAsync(image.Id, expiresInHours: 24);
Console.WriteLine($"6. Full-size download link until {link.ExpiresAt:u}:\n {link.Url}");
var domains = await ps.SetDomainsAsync(account.Domains);
Console.WriteLine($"7. Saved the approved sites unchanged: [{string.Join(", ", domains)}]");
var traffic = await ps.GetTrafficAsync(days: 7);
Console.WriteLine($"8. Traffic, last 7 days: {traffic.GetProperty("totals")}");
var patrols = await ps.ListPatrolsAsync();
Console.WriteLine($"9. {patrols.Patrols.Count} patrols so far; {patrols.Allowance?.Left ?? 0} left this month");
if (args.Contains("--patrol"))
{
var started = await ps.StartPatrolAsync([image.Id], allowUnprotected: true);
var patrol = await ps.WaitForPatrolAsync(started.Patrol.Id);
Console.WriteLine($" Patrol {started.Patrol.Id}: {patrol.GetProperty("status")}, {patrol.GetProperty("copies")} copies found");
Console.WriteLine(await ps.GetPatrolCsvAsync(started.Patrol.Id));
}
if (args.Contains("--keep"))
{
Console.WriteLine($"10. Kept {image.Id}");
}
else
{
await ps.DeleteImageAsync(image.Id);
Console.WriteLine($"10. Deleted {image.Id}");
}
return 0;
}
catch (PixelSentinelException e)
{
Console.Error.WriteLine($"Pixel Sentinel said {e.Message}");
return 1;
}PixelSentinelDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>In samples/javascript:
node demo.mjs photo.jpgpixelsentinel.mjs
// A small Pixel Sentinel Client API wrapper for Node.js 20+, Cloudflare Workers, Deno and Bun
// (built-in fetch, no dependencies). The sample gallery website runs on this file.
//
// Authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before it
// expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
//
// import { PixelSentinel } from './pixelsentinel.mjs';
// const ps = new PixelSentinel({ clientId, clientSecret });
// const { images } = await ps.listImages({ q: 'photo--' });
export const DEFAULT_BASE_URL = 'https://argus.pixelsentinel.net';
export class PixelSentinelError extends Error {
constructor(status, message, body) {
super(`${status}: ${message}`);
this.status = status;
this.body = body;
}
}
export class PixelSentinel {
#tokens;
#onResponse;
/**
* `scope` asks for fewer scopes than the client has, e.g. 'images:read'.
* `tokenStore` is any object; pass the same one to several clients (one per request on a server) so
* they share a token instead of each getting their own.
* `onResponse({ method, path, status, ms })` is called after every API call, for logging.
*/
constructor({ clientId, clientSecret, baseUrl = DEFAULT_BASE_URL, scope, tokenStore = {}, onResponse } = {}) {
if (!clientId || !clientSecret) throw new Error('clientId and clientSecret are required');
Object.assign(this, { clientId, clientSecret, baseUrl: baseUrl.replace(/\/+$/, ''), scope });
this.#tokens = tokenStore;
this.#onResponse = onResponse;
}
/** PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE. */
static fromEnv(env = process.env) {
return new PixelSentinel({ clientId: env.PS_CLIENT_ID, clientSecret: env.PS_CLIENT_SECRET, baseUrl: env.PS_API_BASE || DEFAULT_BASE_URL });
}
// ---------------------------------------------------------------- auth
/** POST /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour. */
async token(force = false) {
const t = this.#tokens;
if (!force && t.token && Date.now() < t.expires - 60_000) return t.token;
// Calls made at the same moment share one token request.
if (!force && t.pending) return t.pending;
t.pending = this.#newToken().finally(() => { t.pending = null; });
return t.pending;
}
async #newToken() {
const t = this.#tokens;
const form = new URLSearchParams({ grant_type: 'client_credentials' });
if (this.scope) form.set('scope', this.scope);
const res = await this.#fetch('POST', '/oauth/token', {
headers: { Authorization: `Basic ${btoa(`${encodeURIComponent(this.clientId)}:${encodeURIComponent(this.clientSecret)}`)}` },
body: form,
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new PixelSentinelError(res.status, body.error_description || body.error || res.statusText, body);
t.token = body.access_token;
t.expires = Date.now() + body.expires_in * 1000;
return t.token;
}
async #fetch(method, p, init) {
const started = Date.now();
const res = await fetch(`${this.baseUrl}${p}`, { method, ...init });
this.#onResponse?.({ method, path: p, status: res.status, ms: Date.now() - started });
return res;
}
async #request(method, p, { query, json, form, raw = false } = {}) {
const qs = new URLSearchParams(Object.entries(query ?? {}).filter(([, v]) => v != null)).toString();
const target = `/client/v1${p}${qs ? `?${qs}` : ''}`;
let res;
for (const attempt of [1, 2]) {
const headers = { Authorization: `Bearer ${await this.token(attempt === 2)}` };
if (json !== undefined) headers['Content-Type'] = 'application/json';
res = await this.#fetch(method, target, { headers, body: json !== undefined ? JSON.stringify(json) : form });
// The token expired early or the secret was replaced: get a new one, once.
if (res.status !== 401) break;
}
if (raw && res.ok) return res;
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new PixelSentinelError(res.status, body.error || res.statusText, body);
return body;
}
// ---------------------------------------------------------------- account
/** account:read - name, status, credits, views this month, approved sites. */
getAccount() { return this.#request('GET', '/account'); }
/** domains:write - replace the whole approved-sites list ('*.example.com' covers subdomains). */
async setDomains(domains) { return (await this.#request('PUT', '/domains', { json: { domains } })).domains; }
// ---------------------------------------------------------------- images
/** images:read - newest first; `q` filters by name. Returns { images, total, offset, limit }. */
listImages({ q, limit = 25, offset = 0 } = {}) { return this.#request('GET', '/images', { query: { q, limit, offset } }); }
/** Every image, following the pages. */
async *iterImages({ q, pageSize = 100 } = {}) {
for (let offset = 0; ;) {
const page = await this.listImages({ q, limit: pageSize, offset });
yield* page.images;
offset += page.images.length;
if (!page.images.length || offset >= page.total) return;
}
}
/** images:read - one image; 404 if it isn't in this account. */
async getImage(id) { return (await this.#request('GET', `/images/${encodeURIComponent(id)}`)).image; }
/**
* images:write - upload a Blob/File, or (in Node) a file path. The image's name is the filename sent
* (`name` overrides it).
*/
async uploadImage(file, name) {
let blob = file;
if (typeof file === 'string') {
const { openAsBlob } = await import('node:fs');
blob = await openAsBlob(file);
name ??= file.split(/[\\/]/).pop();
}
const form = new FormData();
form.append('file', blob, name ?? file.name);
return (await this.#request('POST', '/images', { form })).image;
}
/** images:write */
async deleteImage(id) { await this.#request('DELETE', `/images/${encodeURIComponent(id)}`); }
/** links:write - a signed, watermarked full-size download. Returns { url, expiresAt }. */
createDownloadLink(id, expiresInHours = 168) {
return this.#request('POST', `/images/${encodeURIComponent(id)}/download-link`, { json: { expiresInHours } });
}
/** The script-embed snippet: the <img> loads the scrambled copy, embed.js swaps in the real one. */
static embedHtml(image, alt = '') {
const base = image.urls.scrambled.split('/i/')[0];
const esc = (s) => String(s).replace(/[&"<>]/g, (c) => `&#${c.charCodeAt(0)};`);
return `<script src="${base}/embed.js"></script>\n`
+ `<img src="${image.urls.scrambled}" data-pg="${image.id}" width="${image.width}" height="${image.height}" alt="${esc(alt)}">`;
}
// ---------------------------------------------------------------- traffic and patrols
/** traffic:read - the dashboard's Traffic report. */
traffic({ days = 30, imageId } = {}) { return this.#request('GET', '/traffic', { query: { days, image: imageId } }); }
/** patrols:read - recent patrols and this month's allowance. */
listPatrols() { return this.#request('GET', '/patrols'); }
/**
* patrols:write - search the web for copies. Sends unprotected 1000 px copies of these images to
* Google and TinEye, so `allowUnprotected: true` must be passed deliberately.
*/
startPatrol(imageIds, { allowUnprotected } = {}) {
if (allowUnprotected !== true) throw new Error('startPatrol sends unprotected copies to search providers: pass { allowUnprotected: true }');
return this.#request('POST', '/patrols', { json: { imageIds, allowUnprotected: true } });
}
/** patrols:read - the report, with every sighting. */
async getPatrol(id) { return (await this.#request('GET', `/patrols/${encodeURIComponent(id)}`)).patrol; }
async waitForPatrol(id, { every = 5000, timeout = 600_000 } = {}) {
const deadline = Date.now() + timeout;
for (;;) {
const patrol = await this.getPatrol(id);
if (['done', 'failed'].includes(patrol.status) || Date.now() > deadline) return patrol;
await new Promise((r) => setTimeout(r, every));
}
}
/** patrols:read - the report as CSV. */
async patrolCsv(id) { return (await this.#request('GET', `/patrols/${encodeURIComponent(id)}/report.csv`, { raw: true })).text(); }
}demo.mjs
// A tour of every Client API call. Uploads one image, works with it, and deletes it again.
//
// export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
// node demo.mjs path/to/image.jpg [--keep] [--patrol]
//
// --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
// from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
import { PixelSentinel, PixelSentinelError } from './pixelsentinel.mjs';
const args = process.argv.slice(2);
const [file] = args.filter((a) => !a.startsWith('--'));
if (!file) {
console.error('Usage: node demo.mjs path/to/image.jpg [--keep] [--patrol]');
process.exit(1);
}
try {
const ps = PixelSentinel.fromEnv();
await ps.token();
console.log('1. Got an access token');
const account = await ps.getAccount();
console.log(`2. Account ${account.orgName || account.id}: ${account.status}, ${account.credits} credits, ${account.viewsThisMonth} views this month`);
console.log(` Approved sites: ${account.domains.join(', ') || '(none yet)'}`);
let image = await ps.uploadImage(file);
console.log(`3. Uploaded ${image.name} as ${image.id} (${image.width}x${image.height})`);
image = await ps.getImage(image.id);
console.log(`4. Embed it on an approved site with:\n${PixelSentinel.embedHtml(image, image.name)}`);
console.log(` Plain image (no script): ${image.urls.plain}`);
const page = await ps.listImages({ limit: 5 });
console.log(`5. The account has ${page.total} images; newest: ${page.images.map((i) => i.name).join(', ')}`);
const link = await ps.createDownloadLink(image.id, 24);
console.log(`6. Full-size download link until ${link.expiresAt}:\n ${link.url}`);
const domains = await ps.setDomains(account.domains);
console.log(`7. Saved the approved sites unchanged: ${JSON.stringify(domains)}`);
const report = await ps.traffic({ days: 7 });
console.log(`8. Traffic, last 7 days: ${JSON.stringify(report.totals)}`);
const patrols = await ps.listPatrols();
console.log(`9. ${patrols.patrols.length} patrols so far; ${patrols.allowance?.left ?? 0} left this month`);
if (args.includes('--patrol')) {
const started = await ps.startPatrol([image.id], { allowUnprotected: true });
const patrol = await ps.waitForPatrol(started.patrol.id);
console.log(` Patrol ${patrol.id}: ${patrol.status}, ${patrol.copies} copies found`);
console.log(await ps.patrolCsv(patrol.id));
}
if (args.includes('--keep')) {
console.log(`10. Kept ${image.id}`);
} else {
await ps.deleteImage(image.id);
console.log(`10. Deleted ${image.id}`);
}
} catch (e) {
if (!(e instanceof PixelSentinelError)) throw e;
console.error(`Pixel Sentinel said ${e.message}`);
process.exit(1);
}In samples/go:
go run . photo.jpgpixelsentinel/client.go
// Package pixelsentinel is a small Pixel Sentinel Client API wrapper (standard library only).
//
// It authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before
// it expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
//
// ps := pixelsentinel.New(clientID, clientSecret)
// page, err := ps.ListImages(ctx, "photo--", 25, 0)
package pixelsentinel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"mime"
"mime/multipart"
"net/http"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const DefaultBaseURL = "https://argus.pixelsentinel.net"
type ImageURLs struct {
Scrambled string `json:"scrambled"`
Plain string `json:"plain"`
}
type Review struct {
Status string `json:"status"`
Message string `json:"message"`
}
type Image struct {
ID string `json:"id"`
Name string `json:"name"`
Width int `json:"width"`
Height int `json:"height"`
StoredBytes int64 `json:"storedBytes"`
CreatedAt time.Time `json:"createdAt"`
Review *Review `json:"review"`
URLs ImageURLs `json:"urls"`
}
type ImagePage struct {
Images []Image `json:"images"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type Account struct {
ID string `json:"id"`
OrgName string `json:"orgName"`
Copyright string `json:"copyright"`
Status string `json:"status"`
StatusReason *string `json:"statusReason"`
Credits float64 `json:"credits"`
ViewsThisMonth int64 `json:"viewsThisMonth"`
Domains []string `json:"domains"`
EmbedScript string `json:"embedScript"`
}
type DownloadLink struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expiresAt"`
}
type PatrolAllowance struct {
PerMonth int `json:"perMonth"`
Used int `json:"used"`
Left int `json:"left"`
}
type PatrolSummary struct {
ID string `json:"id"`
Status string `json:"status"`
Sources []string `json:"sources"`
Copies int `json:"copies"`
Exposed int `json:"exposed"`
Watermarks int `json:"watermarks"`
}
type PatrolOverview struct {
Enabled bool `json:"enabled"`
MaxImages int `json:"maxImages"`
Allowance *PatrolAllowance `json:"allowance"`
Patrols []PatrolSummary `json:"patrols"`
}
// Patrol is a report: the summary plus every image and its sightings (kept as raw JSON).
type Patrol struct {
PatrolSummary
Images json.RawMessage `json:"images"`
}
// Error is an error answer from the API.
type Error struct {
Status int
Message string
Body []byte
}
func (e *Error) Error() string { return fmt.Sprintf("%d: %s", e.Status, e.Message) }
type Client struct {
BaseURL string
ClientID string
ClientSecret string
Scope string // ask for fewer scopes than the client has, e.g. "images:read"
HTTP *http.Client
mu sync.Mutex
token string
expires time.Time
}
func New(clientID, clientSecret string) *Client {
return &Client{BaseURL: DefaultBaseURL, ClientID: clientID, ClientSecret: clientSecret, HTTP: &http.Client{Timeout: 60 * time.Second}}
}
// FromEnv reads PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE.
func FromEnv() *Client {
c := New(os.Getenv("PS_CLIENT_ID"), os.Getenv("PS_CLIENT_SECRET"))
if base := os.Getenv("PS_API_BASE"); base != "" {
c.BaseURL = strings.TrimRight(base, "/")
}
return c
}
// ---------------------------------------------------------------- auth
// Token posts to /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour.
func (c *Client) Token(ctx context.Context, force bool) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force && c.token != "" && time.Now().Before(c.expires.Add(-time.Minute)) {
return c.token, nil
}
form := url.Values{"grant_type": {"client_credentials"}}
if c.Scope != "" {
form.Set("scope", c.Scope)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(url.QueryEscape(c.ClientID), url.QueryEscape(c.ClientSecret))
res, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return "", apiError(res.StatusCode, body, "error_description")
}
var t struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &t); err != nil {
return "", err
}
c.token, c.expires = t.AccessToken, time.Now().Add(time.Duration(t.ExpiresIn)*time.Second)
return c.token, nil
}
// do sends a /client/v1 request; body is rebuilt per attempt by newBody (nil for none).
func (c *Client) do(ctx context.Context, method, path string, contentType string, newBody func() io.Reader) ([]byte, error) {
var res *http.Response
for attempt := 1; attempt <= 2; attempt++ {
token, err := c.Token(ctx, attempt == 2)
if err != nil {
return nil, err
}
var body io.Reader
if newBody != nil {
body = newBody()
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+"/client/v1"+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if res, err = c.HTTP.Do(req); err != nil {
return nil, err
}
// The token expired early or the secret was replaced: get a new one, once.
if res.StatusCode != http.StatusUnauthorized || attempt == 2 {
break
}
res.Body.Close()
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
if res.StatusCode >= 300 {
return nil, apiError(res.StatusCode, data, "error")
}
return data, nil
}
func (c *Client) json(ctx context.Context, method, path string, in, out any) error {
var newBody func() io.Reader
contentType := ""
if in != nil {
payload, err := json.Marshal(in)
if err != nil {
return err
}
newBody, contentType = func() io.Reader { return bytes.NewReader(payload) }, "application/json"
}
data, err := c.do(ctx, method, path, contentType, newBody)
if err != nil || out == nil {
return err
}
return json.Unmarshal(data, out)
}
func apiError(status int, body []byte, field string) error {
var m map[string]any
msg := http.StatusText(status)
if json.Unmarshal(body, &m) == nil {
if s, ok := m[field].(string); ok {
msg = s
} else if s, ok := m["error"].(string); ok {
msg = s
}
}
return &Error{Status: status, Message: msg, Body: body}
}
// ---------------------------------------------------------------- account
// GetAccount (account:read): name, status, credits, views this month, approved sites.
func (c *Client) GetAccount(ctx context.Context) (*Account, error) {
var a Account
return &a, c.json(ctx, http.MethodGet, "/account", nil, &a)
}
// SetDomains (domains:write) replaces the whole approved-sites list ("*.example.com" covers subdomains).
func (c *Client) SetDomains(ctx context.Context, domains []string) ([]string, error) {
if domains == nil {
domains = []string{}
}
var out struct{ Domains []string }
return out.Domains, c.json(ctx, http.MethodPut, "/domains", map[string]any{"domains": domains}, &out)
}
// ---------------------------------------------------------------- images
// ListImages (images:read): newest first; q filters by name.
func (c *Client) ListImages(ctx context.Context, q string, limit, offset int) (*ImagePage, error) {
v := url.Values{"limit": {fmt.Sprint(limit)}, "offset": {fmt.Sprint(offset)}}
if q != "" {
v.Set("q", q)
}
var p ImagePage
return &p, c.json(ctx, http.MethodGet, "/images?"+v.Encode(), nil, &p)
}
// AllImages follows the pages and returns every image.
func (c *Client) AllImages(ctx context.Context, q string) ([]Image, error) {
var all []Image
for {
page, err := c.ListImages(ctx, q, 100, len(all))
if err != nil {
return nil, err
}
all = append(all, page.Images...)
if len(page.Images) == 0 || len(all) >= page.Total {
return all, nil
}
}
}
// GetImage (images:read): one image; 404 if it isn't in this account.
func (c *Client) GetImage(ctx context.Context, id string) (*Image, error) {
var out struct{ Image Image }
return &out.Image, c.json(ctx, http.MethodGet, "/images/"+url.PathEscape(id), nil, &out)
}
// UploadImage (images:write) uploads a file. The image's name is the filename sent (name overrides it).
func (c *Client) UploadImage(ctx context.Context, path, name string) (*Image, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if name == "" {
name = filepath.Base(path)
}
var form bytes.Buffer
w := multipart.NewWriter(&form)
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, name))
ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
if ct == "" {
ct = "application/octet-stream"
}
h.Set("Content-Type", ct)
part, _ := w.CreatePart(h)
part.Write(data)
w.Close()
payload := form.Bytes()
body, err := c.do(ctx, http.MethodPost, "/images", w.FormDataContentType(), func() io.Reader { return bytes.NewReader(payload) })
if err != nil {
return nil, err
}
var out struct{ Image Image }
return &out.Image, json.Unmarshal(body, &out)
}
// DeleteImage (images:write).
func (c *Client) DeleteImage(ctx context.Context, id string) error {
return c.json(ctx, http.MethodDelete, "/images/"+url.PathEscape(id), nil, nil)
}
// CreateDownloadLink (links:write): a signed, watermarked full-size download.
func (c *Client) CreateDownloadLink(ctx context.Context, id string, expiresInHours float64) (*DownloadLink, error) {
var l DownloadLink
return &l, c.json(ctx, http.MethodPost, "/images/"+url.PathEscape(id)+"/download-link", map[string]any{"expiresInHours": expiresInHours}, &l)
}
// EmbedHTML is the script-embed snippet: the img loads the scrambled copy, embed.js swaps in the real one.
func EmbedHTML(img *Image, alt string) string {
base := img.URLs.Scrambled[:strings.Index(img.URLs.Scrambled, "/i/")]
return fmt.Sprintf("<script src=\"%s/embed.js\"></script>\n<img src=\"%s\" data-pg=\"%s\" width=\"%d\" height=\"%d\" alt=\"%s\">",
base, img.URLs.Scrambled, img.ID, img.Width, img.Height, html.EscapeString(alt))
}
// ---------------------------------------------------------------- traffic and patrols
// Traffic (traffic:read): the dashboard's Traffic report. imageID "" means every image.
func (c *Client) Traffic(ctx context.Context, days int, imageID string) (map[string]any, error) {
v := url.Values{"days": {fmt.Sprint(days)}}
if imageID != "" {
v.Set("image", imageID)
}
var r map[string]any
return r, c.json(ctx, http.MethodGet, "/traffic?"+v.Encode(), nil, &r)
}
// ListPatrols (patrols:read): recent patrols and this month's allowance.
func (c *Client) ListPatrols(ctx context.Context) (*PatrolOverview, error) {
var o PatrolOverview
return &o, c.json(ctx, http.MethodGet, "/patrols", nil, &o)
}
// StartPatrol (patrols:write) searches the web for copies. It sends unprotected 1000 px copies of
// these images to Google and TinEye, so allowUnprotected must be passed as true deliberately.
func (c *Client) StartPatrol(ctx context.Context, imageIDs []string, allowUnprotected bool) (*Patrol, error) {
if !allowUnprotected {
return nil, fmt.Errorf("StartPatrol sends unprotected copies to search providers: pass allowUnprotected=true")
}
var out struct{ Patrol Patrol }
return &out.Patrol, c.json(ctx, http.MethodPost, "/patrols", map[string]any{"imageIds": imageIDs, "allowUnprotected": true}, &out)
}
// GetPatrol (patrols:read): the report, with every sighting.
func (c *Client) GetPatrol(ctx context.Context, id string) (*Patrol, error) {
var out struct{ Patrol Patrol }
return &out.Patrol, c.json(ctx, http.MethodGet, "/patrols/"+url.PathEscape(id), nil, &out)
}
// WaitForPatrol polls until the patrol is done or failed.
func (c *Client) WaitForPatrol(ctx context.Context, id string, every time.Duration) (*Patrol, error) {
for {
p, err := c.GetPatrol(ctx, id)
if err != nil || p.Status == "done" || p.Status == "failed" {
return p, err
}
select {
case <-ctx.Done():
return p, ctx.Err()
case <-time.After(every):
}
}
}
// PatrolCSV (patrols:read): the report as CSV.
func (c *Client) PatrolCSV(ctx context.Context, id string) (string, error) {
data, err := c.do(ctx, http.MethodGet, "/patrols/"+url.PathEscape(id)+"/report.csv", "", nil)
return string(data), err
}main.go
// A tour of every Client API call. Uploads one image, works with it, and deletes it again.
//
// export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
// go run . path/to/image.jpg [--keep] [--patrol]
//
// --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
// from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
package main
import (
"context"
"fmt"
"os"
"slices"
"strings"
"time"
"example.com/pixelsentinel-demo/pixelsentinel"
)
func main() {
var file string
for _, a := range os.Args[1:] {
if !strings.HasPrefix(a, "--") {
file = a
break
}
}
if file == "" {
fmt.Fprintln(os.Stderr, "Usage: go run . path/to/image.jpg [--keep] [--patrol]")
os.Exit(1)
}
if err := run(context.Background(), file, os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "Pixel Sentinel said %v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context, file string, args []string) error {
ps := pixelsentinel.FromEnv()
if _, err := ps.Token(ctx, false); err != nil {
return err
}
fmt.Println("1. Got an access token")
account, err := ps.GetAccount(ctx)
if err != nil {
return err
}
name := account.OrgName
if name == "" {
name = account.ID
}
fmt.Printf("2. Account %s: %s, %v credits, %d views this month\n", name, account.Status, account.Credits, account.ViewsThisMonth)
fmt.Printf(" Approved sites: %s\n", orNone(strings.Join(account.Domains, ", ")))
image, err := ps.UploadImage(ctx, file, "")
if err != nil {
return err
}
fmt.Printf("3. Uploaded %s as %s (%dx%d)\n", image.Name, image.ID, image.Width, image.Height)
if image, err = ps.GetImage(ctx, image.ID); err != nil {
return err
}
fmt.Printf("4. Embed it on an approved site with:\n%s\n", pixelsentinel.EmbedHTML(image, image.Name))
fmt.Printf(" Plain image (no script): %s\n", image.URLs.Plain)
page, err := ps.ListImages(ctx, "", 5, 0)
if err != nil {
return err
}
names := make([]string, len(page.Images))
for i, img := range page.Images {
names[i] = img.Name
}
fmt.Printf("5. The account has %d images; newest: %s\n", page.Total, strings.Join(names, ", "))
link, err := ps.CreateDownloadLink(ctx, image.ID, 24)
if err != nil {
return err
}
fmt.Printf("6. Full-size download link until %s:\n %s\n", link.ExpiresAt.Format(time.RFC3339), link.URL)
domains, err := ps.SetDomains(ctx, account.Domains)
if err != nil {
return err
}
fmt.Printf("7. Saved the approved sites unchanged: %v\n", domains)
traffic, err := ps.Traffic(ctx, 7, "")
if err != nil {
return err
}
fmt.Printf("8. Traffic, last 7 days: %v\n", traffic["totals"])
patrols, err := ps.ListPatrols(ctx)
if err != nil {
return err
}
left := 0
if patrols.Allowance != nil {
left = patrols.Allowance.Left
}
fmt.Printf("9. %d patrols so far; %d left this month\n", len(patrols.Patrols), left)
if slices.Contains(args, "--patrol") {
started, err := ps.StartPatrol(ctx, []string{image.ID}, true)
if err != nil {
return err
}
patrol, err := ps.WaitForPatrol(ctx, started.ID, 5*time.Second)
if err != nil {
return err
}
fmt.Printf(" Patrol %s: %s, %d copies found\n", patrol.ID, patrol.Status, patrol.Copies)
csv, err := ps.PatrolCSV(ctx, patrol.ID)
if err != nil {
return err
}
fmt.Println(csv)
}
if slices.Contains(args, "--keep") {
fmt.Printf("10. Kept %s\n", image.ID)
return nil
}
if err := ps.DeleteImage(ctx, image.ID); err != nil {
return err
}
fmt.Printf("10. Deleted %s\n", image.ID)
return nil
}
func orNone(s string) string {
if s == "" {
return "(none yet)"
}
return s
}go.mod
module example.com/pixelsentinel-demo
go 1.22In samples/php:
php demo.php photo.jpgPixelSentinel.php
<?php
/**
* A small Pixel Sentinel Client API wrapper for PHP 8.1+ (ext-curl and ext-json, no Composer packages).
*
* Authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before it
* expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
* Responses are decoded into associative arrays, exactly as the API sends them.
*
* $ps = new PixelSentinel\Client($clientId, $clientSecret);
* foreach ($ps->listImages(q: 'photo--')['images'] as $image) echo $image['name'], "\n";
*/
declare(strict_types=1);
namespace PixelSentinel;
final class ApiException extends \RuntimeException
{
public function __construct(public readonly int $status, string $message, public readonly mixed $body = null)
{
parent::__construct("$status: $message", $status);
}
}
final class Client
{
public const DEFAULT_BASE_URL = 'https://argus.pixelsentinel.net';
private ?string $token = null;
private float $expires = 0;
/** @param ?string $scope Ask for fewer scopes than the client has, e.g. 'images:read'. */
public function __construct(
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $baseUrl = self::DEFAULT_BASE_URL,
private readonly ?string $scope = null,
private readonly int $timeout = 60,
) {
}
/** PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE. */
public static function fromEnv(): self
{
return new self((string) getenv('PS_CLIENT_ID'), (string) getenv('PS_CLIENT_SECRET'),
rtrim(getenv('PS_API_BASE') ?: self::DEFAULT_BASE_URL, '/'));
}
// ---------------------------------------------------------------- auth
/** POST /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour. */
public function token(bool $force = false): string
{
if (!$force && $this->token !== null && microtime(true) < $this->expires - 60) {
return $this->token;
}
$form = ['grant_type' => 'client_credentials'] + ($this->scope ? ['scope' => $this->scope] : []);
[$status, $body] = $this->send('POST', '/oauth/token', [
CURLOPT_USERPWD => rawurlencode($this->clientId) . ':' . rawurlencode($this->clientSecret),
CURLOPT_POSTFIELDS => http_build_query($form),
]);
$data = json_decode($body, true) ?? [];
if ($status !== 200) {
throw new ApiException($status, $data['error_description'] ?? $data['error'] ?? 'Token request failed', $data);
}
$this->token = $data['access_token'];
$this->expires = microtime(true) + $data['expires_in'];
return $this->token;
}
/** @return array{0:int,1:string} */
private function send(string $method, string $path, array $options): array
{
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, $options + [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new \RuntimeException('Request failed: ' . curl_error($ch));
}
return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body];
}
private function request(string $method, string $path, ?array $json = null, ?array $multipart = null, bool $raw = false): mixed
{
foreach ([1, 2] as $attempt) {
$headers = ['Authorization: Bearer ' . $this->token($attempt === 2)];
$options = [];
if ($json !== null) {
$headers[] = 'Content-Type: application/json';
$options[CURLOPT_POSTFIELDS] = json_encode($json, JSON_THROW_ON_ERROR);
} elseif ($multipart !== null) {
$options[CURLOPT_POSTFIELDS] = $multipart;
}
$options[CURLOPT_HTTPHEADER] = $headers;
[$status, $body] = $this->send($method, '/client/v1' . $path, $options);
// The token expired early or the secret was replaced: get a new one, once.
if ($status !== 401) {
break;
}
}
if ($raw && $status < 300) {
return $body;
}
$data = json_decode($body, true);
if ($status >= 300) {
throw new ApiException($status, $data['error'] ?? "HTTP $status", $data);
}
return $data;
}
// ---------------------------------------------------------------- account
/** account:read - name, status, credits, views this month, approved sites. */
public function getAccount(): array
{
return $this->request('GET', '/account');
}
/** domains:write - replace the whole approved-sites list ('*.example.com' covers subdomains). */
public function setDomains(array $domains): array
{
return $this->request('PUT', '/domains', json: ['domains' => array_values($domains)])['domains'];
}
// ---------------------------------------------------------------- images
/** images:read - newest first; $q filters by name. Returns [images, total, offset, limit]. */
public function listImages(?string $q = null, int $limit = 25, int $offset = 0): array
{
$query = http_build_query(array_filter(['q' => $q, 'limit' => $limit, 'offset' => $offset], fn ($v) => $v !== null));
return $this->request('GET', "/images?$query");
}
/** Every image, following the pages. */
public function iterImages(?string $q = null, int $pageSize = 100): \Generator
{
for ($offset = 0; ;) {
$page = $this->listImages($q, $pageSize, $offset);
yield from $page['images'];
$offset += count($page['images']);
if (!$page['images'] || $offset >= $page['total']) {
return;
}
}
}
/** images:read - one image; 404 if it isn't in this account. */
public function getImage(string $id): array
{
return $this->request('GET', '/images/' . rawurlencode($id))['image'];
}
/** images:write - upload a file. The image's name is the filename sent ($name overrides it). */
public function uploadImage(string $path, ?string $name = null): array
{
$name ??= basename($path);
$mime = mime_content_type($path) ?: 'application/octet-stream';
return $this->request('POST', '/images', multipart: ['file' => new \CURLFile($path, $mime, $name)])['image'];
}
/** images:write */
public function deleteImage(string $id): void
{
$this->request('DELETE', '/images/' . rawurlencode($id));
}
/** links:write - a signed, watermarked full-size download. Returns [url, expiresAt]. */
public function createDownloadLink(string $id, float $expiresInHours = 168): array
{
return $this->request('POST', '/images/' . rawurlencode($id) . '/download-link', json: ['expiresInHours' => $expiresInHours]);
}
/** The script-embed snippet: the <img> loads the scrambled copy, embed.js swaps in the real one. */
public static function embedHtml(array $image, string $alt = ''): string
{
$base = explode('/i/', $image['urls']['scrambled'])[0];
return sprintf("<script src=\"%s/embed.js\"></script>\n<img src=\"%s\" data-pg=\"%s\" width=\"%d\" height=\"%d\" alt=\"%s\">",
$base, $image['urls']['scrambled'], $image['id'], $image['width'], $image['height'], htmlspecialchars($alt));
}
// ---------------------------------------------------------------- traffic and patrols
/** traffic:read - the dashboard's Traffic report. */
public function traffic(int $days = 30, ?string $imageId = null): array
{
return $this->request('GET', '/traffic?' . http_build_query(array_filter(['days' => $days, 'image' => $imageId])));
}
/** patrols:read - recent patrols and this month's allowance. */
public function listPatrols(): array
{
return $this->request('GET', '/patrols');
}
/**
* patrols:write - search the web for copies. Sends unprotected 1000 px copies of these images to
* Google and TinEye, so $allowUnprotected must be passed as true deliberately.
*/
public function startPatrol(array $imageIds, bool $allowUnprotected = false): array
{
if ($allowUnprotected !== true) {
throw new \InvalidArgumentException('startPatrol sends unprotected copies to search providers: pass allowUnprotected: true');
}
return $this->request('POST', '/patrols', json: ['imageIds' => array_values($imageIds), 'allowUnprotected' => true]);
}
/** patrols:read - the report, with every sighting. */
public function getPatrol(string $id): array
{
return $this->request('GET', '/patrols/' . rawurlencode($id))['patrol'];
}
public function waitForPatrol(string $id, int $every = 5, int $timeout = 600): array
{
$deadline = time() + $timeout;
while (true) {
$patrol = $this->getPatrol($id);
if (in_array($patrol['status'], ['done', 'failed'], true) || time() > $deadline) {
return $patrol;
}
sleep($every);
}
}
/** patrols:read - the report as CSV. */
public function patrolCsv(string $id): string
{
return $this->request('GET', '/patrols/' . rawurlencode($id) . '/report.csv', raw: true);
}
}demo.php
<?php
/**
* A tour of every Client API call. Uploads one image, works with it, and deletes it again.
*
* export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
* php demo.php path/to/image.jpg [--keep] [--patrol]
*
* --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
* from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
*/
declare(strict_types=1);
require __DIR__ . '/PixelSentinel.php';
use PixelSentinel\ApiException;
use PixelSentinel\Client;
$args = array_slice($argv, 1);
$files = array_values(array_filter($args, fn ($a) => !str_starts_with($a, '--')));
if (!$files) {
fwrite(STDERR, "Usage: php demo.php path/to/image.jpg [--keep] [--patrol]\n");
exit(1);
}
try {
$ps = Client::fromEnv();
$ps->token();
echo "1. Got an access token\n";
$account = $ps->getAccount();
printf("2. Account %s: %s, %s credits, %d views this month\n",
$account['orgName'] ?: $account['id'], $account['status'], $account['credits'], $account['viewsThisMonth']);
printf(" Approved sites: %s\n", implode(', ', $account['domains']) ?: '(none yet)');
$image = $ps->uploadImage($files[0]);
printf("3. Uploaded %s as %s (%dx%d)\n", $image['name'], $image['id'], $image['width'], $image['height']);
$image = $ps->getImage($image['id']);
printf("4. Embed it on an approved site with:\n%s\n", Client::embedHtml($image, $image['name']));
printf(" Plain image (no script): %s\n", $image['urls']['plain']);
$page = $ps->listImages(limit: 5);
printf("5. The account has %d images; newest: %s\n", $page['total'], implode(', ', array_column($page['images'], 'name')));
$link = $ps->createDownloadLink($image['id'], expiresInHours: 24);
printf("6. Full-size download link until %s:\n %s\n", $link['expiresAt'], $link['url']);
$domains = $ps->setDomains($account['domains']);
printf("7. Saved the approved sites unchanged: %s\n", json_encode($domains));
$report = $ps->traffic(days: 7);
printf("8. Traffic, last 7 days: %s\n", json_encode($report['totals']));
$patrols = $ps->listPatrols();
printf("9. %d patrols so far; %d left this month\n", count($patrols['patrols']), $patrols['allowance']['left'] ?? 0);
if (in_array('--patrol', $args, true)) {
$started = $ps->startPatrol([$image['id']], allowUnprotected: true);
$patrol = $ps->waitForPatrol($started['patrol']['id']);
printf(" Patrol %s: %s, %d copies found\n", $patrol['id'], $patrol['status'], $patrol['copies']);
echo $ps->patrolCsv($patrol['id']), "\n";
}
if (in_array('--keep', $args, true)) {
printf("10. Kept %s\n", $image['id']);
} else {
$ps->deleteImage($image['id']);
printf("10. Deleted %s\n", $image['id']);
}
} catch (ApiException $e) {
fwrite(STDERR, "Pixel Sentinel said {$e->getMessage()}\n");
exit(1);
}In samples/java:
mvn -q compile exec:java -Dexec.args="photo.jpg"src/main/java/net/pixelsentinel/sample/PixelSentinelClient.java
package net.pixelsentinel.sample;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
/**
* A small Pixel Sentinel Client API wrapper for Java 17+ (java.net.http, plus Jackson for JSON).
*
* <p>Authenticates with OAuth 2.0 client credentials, keeps the access token until shortly before it
* expires, and gets a new one once if a call answers 401. Every /client/v1 operation is here.
*
* <pre>
* var ps = new PixelSentinelClient(clientId, clientSecret);
* var page = ps.listImages("photo--", 25, 0);
* </pre>
*/
public final class PixelSentinelClient {
public static final String DEFAULT_BASE_URL = "https://argus.pixelsentinel.net";
public record ImageUrls(String scrambled, String plain) {}
public record Review(String status, String message) {}
public record Image(String id, String name, int width, int height, long storedBytes, Instant createdAt, Review review, ImageUrls urls) {}
public record ImagePage(List<Image> images, int total, int offset, int limit) {}
public record Account(String id, String orgName, String copyright, String status, String statusReason,
double credits, long viewsThisMonth, List<String> domains, String embedScript) {}
public record DownloadLink(String url, Instant expiresAt) {}
/** An error answer from the API. */
public static final class ApiException extends RuntimeException {
private static final long serialVersionUID = 1L;
public final int status;
public final String body;
ApiException(int status, String message, String body) {
super(status + ": " + message);
this.status = status;
this.body = body;
}
}
private static final ObjectMapper JSON = new ObjectMapper()
.findAndRegisterModules()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final String baseUrl, clientId, clientSecret, scope;
private String token;
private Instant expires = Instant.EPOCH;
/** @param scope ask for fewer scopes than the client has, e.g. "images:read"; null for all of them */
public PixelSentinelClient(String clientId, String clientSecret, String baseUrl, String scope) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.baseUrl = baseUrl.replaceAll("/+$", "");
this.scope = scope;
}
public PixelSentinelClient(String clientId, String clientSecret) {
this(clientId, clientSecret, DEFAULT_BASE_URL, null);
}
/** PS_CLIENT_ID, PS_CLIENT_SECRET and (optionally) PS_API_BASE. */
public static PixelSentinelClient fromEnv() {
String base = System.getenv().getOrDefault("PS_API_BASE", DEFAULT_BASE_URL);
return new PixelSentinelClient(System.getenv("PS_CLIENT_ID"), System.getenv("PS_CLIENT_SECRET"), base, null);
}
// ---------------------------------------------------------------- auth
/** POST /oauth/token with HTTP Basic (client_secret_basic). Tokens last an hour. */
public synchronized String token(boolean force) throws IOException, InterruptedException {
if (!force && token != null && Instant.now().isBefore(expires.minusSeconds(60))) return token;
String form = "grant_type=client_credentials" + (scope == null ? "" : "&scope=" + enc(scope));
String basic = Base64.getEncoder().encodeToString((enc(clientId) + ":" + enc(clientSecret)).getBytes(StandardCharsets.UTF_8));
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/oauth/token"))
.header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw error(res, "error_description");
JsonNode body = JSON.readTree(res.body());
token = body.get("access_token").asText();
expires = Instant.now().plusSeconds(body.get("expires_in").asLong());
return token;
}
private HttpResponse<String> send(String method, String path, String contentType, Supplier<HttpRequest.BodyPublisher> body)
throws IOException, InterruptedException {
HttpResponse<String> res = null;
for (int attempt = 1; attempt <= 2; attempt++) {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(baseUrl + "/client/v1" + path))
.timeout(Duration.ofSeconds(60))
.header("Authorization", "Bearer " + token(attempt == 2))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : body.get());
if (contentType != null) req.header("Content-Type", contentType);
res = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
// The token expired early or the secret was replaced: get a new one, once.
if (res.statusCode() != 401) break;
}
if (res.statusCode() >= 300) throw error(res, "error");
return res;
}
private JsonNode request(String method, String path, Object json) throws IOException, InterruptedException {
String payload = json == null ? null : JSON.writeValueAsString(json);
HttpResponse<String> res = send(method, path, payload == null ? null : "application/json",
payload == null ? null : () -> HttpRequest.BodyPublishers.ofString(payload));
return JSON.readTree(res.body());
}
private static <T> T as(JsonNode node, Class<T> type) throws IOException {
return JSON.treeToValue(node, type);
}
private static ApiException error(HttpResponse<String> res, String field) {
String message = "HTTP " + res.statusCode();
try {
JsonNode body = JSON.readTree(res.body());
if (body.hasNonNull(field)) message = body.get(field).asText();
else if (body.hasNonNull("error")) message = body.get("error").asText();
} catch (IOException ignored) {
// not JSON
}
return new ApiException(res.statusCode(), message, res.body());
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
// ---------------------------------------------------------------- account
/** account:read - name, status, credits, views this month, approved sites. */
public Account getAccount() throws IOException, InterruptedException {
return as(request("GET", "/account", null), Account.class);
}
/** domains:write - replace the whole approved-sites list ("*.example.com" covers subdomains). */
public List<String> setDomains(List<String> domains) throws IOException, InterruptedException {
JsonNode out = request("PUT", "/domains", Map.of("domains", domains));
return JSON.convertValue(out.get("domains"), JSON.getTypeFactory().constructCollectionType(List.class, String.class));
}
// ---------------------------------------------------------------- images
/** images:read - newest first; q (nullable) filters by name. */
public ImagePage listImages(String q, int limit, int offset) throws IOException, InterruptedException {
String query = "?limit=" + limit + "&offset=" + offset + (q == null ? "" : "&q=" + enc(q));
return as(request("GET", "/images" + query, null), ImagePage.class);
}
/** Every image, following the pages. */
public List<Image> allImages(String q) throws IOException, InterruptedException {
List<Image> all = new ArrayList<>();
while (true) {
ImagePage page = listImages(q, 100, all.size());
all.addAll(page.images());
if (page.images().isEmpty() || all.size() >= page.total()) return all;
}
}
/** images:read - one image; 404 if it isn't in this account. */
public Image getImage(String id) throws IOException, InterruptedException {
return as(request("GET", "/images/" + enc(id), null).get("image"), Image.class);
}
/** images:write - upload a file. The image's name is the filename sent (name, if not null, overrides it). */
public Image uploadImage(Path file, String name) throws IOException, InterruptedException {
String filename = name != null ? name : file.getFileName().toString();
String mime = URLConnection.guessContentTypeFromName(filename);
String boundary = "----pixelsentinel" + UUID.randomUUID();
byte[] head = ("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename.replace("\"", "") + "\"\r\n"
+ "Content-Type: " + (mime != null ? mime : "application/octet-stream") + "\r\n\r\n").getBytes(StandardCharsets.UTF_8);
byte[] tail = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8);
byte[] data = Files.readAllBytes(file);
HttpResponse<String> res = send("POST", "/images", "multipart/form-data; boundary=" + boundary,
() -> HttpRequest.BodyPublishers.ofByteArrays(List.of(head, data, tail)));
return as(JSON.readTree(res.body()).get("image"), Image.class);
}
/** images:write */
public void deleteImage(String id) throws IOException, InterruptedException {
request("DELETE", "/images/" + enc(id), null);
}
/** links:write - a signed, watermarked full-size download. */
public DownloadLink createDownloadLink(String id, double expiresInHours) throws IOException, InterruptedException {
return as(request("POST", "/images/" + enc(id) + "/download-link", Map.of("expiresInHours", expiresInHours)), DownloadLink.class);
}
/** The script-embed snippet: the img loads the scrambled copy, embed.js swaps in the real one. */
public static String embedHtml(Image image, String alt) {
String base = image.urls().scrambled().substring(0, image.urls().scrambled().indexOf("/i/"));
String safeAlt = alt.replace("&", "&").replace("\"", """).replace("<", "<");
return "<script src=\"" + base + "/embed.js\"></script>\n"
+ "<img src=\"" + image.urls().scrambled() + "\" data-pg=\"" + image.id() + "\" width=\"" + image.width()
+ "\" height=\"" + image.height() + "\" alt=\"" + safeAlt + "\">";
}
// ---------------------------------------------------------------- traffic and patrols
/** traffic:read - the dashboard's Traffic report (totals, daily, breakdown). imageId may be null. */
public JsonNode traffic(int days, String imageId) throws IOException, InterruptedException {
return request("GET", "/traffic?days=" + days + (imageId == null ? "" : "&image=" + enc(imageId)), null);
}
/** patrols:read - recent patrols and this month's allowance. */
public JsonNode listPatrols() throws IOException, InterruptedException {
return request("GET", "/patrols", null);
}
/**
* patrols:write - search the web for copies. Sends unprotected 1000 px copies of these images to
* Google and TinEye, so allowUnprotected must be passed as true deliberately.
*/
public JsonNode startPatrol(List<String> imageIds, boolean allowUnprotected) throws IOException, InterruptedException {
if (!allowUnprotected) throw new IllegalArgumentException("startPatrol sends unprotected copies to search providers: pass allowUnprotected = true");
return request("POST", "/patrols", Map.of("imageIds", imageIds, "allowUnprotected", true));
}
/** patrols:read - the report, with every sighting. */
public JsonNode getPatrol(String id) throws IOException, InterruptedException {
return request("GET", "/patrols/" + enc(id), null).get("patrol");
}
public JsonNode waitForPatrol(String id, Duration every, Duration timeout) throws IOException, InterruptedException {
Instant deadline = Instant.now().plus(timeout);
while (true) {
JsonNode patrol = getPatrol(id);
String status = patrol.get("status").asText();
if (status.equals("done") || status.equals("failed") || Instant.now().isAfter(deadline)) return patrol;
Thread.sleep(every.toMillis());
}
}
/** patrols:read - the report as CSV. */
public String patrolCsv(String id) throws IOException, InterruptedException {
return send("GET", "/patrols/" + enc(id) + "/report.csv", null, null).body();
}
}src/main/java/net/pixelsentinel/sample/Demo.java
package net.pixelsentinel.sample;
import com.fasterxml.jackson.databind.JsonNode;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* A tour of every Client API call. Uploads one image, works with it, and deletes it again.
*
* <pre>
* export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
* mvn -q compile exec:java -Dexec.args="path/to/image.jpg [--keep] [--patrol]"
* </pre>
*
* --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
* from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
*/
public final class Demo {
public static void main(String[] args) throws Exception {
List<String> flags = Arrays.asList(args);
String file = flags.stream().filter(a -> !a.startsWith("--")).findFirst().orElse(null);
if (file == null) {
System.err.println("Usage: Demo path/to/image.jpg [--keep] [--patrol]");
System.exit(1);
}
try {
run(file, flags);
} catch (PixelSentinelClient.ApiException e) {
System.err.println("Pixel Sentinel said " + e.getMessage());
System.exit(1);
}
}
static void run(String file, List<String> flags) throws Exception {
var ps = PixelSentinelClient.fromEnv();
ps.token(false);
System.out.println("1. Got an access token");
var account = ps.getAccount();
System.out.printf("2. Account %s: %s, %s credits, %d views this month%n",
account.orgName().isEmpty() ? account.id() : account.orgName(), account.status(), account.credits(), account.viewsThisMonth());
System.out.printf(" Approved sites: %s%n", account.domains().isEmpty() ? "(none yet)" : String.join(", ", account.domains()));
var image = ps.uploadImage(Path.of(file), null);
System.out.printf("3. Uploaded %s as %s (%dx%d)%n", image.name(), image.id(), image.width(), image.height());
image = ps.getImage(image.id());
System.out.printf("4. Embed it on an approved site with:%n%s%n", PixelSentinelClient.embedHtml(image, image.name()));
System.out.printf(" Plain image (no script): %s%n", image.urls().plain());
var page = ps.listImages(null, 5, 0);
System.out.printf("5. The account has %d images; newest: %s%n", page.total(),
page.images().stream().map(PixelSentinelClient.Image::name).collect(Collectors.joining(", ")));
var link = ps.createDownloadLink(image.id(), 24);
System.out.printf("6. Full-size download link until %s:%n %s%n", link.expiresAt(), link.url());
var domains = ps.setDomains(account.domains());
System.out.printf("7. Saved the approved sites unchanged: %s%n", domains);
JsonNode traffic = ps.traffic(7, null);
System.out.printf("8. Traffic, last 7 days: %s%n", traffic.get("totals"));
JsonNode patrols = ps.listPatrols();
System.out.printf("9. %d patrols so far; %d left this month%n",
patrols.get("patrols").size(), patrols.path("allowance").path("left").asInt(0));
if (flags.contains("--patrol")) {
String patrolId = ps.startPatrol(List.of(image.id()), true).get("patrol").get("id").asText();
JsonNode patrol = ps.waitForPatrol(patrolId, Duration.ofSeconds(5), Duration.ofMinutes(10));
System.out.printf(" Patrol %s: %s, %d copies found%n", patrolId, patrol.get("status").asText(), patrol.get("copies").asInt());
System.out.println(ps.patrolCsv(patrolId));
}
if (flags.contains("--keep")) {
System.out.printf("10. Kept %s%n", image.id());
} else {
ps.deleteImage(image.id());
System.out.printf("10. Deleted %s%n", image.id());
}
}
}pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>net.pixelsentinel.sample</groupId>
<artifactId>pixelsentinel-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>2.18.2</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>net.pixelsentinel.sample.Demo</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>In samples/curl:
./demo.sh photo.jpgdemo.sh
#!/usr/bin/env bash
# A tour of every Client API call with curl and jq. Uploads one image, works with it, and deletes it again.
#
# export PS_CLIENT_ID=psc_… PS_CLIENT_SECRET=pss_… # PS_API_BASE defaults to production
# ./demo.sh path/to/image.jpg [--keep] [--patrol]
#
# --keep leaves the image in the account; --patrol also runs a patrol on it, which uses one patrol
# from the plan's allowance and sends an unprotected 1000 px copy to Google and TinEye.
set -euo pipefail
FILE=${1:?Usage: ./demo.sh path/to/image.jpg [--keep] [--patrol]}
BASE=${PS_API_BASE:-https://argus.pixelsentinel.net}
: "${PS_CLIENT_ID:?Set PS_CLIENT_ID}" "${PS_CLIENT_SECRET:?Set PS_CLIENT_SECRET}"
has() { local flag=$1; shift; [[ " $* " == *" $flag "* ]]; }
# 1. Client credentials -> access token (an hour). -u sends HTTP Basic (client_secret_basic).
TOKEN=$(curl -sS --fail-with-body -u "$PS_CLIENT_ID:$PS_CLIENT_SECRET" \
-d grant_type=client_credentials "$BASE/oauth/token" | jq -r .access_token)
echo "1. Got an access token"
# Every /client/v1 call: the bearer token, JSON errors shown as they come.
api() { curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN" "$@"; }
# 2. account:read
ACCOUNT=$(api "$BASE/client/v1/account")
jq -r '"2. Account \(if .orgName == "" then .id else .orgName end): \(.status), \(.credits) credits, \(.viewsThisMonth) views this month",
" Approved sites: \(if (.domains | length) > 0 then (.domains | join(", ")) else "(none yet)" end)"' <<<"$ACCOUNT"
# 3. images:write - multipart upload; the image's name is the filename sent
IMAGE=$(api -F "file=@$FILE" "$BASE/client/v1/images" | jq .image)
ID=$(jq -r .id <<<"$IMAGE")
jq -r '"3. Uploaded \(.name) as \(.id) (\(.width)x\(.height))"' <<<"$IMAGE"
# 4. images:read - one image, and the embed snippet for it
IMAGE=$(api "$BASE/client/v1/images/$ID" | jq .image)
jq -r --arg base "$BASE" '"4. Embed it on an approved site with:",
"<script src=\"\($base)/embed.js\"></script>",
"<img src=\"\(.urls.scrambled)\" data-pg=\"\(.id)\" width=\"\(.width)\" height=\"\(.height)\" alt=\"\(.name)\">",
" Plain image (no script): \(.urls.plain)"' <<<"$IMAGE"
# 5. images:read - a page of images, newest first (add &q=photo-- to filter by name)
api "$BASE/client/v1/images?limit=5" | jq -r '"5. The account has \(.total) images; newest: \([.images[].name] | join(", "))"'
# 6. links:write - a signed, watermarked full-size download link
api -H 'Content-Type: application/json' -d '{"expiresInHours":24}' "$BASE/client/v1/images/$ID/download-link" |
jq -r '"6. Full-size download link until \(.expiresAt):\n \(.url)"'
# 7. domains:write - replace the approved-sites list (here: the same list again)
jq '{domains}' <<<"$ACCOUNT" | api -X PUT -H 'Content-Type: application/json' --data-binary @- "$BASE/client/v1/domains" |
jq -c '"7. Saved the approved sites unchanged: \(.domains)"' -r
# 8. traffic:read
api "$BASE/client/v1/traffic?days=7" | jq -c '"8. Traffic, last 7 days: \(.totals)"' -r
# 9. patrols:read, and patrols:write with --patrol
api "$BASE/client/v1/patrols" | jq -r '"9. \(.patrols | length) patrols so far; \(.allowance.left // 0) left this month"'
if has --patrol "$@"; then
PATROL=$(api -H 'Content-Type: application/json' -d "{\"imageIds\":[\"$ID\"],\"allowUnprotected\":true}" \
"$BASE/client/v1/patrols" | jq -r .patrol.id)
until jq -e '.patrol.status == "done" or .patrol.status == "failed"' >/dev/null \
<<<"$(api "$BASE/client/v1/patrols/$PATROL")"; do sleep 5; done
api "$BASE/client/v1/patrols/$PATROL" | jq -r '.patrol | " Patrol \(.id): \(.status), \(.copies) copies found"'
api "$BASE/client/v1/patrols/$PATROL/report.csv"
fi
# 10. images:write - delete
if has --keep "$@"; then
echo "10. Kept $ID"
else
api -X DELETE "$BASE/client/v1/images/$ID" >/dev/null
echo "10. Deleted $ID"
fiThings worth knowing
- One account per client. A token only ever sees its own account; another account's image answers 404.
- Image names come from the upload's filename. This site files images into galleries with a name prefix (
photo--…) and lists a gallery with?q=photo--. - Read-only when suspended. Writes answer 403 with
"readOnly": truewhile the account is suspended or has terms to accept. - Limits. 600 requests a minute per IP, 60 uploads a minute, 20 patrol requests an hour; over the limit answers 429.
- Credits. 1 credit = 1,000 real-image views. Scrambled copies are free; a download link costs 10 views or more.
The full reference is the Pixel Sentinel API reference (sign in to your Pixel Sentinel account to read it).